Files
homeclaw/web-ui/html/language-app.html
T
kimandClaude Opus 5 17d6f2824e fix: 언어앱 커스텀 카테고리를 서버에 저장 + 카드 저장 실패를 삼키지 않기
"어휘카드를 추가해도 자꾸 없어진다" 리포트. 카드는 custom-cards.json으로
이미 서버에 있었는데 **카테고리는 localStorage에만** 있었다. 브라우저
데이터를 지우거나 다른 기기에서 열면 카테고리 버튼이 통째로 사라지고,
그 카테고리로 만든 카드는 분류 탭에서 안 보인다(전체 탭에만 남아
"없어진" 것처럼 보인다).

custom-categories.json에 언어별로 저장한다. 병합 방식인 게 중요하다 —
클라이언트가 보낸 목록으로 덮어쓰면 localStorage가 빈 새 기기에서 앱을
한 번 여는 것만으로 서버 카테고리가 전부 날아간다. 빈 배열을 보내도
기존 값이 유지되는 걸 확인했다. 로컬에만 있던 카테고리는 첫 접속 때
서버로 올라가므로 지금 브라우저에 남아 있는 건 손실 없이 넘어간다.

같이 고친 것: saveCustomCards가 .catch(()=>{})로 실패를 삼키면서 호출부는
"✅ N개 추가되었습니다"를 띄우고 있었다. 세션 만료로 POST가 401이 나도
사용자는 성공한 줄 알고, 새로고침하면 카드가 없다 — 리포트된 증상과
정확히 같은 모양이다. 이제 저장을 기다린 뒤 결과에 맞는 문구를 띄운다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:25:19 +09:00

2047 lines
114 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="doc-title">🇺🇿 우즈베크어 학습</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&display=swap" rel="stylesheet">
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>
try {
var _t = localStorage.getItem('homeclaw_theme') || 'dark';
document.documentElement.setAttribute('data-theme', _t);
} catch(e) {}
</script>
<style>
html, body { height: 100%; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', system-ui, sans-serif; display: flex; flex-direction: column; overflow: hidden; height: 100dvh; }
/* ── Header ── */
.lg-header { display: flex; align-items: center; gap: 10px; padding: 7px 14px; background: var(--panel); border-bottom: 1px solid var(--line); flex-shrink: 0; }
.lg-header h1 { font-size: 14px; font-weight: 700; margin: 0; }
.lg-header .sub { font-size: 11px; color: var(--muted); }
.lg-theme-btn { background: none; border: 1.5px solid var(--line); border-radius: 6px; padding: 3px 9px; font-size: 12px; color: var(--muted); cursor: pointer; transition: .15s; }
.lg-theme-btn.active, .lg-theme-btn:hover { border-color: var(--brand); color: var(--brand2, var(--brand)); }
.lg-lang-btn { background: none; border: 1.5px solid var(--line); border-radius: 6px; padding: 3px 8px; font-size: 14px; cursor: pointer; transition: .15s; line-height: 1; opacity: .5; }
.lg-lang-btn.active { border-color: #22c55e; opacity: 1; background: rgba(34,197,94,.1); }
.lg-lang-btn:hover { opacity: 1; }
.stat-chip { font-size: 11px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); border-radius: 20px; padding: 2px 10px; white-space: nowrap; }
/* ── Layout ── */
.lg-layout { flex: 1; display: flex; min-height: 0; }
/* ── Interpreter panel (right dock) ── */
.lg-interp { width: 320px; flex-shrink: 0; display: none; flex-direction: column; border-left: 1px solid var(--line); background: var(--panel); min-height: 0; }
.lg-interp.open { display: flex; }
.lg-interp-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--panel-2); flex-shrink: 0; }
.lg-interp-head b { font-size: 13px; flex: 1; }
.lg-interp-close { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; padding: 2px 6px; }
.lg-interp-close:hover { color: var(--text); }
.lg-interp-msgs { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 10px; }
.lg-interp-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 12px; line-height: 1.6; }
.im-bubble { max-width: 88%; border-radius: 12px; padding: 8px 11px; font-size: 13px; line-height: 1.5; }
.im-user { align-self: flex-end; background: #166534; color: #fff; border-bottom-right-radius: 3px; }
.im-reply { align-self: flex-start; background: var(--panel-2); border: 1px solid var(--line); border-bottom-left-radius: 3px; }
.im-reply .im-rom { font-size: 11px; color: var(--muted); font-style: italic; margin-top: 3px; }
.im-reply .im-lang { font-size: 10px; color: #22c55e; font-weight: 700; margin-bottom: 3px; text-transform: uppercase; letter-spacing: .04em; }
.im-reply .im-tts { margin-left: 6px; }
.im-loading { align-self: flex-start; color: var(--muted); font-size: 12px; padding: 4px 0; }
.teacher-md-p { margin: 0 0 6px; }
.teacher-md-p:last-child { margin-bottom: 0; }
.teacher-md-heading { font-weight: 700; margin: 8px 0 4px; }
.teacher-md-heading:first-child { margin-top: 0; }
.teacher-md-list { margin: 0 0 6px; padding-left: 18px; }
.teacher-md-quote { margin: 0 0 6px; padding: 4px 10px; border-left: 3px solid var(--line); color: var(--muted); }
.teacher-md-hr { border: none; border-top: 1px solid var(--line); margin: 8px 0; }
.teacher-md-table { border-collapse: collapse; width: 100%; margin: 0 0 6px; font-size: 12px; }
.teacher-md-table th, .teacher-md-table td { border: 1px solid var(--line); padding: 4px 6px; text-align: left; }
.teacher-md-table th { background: var(--panel); }
.lg-interp-input-row { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--line); flex-shrink: 0; background: var(--panel-2); }
.lg-interp-input-row textarea { flex: 1; resize: none; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 13px; color: var(--text); outline: none; font-family: inherit; height: 38px; max-height: 90px; }
.lg-interp-input-row textarea:focus { border-color: var(--brand); }
.lg-interp-send { background: #166534; border: 1px solid #22c55e; color: #fff; border-radius: 8px; padding: 0 14px; font-size: 13px; font-weight: 700; cursor: pointer; flex-shrink: 0; }
.lg-interp-send:hover { filter: brightness(1.15); }
.lg-interp-send:disabled { opacity: .5; cursor: default; }
.lg-interp-toggle-btn { background: var(--panel); border: 1px solid var(--line); border-radius: 7px; padding: 5px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit; transition: .15s; white-space: nowrap; }
.lg-interp-toggle-btn:hover, .lg-interp-toggle-btn.active { border-color: #22c55e; color: #22c55e; }
.lg-mic-btn { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; width: 38px; height: 38px; flex-shrink: 0; font-size: 15px; cursor: pointer; }
.lg-mic-btn:hover { border-color: #22c55e; }
.lg-mic-btn.recording { border-color: #ef4444; color: #ef4444; animation: lg-mic-pulse .8s infinite alternate; background: rgba(239,68,68,.08); }
@keyframes lg-mic-pulse { from { opacity: .6; } to { opacity: 1; } }
.lg-call-btn { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; padding: 2px 6px; }
.lg-call-btn:hover { color: #22c55e; }
.lg-call-btn.active { color: #ef4444; animation: lg-mic-pulse .8s infinite alternate; }
.lg-call-status { font-size: 11px; color: #22c55e; padding: 0 12px 8px; min-height: 14px; flex-shrink: 0; }
@media(max-width:1024px){
.lg-interp { position: fixed; top: 0; right: 0; bottom: 0; z-index: 700; width: 88vw; max-width: 360px; box-shadow: -4px 0 24px rgba(0,0,0,.45); }
}
/* ── Sidebar ── */
.lg-sidebar { width: 220px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--panel); min-height: 0; }
.csb-tab-bar { display: flex; border-bottom: 2px solid var(--line); flex-shrink: 0; background: var(--panel-2); }
.lg-nav-btn { flex: 1; background: none; border: none; border-bottom: 2px solid transparent; padding: 9px 4px; font-size: 11px; font-weight: 700; color: var(--muted); cursor: pointer; transition: .15s; font-family: inherit; margin-bottom: -2px; }
.lg-nav-btn:hover:not(.active) { color: var(--text); background: rgba(34,197,94,.05); }
.lg-nav-btn.active { color: #22c55e; border-bottom-color: #22c55e; background: rgba(34,197,94,.08); }
.lg-nav-content { flex: 1; overflow-y: auto; padding: 8px 0; }
.lg-section-label { font-size: 10px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; padding: 8px 12px 4px; }
.lg-lesson-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; cursor: pointer; font-size: 12px; color: var(--muted); transition: .12s; border-left: 3px solid transparent; }
.lg-lesson-item:hover { background: rgba(34,197,94,.06); color: var(--text); }
.lg-lesson-item.active { background: rgba(34,197,94,.1); color: #22c55e; border-left-color: #22c55e; font-weight: 700; }
.lg-lesson-item.done .lg-done-badge { color: #22c55e; }
.lg-done-badge { font-size: 12px; flex-shrink: 0; color: var(--muted); }
.lg-cat-btn { display: block; width: 100%; text-align: left; background: none; border: none; padding: 6px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit; transition: .12s; border-left: 3px solid transparent; }
.lg-cat-btn:hover { background: rgba(34,197,94,.06); color: var(--text); }
.lg-cat-btn.active { color: #22c55e; border-left-color: #22c55e; background: rgba(34,197,94,.1); font-weight: 700; }
/* ── Main area ── */
.lg-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.lg-panel { display: none; flex: 1; flex-direction: column; overflow: hidden; }
.lg-panel.active { display: flex; }
/* ── Toolbar ── */
.lg-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--panel-2); border-bottom: 1px solid var(--line); flex-shrink: 0; flex-wrap: wrap; }
.lg-toolbar-title { font-size: 13px; font-weight: 700; flex: 1; }
.lg-btn { background: var(--panel); border: 1px solid var(--line); border-radius: 7px; padding: 5px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit; transition: .15s; white-space: nowrap; }
.lg-btn:hover { border-color: var(--brand); color: var(--brand); }
.lg-btn.primary { background: #166534; border-color: #22c55e; color: #fff; }
.lg-btn.primary:hover { filter: brightness(1.15); }
.lg-btn.danger { background: var(--panel); border-color: #ef4444; color: #ef4444; }
.lg-btn.success { background: #14532d; border-color: #22c55e; color: #22c55e; }
.lg-select { background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 5px 8px; font-size: 12px; color: var(--text); cursor: pointer; font-family: inherit; }
/* ── Flashcard ── */
.card-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px 16px; overflow: hidden; }
.card-stage { perspective: 1200px; width: 100%; max-width: 480px; height: 240px; cursor: pointer; }
.card-inner { width: 100%; height: 100%; position: relative; transform-style: preserve-3d; transition: transform 0.45s cubic-bezier(.4,0,.2,1); }
.card-inner.flipped { transform: rotateY(180deg); }
.card-face { position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; border: 1px solid var(--line); box-shadow: 0 4px 24px rgba(0,0,0,.18); }
.card-front { background: var(--panel); }
.card-back { background: #0d1f1a; border-color: #1a4a35; transform: rotateY(180deg); }
.card-uz { font-size: 32px; font-weight: 800; color: var(--text); margin-bottom: 6px; }
.card-rom { font-size: 16px; color: var(--muted); font-style: italic; }
.card-ko { font-size: 28px; font-weight: 700; color: #22c55e; margin-bottom: 6px; }
.card-hint { font-size: 12px; color: #4a7a5c; }
.card-actions { display: flex; gap: 12px; margin-top: 20px; justify-content: center; }
.card-counter { font-size: 12px; color: var(--muted); text-align: center; margin-top: 8px; }
.known-bar { display: flex; align-items: center; gap: 16px; margin-top: 12px; }
.known-bar span { font-size: 12px; color: var(--muted); }
.known-bar .known-val { color: #22c55e; font-weight: 700; }
.known-bar .unknown-val { color: #f87171; font-weight: 700; }
/* ── Flashcard tile grid ── */
.card-grid { flex: 1; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-auto-rows: 1fr; gap: 20px; padding: 24px; width: 100%; height: 100%; overflow-y: auto; align-content: stretch; box-sizing: border-box; }
.tile-stage { perspective: 1000px; min-height: 220px; cursor: pointer; }
.tile-inner { width: 100%; height: 100%; position: relative; transform-style: preserve-3d; transition: transform 0.4s cubic-bezier(.4,0,.2,1); }
.tile-inner.flipped { transform: rotateY(180deg); }
.tile-face { position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; border: 1px solid var(--line); box-shadow: 0 4px 18px rgba(0,0,0,.16); text-align: center; }
.tile-front { background: var(--panel); }
.tile-back { background: #0d1f1a; border-color: #1a4a35; transform: rotateY(180deg); }
.tile-uz { font-size: 26px; font-weight: 800; color: var(--text); word-break: keep-all; }
.tile-rom { font-size: 15px; color: var(--muted); font-style: italic; margin-top: 8px; }
.tile-ko { font-size: 26px; font-weight: 700; color: #22c55e; word-break: keep-all; }
.tile-cat { position: absolute; top: 8px; right: 10px; font-size: 11px; color: var(--muted); }
.tile-tts-btn { font-size: 20px; margin-top: 8px; }
/* ── Vocab table ── */
.vocab-wrap { flex: 1; overflow: auto; padding: 0; }
.vocab-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.vocab-table th { background: var(--panel-2); padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; position: sticky; top: 0; border-bottom: 2px solid var(--line); }
.vocab-table td { padding: 9px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.vocab-table tr:hover td { background: rgba(34,197,94,.04); }
.vocab-uz { font-weight: 700; font-size: 15px; color: var(--text); }
.vocab-rom { color: var(--muted); font-style: italic; font-size: 12px; }
.vocab-cat { display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 20px; background: rgba(34,197,94,.12); color: #22c55e; font-weight: 700; }
.search-row { display: flex; gap: 8px; align-items: center; padding: 10px 16px; background: var(--panel-2); border-bottom: 1px solid var(--line); flex-shrink: 0; }
.search-row input { flex: 1; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px; font-size: 13px; color: var(--text); outline: none; font-family: inherit; }
.search-row input:focus { border-color: var(--brand); }
/* ── Quiz ── */
.quiz-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding: 28px 16px; overflow-y: auto; }
.quiz-setup-box, .quiz-result-box { width: 100%; max-width: 420px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 28px; display: flex; flex-direction: column; gap: 16px; }
.quiz-q-box { width: 100%; max-width: 520px; }
.quiz-word { font-size: 36px; font-weight: 800; text-align: center; margin: 20px 0 6px; color: var(--text); }
.quiz-sub { font-size: 14px; color: var(--muted); text-align: center; margin-bottom: 24px; }
.quiz-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px; }
.quiz-opt { background: var(--panel); border: 1.5px solid var(--line); border-radius: 10px; padding: 14px; font-size: 14px; font-weight: 600; color: var(--text); cursor: pointer; text-align: center; transition: .15s; font-family: inherit; }
.quiz-opt:hover:not(:disabled) { border-color: var(--brand); background: rgba(34,197,94,.06); }
.quiz-opt.correct { border-color: #22c55e; background: rgba(34,197,94,.12); color: #22c55e; }
.quiz-opt.wrong { border-color: #ef4444; background: rgba(239,68,68,.1); color: #ef4444; }
.quiz-fill-row { display: flex; gap: 8px; margin-bottom: 16px; }
.quiz-fill { flex: 1; background: var(--panel); border: 1.5px solid var(--line); border-radius: 10px; padding: 12px 14px; font-size: 16px; color: var(--text); outline: none; font-family: inherit; }
.quiz-fill:focus { border-color: var(--brand); }
.quiz-fill.correct { border-color: #22c55e; background: rgba(34,197,94,.08); }
.quiz-fill.wrong { border-color: #ef4444; background: rgba(239,68,68,.08); }
.quiz-feedback { text-align: center; font-size: 14px; font-weight: 700; min-height: 22px; padding: 4px 0; }
.quiz-feedback.correct { color: #22c55e; }
.quiz-feedback.wrong { color: #f87171; }
.quiz-progress-bar-wrap { width: 100%; max-width: 520px; margin-bottom: 12px; }
.quiz-progress-bar { height: 4px; background: var(--panel-2); border-radius: 4px; overflow: hidden; }
.quiz-progress-fill { height: 100%; background: #22c55e; border-radius: 4px; transition: width .4s; }
.score-circle { width: 100px; height: 100px; border-radius: 50%; border: 4px solid #22c55e; display: flex; flex-direction: column; align-items: center; justify-content: center; margin: 0 auto 16px; }
.score-circle .score-n { font-size: 30px; font-weight: 800; color: #22c55e; line-height: 1; }
.score-circle .score-d { font-size: 13px; color: var(--muted); }
.wrong-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; max-height: 200px; overflow-y: auto; }
.wrong-item { display: flex; align-items: center; gap: 10px; padding: 7px 10px; background: var(--panel-2); border-radius: 8px; font-size: 13px; }
.wrong-item .wi-uz { font-weight: 700; color: var(--text); }
.wrong-item .wi-ko { color: var(--muted); }
/* ── Lessons ── */
.lessons-grid { flex: 1; overflow-y: auto; padding: 16px; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; align-content: start; }
.lesson-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; cursor: pointer; transition: .15s; position: relative; }
.lesson-card:hover { border-color: var(--brand); box-shadow: 0 2px 12px rgba(34,197,94,.1); }
.lesson-card.active { border-color: #22c55e; }
.lesson-card h3 { font-size: 13px; font-weight: 700; margin: 0 0 6px; }
.lesson-card p { font-size: 11px; color: var(--muted); margin: 0 0 10px; line-height: 1.5; }
.lesson-done-badge { position: absolute; top: 10px; right: 10px; font-size: 14px; }
.lesson-prog-bar { height: 3px; background: var(--panel-2); border-radius: 3px; overflow: hidden; }
.lesson-prog-fill { height: 100%; background: #22c55e; border-radius: 3px; }
.lesson-detail-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.lesson-steps { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.step-explain { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; font-size: 14px; line-height: 1.7; }
.step-explain .step-label { font-size: 10px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
.step-example { background: #0d1f1a; border: 1px solid #1a4a35; border-radius: 10px; padding: 14px 16px; }
.step-example .ex-uz { font-size: 18px; font-weight: 700; color: #22c55e; margin-bottom: 4px; }
.step-example .ex-rom { font-size: 13px; color: #4a9a68; font-style: italic; margin-bottom: 4px; }
.step-example .ex-ko { font-size: 14px; color: #86c9a0; }
.step-practice { background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.step-practice .prac-q { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 10px; }
.step-practice input { width: 100%; background: var(--panel); border: 1.5px solid var(--line); border-radius: 7px; padding: 8px 12px; font-size: 14px; color: var(--text); outline: none; font-family: inherit; box-sizing: border-box; }
.step-practice input:focus { border-color: var(--brand); }
.lesson-nav { display: flex; align-items: center; gap: 10px; padding: 10px 20px; background: var(--panel-2); border-top: 1px solid var(--line); flex-shrink: 0; }
.step-counter { font-size: 12px; color: var(--muted); flex: 1; text-align: center; }
/* ── TTS ── */
.tts-btn { background: none; border: none; cursor: pointer; font-size: 16px; padding: 2px 4px; border-radius: 5px; line-height: 1; transition: .15s; opacity: .6; flex-shrink: 0; }
.tts-btn:hover { opacity: 1; background: rgba(34,197,94,.1); }
.tts-btn.playing { opacity: 1; animation: tts-pulse .7s infinite alternate; }
@keyframes tts-pulse { from { transform: scale(1); } to { transform: scale(1.2); } }
.tts-gender { background: var(--panel-2); border: 1px solid var(--line); border-radius: 20px; padding: 2px 8px; font-size: 11px; color: var(--muted); cursor: pointer; }
.tts-gender.active { border-color: #22c55e; color: #22c55e; }
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
/* ── Mobile ── */
@media(max-width:1024px){
/* Header: wrap to 2 rows */
.lg-header { flex-wrap:wrap; padding:6px 10px; gap:5px; }
.lg-header h1 { font-size:13px; }
.lg-header .sub { display:none; }
/* hide theme & voice on mobile (secondary settings) */
.lg-header > div:has(.lg-theme-btn),
.lg-header > div:has(.tts-gender) { display:none; }
.lg-header > a[href="/"] { display:none; }
/* stat chip stays, moves to right */
/* Sidebar: overlay drawer */
.lg-sidebar {
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:80vw;max-width:280px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.45);
}
.lg-sidebar.mob-open { transform:translateX(0); }
/* hide sidebar nav tabs on mobile — bottom tabbar replaces them */
.csb-tab-bar { display:none; }
/* all sb-* content visible in drawer (setNav handles show/hide) */
#mob-backdrop { display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.5);z-index:499; }
#mob-backdrop.show { display:block; }
/* Main takes full width */
.lg-main { width:100%; }
/* bottom padding so content clears tabbar */
.card-wrap, .vocab-wrap, .quiz-wrap, .lessons-grid, .lesson-steps { padding-bottom:64px; }
/* Bottom tabbar */
#mob-tabbar {
display:flex !important;
position:fixed;bottom:0;left:0;right:0;z-index:600;
background:var(--panel);border-top:1px solid var(--line);
height:52px;
}
/* Add filter button to toolbar (hidden on desktop) */
.mob-filter-btn { display:flex !important; }
/* iOS zoom prevention */
input, textarea, select { font-size:16px !important; }
.quiz-fill { font-size:16px !important; }
}
@media(min-width:1025px){
#mob-tabbar { display:none !important; }
#mob-backdrop { display:none !important; }
.mob-filter-btn { display:none !important; }
}
</style>
</head>
<body>
<!-- Header -->
<div class="lg-header">
<span style="font-size:18px" id="hdr-flag">🇺🇿</span>
<h1 id="hdr-title">우즈베크어 학습</h1>
<div style="display:flex;gap:3px" id="lang-switcher"></div>
<span class="sub" id="hdr-progress">로딩 중...</span>
<div style="flex:1"></div>
<span class="stat-chip" id="stat-known">알고 있는 단어 0개</span>
<div style="display:flex;gap:4px;align-items:center">
<span style="font-size:11px;color:var(--muted)">음성:</span>
<button class="tts-gender active" id="gender-female" onclick="setGender('female')">👩 Madina</button>
<button class="tts-gender" id="gender-male" onclick="setGender('male')">👨 Sardor</button>
</div>
<div style="display:flex;gap:4px">
<button class="lg-theme-btn" onclick="setTheme('dark')">🌙 다크</button>
<button class="lg-theme-btn" onclick="setTheme('light')">☀️ 라이트</button>
<button class="lg-theme-btn" onclick="setTheme('warm')">🌿 웜</button>
</div>
<button class="lg-interp-toggle-btn" id="interp-toggle-btn" onclick="toggleInterpreter()">🗣️ 통역사</button>
<button class="lg-interp-toggle-btn" id="teacher-toggle-btn" onclick="toggleTeacher()">👨‍🏫 선생님</button>
<a href="/" style="font-size:12px;color:var(--muted);text-decoration:none;margin-left:4px">← 메인</a>
</div>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-flash" onclick="mobNav('flash')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/></svg>
<span>카드</span>
</button>
<button class="mob-tab" id="mobtab-vocab" onclick="mobNav('vocab')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<span>어휘</span>
</button>
<button class="mob-tab" id="mobtab-quiz" onclick="mobNav('quiz')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>퀴즈</span>
</button>
<button class="mob-tab" id="mobtab-lessons" onclick="mobNav('lessons')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
<span>레슨</span>
</button>
</div>
<div class="lg-layout">
<!-- Sidebar -->
<div class="lg-sidebar">
<div class="csb-tab-bar">
<button class="lg-nav-btn active" id="nav-flash" onclick="setNav('flash')">🃏 카드</button>
<button class="lg-nav-btn" id="nav-vocab" onclick="setNav('vocab')">📖 어휘</button>
<button class="lg-nav-btn" id="nav-quiz" onclick="setNav('quiz')">✏️ 퀴즈</button>
<button class="lg-nav-btn" id="nav-lessons" onclick="setNav('lessons')">📚 레슨</button>
</div>
<!-- Sidebar content per nav -->
<div class="lg-nav-content" id="sb-flash">
<div class="lg-section-label">카테고리</div>
<div id="cat-buttons-flash"></div>
<button class="lg-cat-btn" onclick="addCategory()" style="border-style:dashed">+ 카테고리 추가</button>
<div class="lg-section-label" style="margin-top:8px">도구</div>
<button class="lg-cat-btn" onclick="shuffleDeck()">🔀 섞기</button>
<button class="lg-cat-btn" onclick="resetDeck()">↺ 다시 시작</button>
<button class="lg-cat-btn" onclick="generateMoreCards()">✨ AI 카드 생성</button>
</div>
<div class="lg-nav-content" id="sb-vocab" style="display:none">
<div class="lg-section-label">필터</div>
<div id="cat-buttons-vocab"></div>
<button class="lg-cat-btn" onclick="addCategory()" style="border-style:dashed">+ 카테고리 추가</button>
</div>
<div class="lg-nav-content" id="sb-quiz" style="display:none">
<div class="lg-section-label">퀴즈 설정</div>
<div id="cat-buttons-quiz"></div>
</div>
<div class="lg-nav-content" id="sb-lessons" style="display:none">
<div class="lg-section-label">레슨 목록</div>
<div id="lesson-nav-list"></div>
</div>
</div>
<!-- Main -->
<div class="lg-main">
<!-- ═══ FLASHCARD PANEL ═══ -->
<div class="lg-panel active" id="panel-flash">
<div class="lg-toolbar">
<button class="lg-btn mob-filter-btn" style="display:none;padding:4px 8px" onclick="mobOpenFilter()" title="카테고리">☰</button>
<span class="lg-toolbar-title" id="flash-title">플래시카드 — 전체</span>
<span style="font-size:12px;color:var(--muted)" id="flash-counter">0 / 0</span>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--muted);cursor:pointer">
<input type="checkbox" id="show-rom" checked onchange="renderFlashView()" style="accent-color:#22c55e">발음 표시
</label>
<button class="lg-btn" id="view-toggle-btn" onclick="toggleViewMode()">🔄 한장씩 보기</button>
</div>
<div class="card-wrap" id="card-wrap-single" style="display:none">
<div class="card-stage" onclick="flipCard()" id="card-stage">
<div class="card-inner" id="card-inner">
<div class="card-face card-front">
<div class="card-uz" id="card-uz">—</div>
<div class="card-rom" id="card-rom"></div>
<button class="tts-btn" id="card-tts-btn" onclick="event.stopPropagation();speakCurrent()" title="발음 듣기">🔊</button>
</div>
<div class="card-face card-back">
<div class="card-ko" id="card-ko">—</div>
<div class="card-hint">카드를 탭해서 앞면으로</div>
</div>
</div>
</div>
<div class="card-actions">
<button class="lg-btn danger" onclick="markUnknown()">❌ 모르는 단어</button>
<button class="lg-btn" onclick="flipCard()">🔄 뒤집기</button>
<button class="lg-btn success" onclick="markKnown()">✅ 아는 단어</button>
</div>
<div class="card-counter" id="card-counter"></div>
<div class="known-bar">
<span>알고 있음: <span class="known-val" id="known-count">0</span></span>
<span>학습 필요: <span class="unknown-val" id="unknown-count">0</span></span>
</div>
</div>
<div class="card-grid" id="card-grid"></div>
</div>
<!-- ═══ VOCAB PANEL ═══ -->
<div class="lg-panel" id="panel-vocab">
<div class="search-row">
<button class="lg-btn mob-filter-btn" style="display:none;padding:4px 8px;flex-shrink:0" onclick="mobOpenFilter()" title="필터">☰</button>
<input type="text" id="vocab-search" placeholder="단어 검색..." oninput="renderVocabTable()">
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap">
<input type="checkbox" id="vocab-show-rom" checked onchange="renderVocabTable()" style="accent-color:#22c55e">발음
</label>
<span style="font-size:12px;color:var(--muted);white-space:nowrap" id="vocab-count"></span>
</div>
<div class="vocab-wrap">
<table class="vocab-table">
<thead>
<tr>
<th id="vocab-word-th">우즈베크어</th>
<th id="vocab-rom-th">발음</th>
<th>한국어</th>
<th>카테고리</th>
</tr>
</thead>
<tbody id="vocab-tbody"></tbody>
</table>
</div>
</div>
<!-- ═══ QUIZ PANEL ═══ -->
<div class="lg-panel" id="panel-quiz">
<div class="lg-toolbar">
<button class="lg-btn mob-filter-btn" style="display:none;padding:4px 8px" onclick="mobOpenFilter()" title="퀴즈 설정">☰</button>
<span class="lg-toolbar-title">퀴즈</span>
<select class="lg-select" id="quiz-type-sel">
<option value="multiple">객관식</option>
<option value="fill">주관식</option>
</select>
<select class="lg-select" id="quiz-count-sel">
<option value="5">5문제</option>
<option value="10" selected>10문제</option>
<option value="20">20문제</option>
</select>
</div>
<div class="quiz-wrap" id="quiz-wrap">
<!-- setup -->
<div class="quiz-setup-box" id="quiz-setup">
<div style="font-size:20px;text-align:center">✏️</div>
<div style="font-size:15px;font-weight:700;text-align:center">퀴즈 시작</div>
<div style="font-size:13px;color:var(--muted);text-align:center">카테고리와 문제 수를 선택하고 퀴즈를 시작하세요.</div>
<button class="lg-btn primary" onclick="startQuiz()" style="margin-top:4px">시작하기</button>
</div>
<!-- active quiz -->
<div id="quiz-active" style="display:none;width:100%;max-width:520px">
<div class="quiz-progress-bar-wrap">
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px">
<span id="quiz-q-label">문제 1 / 10</span>
<span id="quiz-score-live">점수: 0</span>
</div>
<div class="quiz-progress-bar"><div class="quiz-progress-fill" id="quiz-prog-fill" style="width:0%"></div></div>
</div>
<div class="quiz-q-box">
<div class="quiz-word" id="quiz-word"></div>
<div class="quiz-sub" id="quiz-sub"></div>
<div class="quiz-options" id="quiz-options"></div>
<div class="quiz-fill-row" id="quiz-fill-row" style="display:none">
<input class="quiz-fill" id="quiz-fill-input" type="text" placeholder="한국어로 입력..." onkeydown="if(event.key==='Enter')submitQuizFill()">
<button class="lg-btn primary" onclick="submitQuizFill()">확인</button>
</div>
<div class="quiz-feedback" id="quiz-feedback"></div>
</div>
</div>
<!-- results -->
<div class="quiz-result-box" id="quiz-results" style="display:none">
<div class="score-circle">
<div class="score-n" id="result-score">0</div>
<div class="score-d" id="result-denom">/ 10</div>
</div>
<div style="text-align:center;font-size:15px;font-weight:700" id="result-msg">결과</div>
<div class="wrong-list" id="wrong-list"></div>
<div style="display:flex;gap:8px">
<button class="lg-btn primary" onclick="startQuiz()" style="flex:1">다시 시작</button>
<button class="lg-btn" onclick="reviewWrong()" id="btn-review-wrong" style="flex:1">오답 복습</button>
</div>
</div>
</div>
</div>
<!-- ═══ LESSONS PANEL ═══ -->
<div class="lg-panel" id="panel-lessons">
<!-- Lesson grid view -->
<div id="lessons-grid-view" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
<div class="lg-toolbar">
<button class="lg-btn mob-filter-btn" style="display:none;padding:4px 8px" onclick="mobOpenFilter()" title="레슨 목록">☰</button>
<span class="lg-toolbar-title">레슨</span>
<button class="lg-btn" onclick="resetLessons()">↺ 진도 초기화</button>
</div>
<div class="lessons-grid" id="lessons-grid"></div>
</div>
<!-- Lesson detail view -->
<div id="lesson-detail-view" style="display:none;flex-direction:column;flex:1;overflow:hidden">
<div class="lg-toolbar">
<button class="lg-btn" onclick="closeLessonDetail()">← 목록</button>
<span class="lg-toolbar-title" id="lesson-detail-title"></span>
<button class="lg-btn" onclick="askAI()" style="margin-left:auto">💬 AI에게 질문</button>
</div>
<div class="lesson-detail-wrap">
<div class="lesson-steps" id="lesson-steps"></div>
<div class="lesson-nav">
<button class="lg-btn" onclick="prevLessonStep()" id="btn-prev-step">← 이전</button>
<span class="step-counter" id="step-counter"></span>
<button class="lg-btn primary" onclick="nextLessonStep()" id="btn-next-step">다음 →</button>
</div>
</div>
</div>
</div>
</div><!-- /lg-main -->
<!-- Interpreter panel -->
<div class="lg-interp" id="lg-interp">
<div class="lg-interp-head">
<b>🗣️ 실시간 통역사</b>
<span style="font-size:11px;color:var(--muted)" id="interp-lang-label">한국어 ⇄ 우즈베크어</span>
<button class="lg-call-btn" id="lg-call-btn" onclick="toggleCall()" title="통역 통화 시작/종료">📞</button>
<button class="lg-interp-close" onclick="toggleInterpreter(false)" title="닫기">✕</button>
</div>
<div class="lg-call-status" id="lg-call-status"></div>
<div class="lg-interp-msgs" id="lg-interp-msgs">
<div class="lg-interp-empty" id="lg-interp-empty">문장을 입력하거나 🎤/📞로 말하면 한국어↔우즈베크어를 자동으로 판별해서 통역해드려요.<br>예: "화장실이 어디예요?" 또는 "Rahmat"</div>
</div>
<div class="lg-interp-input-row">
<button class="lg-mic-btn" id="lg-mic-btn" onclick="toggleMicInput()" title="음성으로 입력">🎤</button>
<textarea id="lg-interp-input" placeholder="한국어 또는 우즈베크어로 입력..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendInterpret();}"></textarea>
<button class="lg-interp-send" id="lg-interp-send-btn" onclick="sendInterpret()">전송</button>
</div>
</div>
<!-- Teacher chat panel: general Q&A (grammar/usage/culture), not translation -->
<div class="lg-interp" id="lg-teacher">
<div class="lg-interp-head">
<b id="teacher-title">👨‍🏫 우즈베크어 선생님</b>
<span style="font-size:11px;color:var(--muted)">문법·단어·문화 질문</span>
<button class="lg-interp-close" onclick="toggleTeacher(false)" title="닫기">✕</button>
</div>
<div class="lg-interp-msgs" id="lg-teacher-msgs">
<div class="lg-interp-empty" id="lg-teacher-empty">궁금한 걸 물어보세요.<br>예: "이 문법 언제 써요?", "이 단어랑 이 단어 차이가 뭐예요?", "이 문장 자연스러운가요?"</div>
</div>
<div class="lg-interp-input-row">
<textarea id="lg-teacher-input" placeholder="질문을 입력하세요..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendTeacherMsg();}"></textarea>
<button class="lg-interp-send" id="lg-teacher-send-btn" onclick="sendTeacherMsg()">전송</button>
</div>
</div>
</div><!-- /lg-layout -->
<script>
// Vocab/flashcard/quiz content can come from generate-flashcards (LLM output) — never trust
// it as HTML. Escape before every innerHTML insertion of model- or user-derived text.
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
// Lightweight markdown renderer for teacher replies (tables/bold/lists/blockquotes/headings) —
// escapes first so raw model text can never inject HTML, then builds block/inline markup on
// top of the already-escaped string (e.g. matching literal '>' via its escaped '&gt;' form).
function mdInline(s) {
return s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/`([^`]+?)`/g, '<code>$1</code>');
}
function renderTeacherMd(raw) {
const lines = escHtml(raw).split('\n');
const blocks = [];
let i = 0;
const isTable = l => /^\s*\|.*\|\s*$/.test(l);
const isQuote = l => /^\s*&gt;\s?/.test(l);
const isListItem = l => /^\s*[-*]\s+/.test(l);
const isHeading = l => /^\s*#{1,4}\s+/.test(l);
const isHr = l => /^\s*-{3,}\s*$/.test(l);
while (i < lines.length) {
const line = lines[i];
if (isTable(line)) {
const rows = [];
while (i < lines.length && isTable(lines[i])) { rows.push(lines[i].trim()); i++; }
const dataRows = rows.filter(l => !/^\|[\s:|-]+\|$/.test(l));
const cellsOf = l => l.replace(/^\||\|$/g, '').split('|').map(c => mdInline(c.trim()));
if (dataRows.length) {
const [head, ...body] = dataRows;
let t = '<table class="teacher-md-table"><thead><tr>' + cellsOf(head).map(c => `<th>${c}</th>`).join('') + '</tr></thead><tbody>';
for (const r of body) t += '<tr>' + cellsOf(r).map(c => `<td>${c}</td>`).join('') + '</tr>';
blocks.push(t + '</tbody></table>');
}
continue;
}
if (isQuote(line)) {
const qLines = [];
while (i < lines.length && isQuote(lines[i])) { qLines.push(lines[i].replace(/^\s*&gt;\s?/, '')); i++; }
blocks.push('<blockquote class="teacher-md-quote">' + mdInline(qLines.join('<br>')) + '</blockquote>');
continue;
}
if (isListItem(line)) {
const items = [];
while (i < lines.length && isListItem(lines[i])) { items.push(lines[i].replace(/^\s*[-*]\s+/, '')); i++; }
blocks.push('<ul class="teacher-md-list">' + items.map(it => `<li>${mdInline(it)}</li>`).join('') + '</ul>');
continue;
}
if (isHeading(line)) {
blocks.push(`<div class="teacher-md-heading">${mdInline(line.replace(/^\s*#{1,4}\s+/, ''))}</div>`);
i++; continue;
}
if (isHr(line)) { blocks.push('<hr class="teacher-md-hr">'); i++; continue; }
if (/^\s*$/.test(line)) { i++; continue; }
const pLines = [];
while (i < lines.length && lines[i].trim() && !isTable(lines[i]) && !isQuote(lines[i]) && !isListItem(lines[i]) && !isHeading(lines[i]) && !isHr(lines[i])) {
pLines.push(lines[i]); i++;
}
blocks.push('<div class="teacher-md-p">' + mdInline(pLines.join('<br>')) + '</div>');
}
return blocks.join('');
}
// ── Mobile ────────────────────────────────────────────────────────────────────
function mobCloseFilter(){
document.querySelector('.lg-sidebar').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
}
function mobOpenFilter(){
document.querySelector('.lg-sidebar').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
}
function mobCloseAll(){
mobCloseFilter();
toggleInterpreter(false);
toggleTeacher(false);
}
function mobNav(nav){
mobCloseFilter();
setNav(nav);
// update bottom tab active state
['flash','vocab','quiz','lessons'].forEach(n=>{
const t=document.getElementById('mobtab-'+n);
if(t) t.classList.toggle('active', n===nav);
});
}
// hook category/filter buttons to auto-close sidebar on mobile
['setCategory','setVocabCat','setQuizCat'].forEach(fn=>{
const orig=window[fn];
if(orig) window[fn]=function(...a){orig(...a);if(window.innerWidth<=1024)mobCloseFilter();};
});
// ═══════════════════════════════════════════════════════
// LANGUAGES
// ═══════════════════════════════════════════════════════
const LANG_CONFIG = {
uz: { flag:'🇺🇿', name:'우즈베크어', title:'우즈베크어 학습',
voices:{ female:{label:'Madina'}, male:{label:'Sardor'} },
interpLabel:'한국어 ⇄ 우즈베크어',
interpPlaceholder:'한국어 또는 우즈베크어로 입력...',
interpEmpty:'문장을 입력하거나 🎤/📞로 말하면 한국어↔우즈베크어를 자동으로 판별해서 통역해드려요.<br>예: "화장실이 어디예요?" 또는 "Rahmat"' },
de: { flag:'🇩🇪', name:'독일어', title:'독일어 학습',
voices:{ female:{label:'Katja'}, male:{label:'Conrad'} },
interpLabel:'한국어 ⇄ 독일어',
interpPlaceholder:'한국어 또는 독일어로 입력...',
interpEmpty:'문장을 입력하거나 🎤/📞로 말하면 한국어↔독일어를 자동으로 판별해서 통역해드려요.<br>예: "화장실이 어디예요?" 또는 "Danke"' },
};
// ═══════════════════════════════════════════════════════
// DATA
// ═══════════════════════════════════════════════════════
const CAT_LABELS = { greetings:'인사', numbers:'숫자', food:'음식', verbs:'동사', travel:'여행' };
const DEFAULT_CATEGORY_KEYS = Object.keys(CAT_LABELS);
// User-added categories persist in localStorage per language as a plain string list — the
// string IS both the key and the label (no separate slug), so "가족"/"날씨" etc. work without
// asking the user to think about internal naming. Merged into CAT_LABELS on load/switch so
// every existing lookup (CAT_LABELS[v.cat]) already works for custom categories.
let CUSTOM_CATEGORIES = [];
function loadCustomCategories(lang) {
try { return JSON.parse(localStorage.getItem('lg_custom_cats_' + lang) || '[]').filter(c => typeof c === 'string' && c.trim()); }
catch { return []; }
}
// One-time migration from the pre-multi-language key (always held Uzbek data).
if (localStorage.getItem('uz_custom_categories') && !localStorage.getItem('lg_custom_cats_uz')) {
localStorage.setItem('lg_custom_cats_uz', localStorage.getItem('uz_custom_categories'));
}
function applyCustomCategories(lang) {
// Drop any custom category label left from the previous language before merging this
// language's own in — otherwise switching uz → de → uz would accumulate stale entries.
for (const k of Object.keys(CAT_LABELS)) { if (!DEFAULT_CATEGORY_KEYS.includes(k)) delete CAT_LABELS[k]; }
CUSTOM_CATEGORIES = loadCustomCategories(lang);
for (const c of CUSTOM_CATEGORIES) CAT_LABELS[c] = c;
}
// 카테고리는 원래 localStorage에만 있었다 — 브라우저 데이터를 지우거나 다른 기기에서 열면
// 통째로 사라지고, 그 카테고리로 만든 카드는 분류 탭에서 안 보여 "카드가 없어졌다"로 보인다
// (2026-08-16). 이제 서버가 정본이고 localStorage는 오프라인 캐시다.
async function syncCustomCategories(lang) {
try {
const local = loadCustomCategories(lang);
// 로컬에만 있는 게 있으면 먼저 올려서 서버를 정본으로 만든다(과거 기기에 남아 있던 것 구제).
const url = '/api/language/custom-categories/session';
const res = local.length
? await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lang, categories: local }) })
: await fetch(url + '?lang=' + encodeURIComponent(lang));
const data = await res.json();
if (!data.success || !Array.isArray(data.categories)) return;
localStorage.setItem('lg_custom_cats_' + lang, JSON.stringify(data.categories));
if (lang !== STATE.lang) return; // 늦게 온 응답이 다른 언어 화면을 덮어쓰지 않게
applyCustomCategories(lang);
renderCategoryButtons();
} catch {}
}
function renderCategoryButtons() {
const specs = [
{ containerId: 'cat-buttons-flash', attr: 'data-cat', active: STATE.category, fn: 'setCategory' },
{ containerId: 'cat-buttons-vocab', attr: 'data-vcat', active: STATE.vocabCat, fn: 'setVocabCat' },
{ containerId: 'cat-buttons-quiz', attr: 'data-qcat', active: STATE.quizCat, fn: 'setQuizCat' },
];
const keys = ['all', ...DEFAULT_CATEGORY_KEYS, ...CUSTOM_CATEGORIES];
for (const s of specs) {
const el = document.getElementById(s.containerId);
if (!el) continue;
el.innerHTML = keys.map(k => {
const label = k === 'all' ? '전체' : (CAT_LABELS[k] || k);
const activeCls = k === s.active ? ' active' : '';
return `<button class="lg-cat-btn${activeCls}" onclick="${s.fn}('${k.replace(/'/g, "\\'")}')" ${s.attr}="${escHtml(k)}">${escHtml(label)}</button>`;
}).join('');
}
}
function addCategory() {
const name = (prompt('추가할 카테고리 이름을 입력하세요 (예: 가족, 날씨, 직업)') || '').trim();
if (!name) return;
if (CAT_LABELS[name]) { alert('이미 있는 카테고리입니다.'); return; }
CUSTOM_CATEGORIES.push(name);
CAT_LABELS[name] = name;
localStorage.setItem('lg_custom_cats_' + STATE.lang, JSON.stringify(CUSTOM_CATEGORIES));
renderCategoryButtons();
// 서버에도 올린다. 실패하면 이 카테고리는 이 브라우저에만 남으므로 조용히 넘기지 않는다.
const lang = STATE.lang;
fetch('/api/language/custom-categories/session', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lang, categories: CUSTOM_CATEGORIES }),
}).then(r => r.json()).then(d => {
if (!d?.success) throw new Error(d?.error || '저장 실패');
}).catch(e => {
alert('⚠️ "' + name + '" 카테고리가 서버에 저장되지 않았습니다 (' + e.message + ').\n'
+ '이 브라우저에서만 보이고, 데이터를 지우면 사라집니다.');
});
}
const VOCAB_DATA = {
uz: [
// 인사
{word:'Salom', rom:'살롬', ko:'안녕하세요', cat:'greetings'},
{word:'Xayr', rom:'하이르', ko:'안녕히 가세요', cat:'greetings'},
{word:'Rahmat', rom:'라흐맛', ko:'감사합니다', cat:'greetings'},
{word:'Iltimos', rom:'일티모스', ko:'부탁합니다', cat:'greetings'},
{word:'Ha', rom:'하', ko:'네', cat:'greetings'},
{word:'Yoʻq', rom:'요으q', ko:'아니요', cat:'greetings'},
{word:'Kechirasiz', rom:'케치라시즈', ko:'실례합니다', cat:'greetings'},
{word:'Tushunmadim', rom:'투슈나딤', ko:'이해 못 했습니다', cat:'greetings'},
{word:'Yaxshimisiz?', rom:'야흐쉬미시즈', ko:'잘 지내시나요?', cat:'greetings'},
{word:'Yaxshi', rom:'야흐쉬', ko:'좋아요', cat:'greetings'},
// 숫자
{word:'bir', rom:'비르', ko:'1', cat:'numbers'},
{word:'ikki', rom:'익키', ko:'2', cat:'numbers'},
{word:'uch', rom:'우치', ko:'3', cat:'numbers'},
{word:'toʻrt', rom:'토으르트', ko:'4', cat:'numbers'},
{word:'besh', rom:'베쉬', ko:'5', cat:'numbers'},
{word:'olti', rom:'올티', ko:'6', cat:'numbers'},
{word:'yetti', rom:'예티', ko:'7', cat:'numbers'},
{word:'sakkiz', rom:'사끼즈', ko:'8', cat:'numbers'},
{word:'toʻqqiz',rom:'토으q기즈', ko:'9', cat:'numbers'},
{word:"oʻn", rom:'오으느', ko:'10', cat:'numbers'},
{word:"oʻn bir",rom:'오으느 비르', ko:'11', cat:'numbers'},
{word:"oʻn ikki",rom:'오으느 익키',ko:'12', cat:'numbers'},
{word:"oʻn besh",rom:'오으느 베쉬',ko:'15', cat:'numbers'},
{word:'yigirma',rom:'이기르마', ko:'20', cat:'numbers'},
{word:'ellik', rom:'엘릭', ko:'50', cat:'numbers'},
{word:'yuz', rom:'유즈', ko:'100', cat:'numbers'},
// 음식
{word:'non', rom:'논', ko:'빵', cat:'food'},
{word:'suv', rom:'수브', ko:'물', cat:'food'},
{word:'gosht', rom:'고슈트', ko:'고기', cat:'food'},
{word:'tuxum', rom:'투훔', ko:'달걀', cat:'food'},
{word:'sabzavot', rom:'사브자보트', ko:'채소', cat:'food'},
{word:'meva', rom:'메바', ko:'과일', cat:'food'},
{word:'choy', rom:'초이', ko:'차', cat:'food'},
{word:'sharbat', rom:'샤르밧', ko:'주스', cat:'food'},
{word:'guruch', rom:'구루치', ko:'쌀', cat:'food'},
{word:'osh', rom:'오쉬', ko:'필라프', cat:'food'},
// 동사
{word:'bormoq', rom:'보르모q', ko:'가다', cat:'verbs'},
{word:'kelmoq', rom:'켈모q', ko:'오다', cat:'verbs'},
{word:'yemoq', rom:'예모q', ko:'먹다', cat:'verbs'},
{word:'ichmoq', rom:'이치모q', ko:'마시다', cat:'verbs'},
{word:"koʻrmoq", rom:'코으르모q', ko:'보다', cat:'verbs'},
{word:'gapirmoq', rom:'가피르모q', ko:'말하다', cat:'verbs'},
{word:"oʻqimoq", rom:'오으끼모q', ko:'읽다', cat:'verbs'},
{word:'yozmoq', rom:'요즈모q', ko:'쓰다', cat:'verbs'},
{word:'sotib olmoq',rom:'소팁 올모q',ko:'사다', cat:'verbs'},
{word:'yurmoq', rom:'유르모q', ko:'걷다', cat:'verbs'},
// 여행
{word:'stansiya', rom:'스탄시야', ko:'역', cat:'travel'},
{word:'aeroport', rom:'아에로포르트', ko:'공항', cat:'travel'},
{word:'mehmonxona', rom:'메흐몬호나', ko:'호텔', cat:'travel'},
{word:'bozor', rom:'보조르', ko:'시장', cat:'travel'},
{word:'shifoxona', rom:'쉬포호나', ko:'병원', cat:'travel'},
{word:'narxi', rom:'나르히', ko:'가격', cat:'travel'},
{word:'chiqish', rom:'치끼쉬', ko:'출구', cat:'travel'},
{word:'kirish', rom:'키리쉬', ko:'입구', cat:'travel'},
{word:'taksit', rom:'탁시트', ko:'택시', cat:'travel'},
{word:'xarita', rom:'하리타', ko:'지도', cat:'travel'},
],
de: [
// 인사
{word:'Hallo', rom:'할로', ko:'안녕하세요', cat:'greetings'},
{word:'Tschüss', rom:'취스', ko:'안녕히 가세요', cat:'greetings'},
{word:'Danke', rom:'당케', ko:'감사합니다', cat:'greetings'},
{word:'Bitte', rom:'비테', ko:'부탁합니다', cat:'greetings'},
{word:'Ja', rom:'야', ko:'네', cat:'greetings'},
{word:'Nein', rom:'나인', ko:'아니요', cat:'greetings'},
{word:'Entschuldigung', rom:'엔트슐디궁', ko:'실례합니다', cat:'greetings'},
{word:'Ich verstehe nicht', rom:'이히 페어슈테에 니히트', ko:'이해 못 했습니다', cat:'greetings'},
{word:'Wie geht\'s?', rom:'비 게이츠', ko:'잘 지내시나요?', cat:'greetings'},
{word:'Gut', rom:'구트', ko:'좋아요', cat:'greetings'},
// 숫자
{word:'eins', rom:'아인스', ko:'1', cat:'numbers'},
{word:'zwei', rom:'츠바이', ko:'2', cat:'numbers'},
{word:'drei', rom:'드라이', ko:'3', cat:'numbers'},
{word:'vier', rom:'피어', ko:'4', cat:'numbers'},
{word:'fünf', rom:'퓐프', ko:'5', cat:'numbers'},
{word:'sechs', rom:'젝스', ko:'6', cat:'numbers'},
{word:'sieben', rom:'지벤', ko:'7', cat:'numbers'},
{word:'acht', rom:'아흐트', ko:'8', cat:'numbers'},
{word:'neun', rom:'노인', ko:'9', cat:'numbers'},
{word:'zehn', rom:'첸', ko:'10', cat:'numbers'},
{word:'elf', rom:'엘프', ko:'11', cat:'numbers'},
{word:'zwölf', rom:'츠뵐프', ko:'12', cat:'numbers'},
{word:'fünfzehn',rom:'퓐프첸', ko:'15', cat:'numbers'},
{word:'zwanzig', rom:'츠반치히', ko:'20', cat:'numbers'},
{word:'fünfzig', rom:'퓐프치히', ko:'50', cat:'numbers'},
{word:'hundert', rom:'훈더트', ko:'100', cat:'numbers'},
// 음식
{word:'Brot', rom:'브로트', ko:'빵', cat:'food'},
{word:'Wasser', rom:'바써', ko:'물', cat:'food'},
{word:'Fleisch', rom:'플라이슈', ko:'고기', cat:'food'},
{word:'Ei', rom:'아이', ko:'달걀', cat:'food'},
{word:'Gemüse', rom:'게뮤제', ko:'채소', cat:'food'},
{word:'Obst', rom:'오프스트', ko:'과일', cat:'food'},
{word:'Tee', rom:'테', ko:'차', cat:'food'},
{word:'Saft', rom:'자프트', ko:'주스', cat:'food'},
{word:'Reis', rom:'라이스', ko:'쌀', cat:'food'},
{word:'Kartoffel',rom:'카르토펠', ko:'감자', cat:'food'},
// 동사
{word:'gehen', rom:'게엔', ko:'가다', cat:'verbs'},
{word:'kommen', rom:'코멘', ko:'오다', cat:'verbs'},
{word:'essen', rom:'에센', ko:'먹다', cat:'verbs'},
{word:'trinken', rom:'트링켄', ko:'마시다', cat:'verbs'},
{word:'sehen', rom:'제엔', ko:'보다', cat:'verbs'},
{word:'sprechen', rom:'슈프레헨', ko:'말하다', cat:'verbs'},
{word:'lesen', rom:'레젠', ko:'읽다', cat:'verbs'},
{word:'schreiben',rom:'슈라이벤', ko:'쓰다', cat:'verbs'},
{word:'kaufen', rom:'카우펜', ko:'사다', cat:'verbs'},
{word:'laufen', rom:'라우펜', ko:'걷다', cat:'verbs'},
// 여행
{word:'Bahnhof', rom:'반호프', ko:'역', cat:'travel'},
{word:'Flughafen', rom:'플룩하펜', ko:'공항', cat:'travel'},
{word:'Hotel', rom:'호텔', ko:'호텔', cat:'travel'},
{word:'Markt', rom:'마르크트', ko:'시장', cat:'travel'},
{word:'Krankenhaus',rom:'크랑켄하우스',ko:'병원', cat:'travel'},
{word:'Preis', rom:'프라이스', ko:'가격', cat:'travel'},
{word:'Ausgang', rom:'아우스강', ko:'출구', cat:'travel'},
{word:'Eingang', rom:'아인강', ko:'입구', cat:'travel'},
{word:'Taxi', rom:'탁시', ko:'택시', cat:'travel'},
{word:'Karte', rom:'카르테', ko:'지도', cat:'travel'},
],
};
const LESSONS_DATA_BY_LANG = {
uz: [
{ id:1, title:'인사와 자기소개', cats:['greetings'], desc:'Salom부터 자기소개까지',
steps:[
{type:'explain', text:'우즈베크어 인사는 하루 중 언제든 "Salom [살롬]"을 씁니다. 공식적인 상황에서는 "Assalomu alaykum [아살로무 알라이쿰]"을 쓰기도 해요.'},
{type:'example', word:'Salom! Men Kimman.', rom:'살롬! 멘 Kim만.', ko:'안녕하세요! 저는 Kim입니다.'},
{type:'explain', text:'"Men [이름]man" 구조로 자기소개를 합니다. "Men" = 저는, "[이름]man" = ~입니다. SOV 어순이라 한국어와 비슷해요!'},
{type:'example', word:'Men koreyalikman.', rom:'멘 코레야릭만.', ko:'저는 한국 사람입니다.'},
{type:'practice', prompt:'자신을 소개해 보세요: Men ___man.'},
{type:'example', word:'Xayr! Ko\'rishguncha.', rom:'하이르! 코으리슈군차.', ko:'안녕히 가세요! 또 봐요.'},
]
},
{ id:2, title:'숫자 1~10', cats:['numbers'], desc:'bir부터 oʻn까지',
steps:[
{type:'explain', text:'우즈베크어 숫자는 규칙적입니다. 1~10을 외우면 11~19는 "oʻn + 숫자" 패턴으로 만들 수 있어요.'},
{type:'example', word:'bir, ikki, uch, toʻrt, besh', rom:'비르, 익키, 우치, 토으르트, 베쉬', ko:'1, 2, 3, 4, 5'},
{type:'example', word:"olti, yetti, sakkiz, toʻqqiz, oʻn", rom:'올티, 예티, 사끼즈, 토으q기즈, 오으느', ko:'6, 7, 8, 9, 10'},
{type:'explain', text:"oʻ와 toʻ의 특수 문자 'ʻ'에 주의하세요. 입술을 둥글게 모아 '오'를 발음합니다."},
{type:'practice', prompt:'5를 우즈베크어로 쓰세요.'},
{type:'practice', prompt:'10을 우즈베크어로 쓰세요.'},
]
},
{ id:3, title:'음식과 식당', cats:['food'], desc:'음식 이름과 주문 표현',
steps:[
{type:'explain', text:"우즈베크 대표 음식 'osh [오쉬]'는 당근·고기·쌀로 만든 필라프입니다. 식당에서 자주 쓰는 표현을 배워봐요."},
{type:'example', word:"Menga osh bering.", rom:'메나 오쉬 베링.', ko:'필라프 주세요.'},
{type:'example', word:"Narxi qancha?", rom:'나르히 칸차?', ko:'가격이 얼마예요?'},
{type:'example', word:"Suv bering, iltimos.", rom:'수브 베링, 일티모스.', ko:'물 주세요, 부탁합니다.'},
{type:'explain', text:"'bering [베링]'은 '주세요'라는 의미입니다. 'Menga [메나]'는 '나에게'예요."},
{type:'practice', prompt:"'빵 주세요'를 우즈베크어로 써 보세요. (non = 빵)"},
]
},
{ id:4, title:'기본 동사', cats:['verbs'], desc:'자주 쓰는 동사 10개',
steps:[
{type:'explain', text:'우즈베크어 동사 원형은 -moq로 끝납니다. 현재형은 -yap, 과거는 -di를 붙여요. 한국어 -다/-아/었다와 비슷해요!'},
{type:'example', word:'bormoq → boraman → bordim', rom:'보르모q → 보라만 → 보르딤', ko:'가다 → 가요 → 갔어요'},
{type:'example', word:'kelmoq → kelaman → keldim', rom:'켈모q → 켈라만 → 켈딤', ko:'오다 → 와요 → 왔어요'},
{type:'explain', text:'부정형은 -ma-를 삽입합니다: boraman (가요) → bormayman (안 가요)'},
{type:'example', word:'Men yemoyman.', rom:'멘 예모이만.', ko:'저는 먹지 않아요.'},
{type:'practice', prompt:"'저는 마셔요'를 우즈베크어로 쓰세요. (ich + aman)"},
]
},
{ id:5, title:'여행 필수 표현', cats:['travel'], desc:'공항·호텔·택시에서 쓰는 말',
steps:[
{type:'explain', text:'여행 중 가장 많이 쓰는 표현들입니다. 방향 묻기, 가격 확인, 위치 찾기가 핵심이에요.'},
{type:'example', word:'Mehmonxona qayerda?', rom:'메흐몬호나 카예르다?', ko:'호텔이 어디에 있나요?'},
{type:'example', word:'Aeroport qancha?', rom:'아에로포르트 칸차?', ko:'공항까지 얼마예요?'},
{type:'example', word:'Chiqish qayerda?', rom:'치끼쉬 카예르다?', ko:'출구가 어디예요?'},
{type:'explain', text:"'qayerda [카예르다]'는 '어디에'라는 뜻입니다. -da는 처소격 조사예요."},
{type:'practice', prompt:"'시장이 어디에 있나요?'를 우즈베크어로 쓰세요. (bozor = 시장)"},
]
},
{ id:6, title:'숫자 11~100', cats:['numbers'], desc:'고급 숫자 표현',
steps:[
{type:'explain', text:"11~19는 'oʻn [오으느] + 숫자' 패턴입니다. 20은 yigirma [이기르마], 50은 ellik [엘릭], 100은 yuz [유즈]."},
{type:'example', word:"oʻn bir, oʻn ikki, oʻn besh", rom:'오으느 비르, 오으느 익키, 오으느 베쉬', ko:'11, 12, 15'},
{type:'example', word:'yigirma, yigirma besh, ellik', rom:'이기르마, 이기르마 베쉬, 엘릭', ko:'20, 25, 50'},
{type:'practice', prompt:'23을 우즈베크어로 쓰세요.'},
{type:'practice', prompt:'47을 우즈베크어로 쓰세요.'},
]
},
{ id:7, title:'일상 회화 패턴', cats:['greetings','verbs'], desc:'날씨·기분·약속 표현',
steps:[
{type:'explain', text:'일상 대화에서 자주 쓰는 패턴을 익혀봐요. 날씨 묻기, 기분 표현, 약속 잡기까지.'},
{type:'example', word:"Bugun ob-havo qanday?", rom:'부군 오브-하보 칸다이?', ko:'오늘 날씨 어때요?'},
{type:'example', word:"Hozir yaxshi his qilyapman.", rom:'호지르 야흐쉬 히스 킬야프만.', ko:'지금 기분이 좋아요.'},
{type:'example', word:"Ertaga uchrashaylik.", rom:'에르타가 우치라샤이릭.', ko:'내일 만나요.'},
{type:'practice', prompt:"'저는 피곤해요'를 표현해 보세요. (charchagan = 피곤한)"},
]
},
{ id:8, title:'쇼핑과 흥정', cats:['travel','numbers'], desc:'시장에서 가격 흥정하기',
steps:[
{type:'explain', text:'우즈베키스탄 시장에서는 흥정이 일반적입니다. 가격을 묻고, 깎고, 사는 표현을 익혀봐요.'},
{type:'example', word:"Bu qancha turadi?", rom:'부 칸차 투라디?', ko:'이것은 얼마예요?'},
{type:'example', word:"Qimmat! Arzonroq bering.", rom:'킴맛! 아르존로q 베링.', ko:'비싸요! 좀 깎아주세요.'},
{type:'example', word:"Yaxshi, olamiz.", rom:'야흐쉬, 올라미즈.', ko:'좋아요, 살게요.'},
{type:'explain', text:"'qimmat [킴맛]'은 비싸다, 'arzon [아르존]'은 싸다. -roq를 붙이면 '더 ~'라는 비교급이에요."},
{type:'practice', prompt:"'더 싸게 해주세요'를 표현해 보세요."},
]
},
],
de: [
{ id:1, title:'인사와 자기소개', cats:['greetings'], desc:'Hallo부터 자기소개까지',
steps:[
{type:'explain', text:'독일어 인사는 시간대별로 다양합니다(Guten Morgen/Tag/Abend). 편하게는 언제나 "Hallo [할로]"를 쓰면 돼요.'},
{type:'example', word:'Hallo! Ich bin Kim.', rom:'할로! 이히 빈 킴.', ko:'안녕하세요! 저는 Kim입니다.'},
{type:'explain', text:'"Ich bin [이름]" 구조로 자기소개를 합니다. "Ich" = 저는, "bin" = ~이다(sein 동사). 한국어와 달리 동사가 주어 바로 뒤에 옵니다(SVO 어순).'},
{type:'example', word:'Ich bin Koreaner.', rom:'이히 빈 코레아너.', ko:'저는 한국 사람입니다.'},
{type:'practice', prompt:'자신을 소개해 보세요: Ich bin ___.'},
{type:'example', word:'Tschüss! Bis bald.', rom:'취스! 비스 발트.', ko:'안녕히 가세요! 곧 봐요.'},
]
},
{ id:2, title:'숫자 1~10', cats:['numbers'], desc:'eins부터 zehn까지',
steps:[
{type:'explain', text:'독일어 숫자는 규칙적입니다. 1~10을 외우면 나머지 숫자 학습이 훨씬 쉬워져요.'},
{type:'example', word:'eins, zwei, drei, vier, fünf', rom:'아인스, 츠바이, 드라이, 피어, 퓐프', ko:'1, 2, 3, 4, 5'},
{type:'example', word:'sechs, sieben, acht, neun, zehn', rom:'젝스, 지벤, 아흐트, 노인, 첸', ko:'6, 7, 8, 9, 10'},
{type:'explain', text:"'ü'는 입술을 둥글게 모아 '위'와 '이' 사이 소리를 냅니다. fünf [퓐프] 발음에 주의하세요."},
{type:'practice', prompt:'5를 독일어로 쓰세요.'},
{type:'practice', prompt:'10을 독일어로 쓰세요.'},
]
},
{ id:3, title:'음식과 식당', cats:['food'], desc:'음식 이름과 주문 표현',
steps:[
{type:'explain', text:"독일 식당에서는 'bitte [비테]'(부탁합니다/천만에요)를 자주 씁니다. 식당에서 자주 쓰는 표현을 배워봐요."},
{type:'example', word:'Ich möchte ein Brot, bitte.', rom:'이히 뫼히테 아인 브로트, 비테.', ko:'빵 하나 주세요.'},
{type:'example', word:'Was kostet das?', rom:'바스 코스테트 다스?', ko:'이것은 얼마예요?'},
{type:'example', word:'Wasser, bitte.', rom:'바써, 비테.', ko:'물 주세요, 부탁합니다.'},
{type:'explain', text:"'möchte [뫼히테]'는 '~하고 싶다'라는 뜻입니다. 'bitte [비테]'는 '부탁합니다'와 '천만에요' 둘 다에 쓰여요."},
{type:'practice', prompt:"'빵 주세요'를 독일어로 써 보세요. (Brot = 빵)"},
]
},
{ id:4, title:'기본 동사', cats:['verbs'], desc:'자주 쓰는 동사 10개',
steps:[
{type:'explain', text:'독일어 동사 원형은 -en으로 끝납니다. 현재형은 주어에 따라 어미가 바뀌어요: ich -e, du -st, er/sie -t.'},
{type:'example', word:'gehen → ich gehe → ich ging', rom:'게엔 → 이히 게에 → 이히 깅', ko:'가다 → 가요 → 갔어요'},
{type:'example', word:'kommen → ich komme → ich kam', rom:'코멘 → 이히 코메 → 이히 캄', ko:'오다 → 와요 → 왔어요'},
{type:'explain', text:'부정형은 동사 뒤에 nicht를 붙입니다: ich esse (먹어요) → ich esse nicht (안 먹어요)'},
{type:'example', word:'Ich esse nicht.', rom:'이히 에세 니히트.', ko:'저는 먹지 않아요.'},
{type:'practice', prompt:"'저는 마셔요'를 독일어로 쓰세요. (trinken → ich trink_)"},
]
},
{ id:5, title:'여행 필수 표현', cats:['travel'], desc:'공항·호텔·택시에서 쓰는 말',
steps:[
{type:'explain', text:'여행 중 가장 많이 쓰는 표현들입니다. 방향 묻기, 가격 확인, 위치 찾기가 핵심이에요.'},
{type:'example', word:'Wo ist das Hotel?', rom:'보 이스트 다스 호텔?', ko:'호텔이 어디에 있나요?'},
{type:'example', word:'Was kostet es zum Flughafen?', rom:'바스 코스테트 에스 춤 플룩하펜?', ko:'공항까지 얼마예요?'},
{type:'example', word:'Wo ist der Ausgang?', rom:'보 이스트 데어 아우스강?', ko:'출구가 어디예요?'},
{type:'explain', text:"'wo [보]'는 '어디에'라는 뜻입니다. 'ist [이스트]'는 sein 동사의 3인칭 단수형(~이다)이에요."},
{type:'practice', prompt:"'시장이 어디에 있나요?'를 독일어로 쓰세요. (Markt = 시장)"},
]
},
{ id:6, title:'숫자 11~100', cats:['numbers'], desc:'고급 숫자 표현',
steps:[
{type:'explain', text:"11(elf)과 12(zwölf)는 불규칙합니다. 13부터는 '숫자+zehn' 패턴이고, 21 이상은 '일의 자리+und+십의 자리' 순서로 뒤집어 읽어요: dreiundzwanzig(3+20=23)."},
{type:'example', word:'elf, zwölf, fünfzehn', rom:'엘프, 츠뵐프, 퓐프첸', ko:'11, 12, 15'},
{type:'example', word:'zwanzig, fünfundzwanzig, fünfzig', rom:'츠반치히, 퓐프운트츠반치히, 퓐프치히', ko:'20, 25, 50'},
{type:'practice', prompt:'23을 독일어로 쓰세요.'},
{type:'practice', prompt:'47을 독일어로 쓰세요.'},
]
},
{ id:7, title:'일상 회화 패턴', cats:['greetings','verbs'], desc:'날씨·기분·약속 표현',
steps:[
{type:'explain', text:'일상 대화에서 자주 쓰는 패턴을 익혀봐요. 날씨 묻기, 기분 표현, 약속 잡기까지.'},
{type:'example', word:'Wie ist das Wetter heute?', rom:'비 이스트 다스 베터 호이테?', ko:'오늘 날씨 어때요?'},
{type:'example', word:'Mir geht es gut.', rom:'미어 게트 에스 구트.', ko:'지금 기분이 좋아요.'},
{type:'example', word:'Lass uns morgen treffen.', rom:'라스 운스 모르겐 트레펜.', ko:'내일 만나요.'},
{type:'practice', prompt:"'저는 피곤해요'를 표현해 보세요. (müde = 피곤한)"},
]
},
{ id:8, title:'쇼핑과 가격 묻기', cats:['travel','numbers'], desc:'시장·플로마켓에서 가격 묻고 사기',
steps:[
{type:'explain', text:'독일 슈퍼마켓은 정찰제지만, 플로마켓(Flohmarkt)이나 중고 거래에서는 가격을 조금 깎기도 해요. 가격을 묻고, 깎고, 사는 표현을 익혀봐요.'},
{type:'example', word:'Was kostet das?', rom:'바스 코스테트 다스?', ko:'이것은 얼마예요?'},
{type:'example', word:'Das ist teuer! Können Sie den Preis senken?', rom:'다스 이스트 토이어! 쾬넨 지 덴 프라이스 젠켄?', ko:'비싸요! 좀 깎아주세요.'},
{type:'example', word:'Gut, ich nehme es.', rom:'구트, 이히 네메 에스.', ko:'좋아요, 살게요.'},
{type:'explain', text:"'teuer [토이어]'는 비싸다, 'billig [빌리히]'는 싸다는 뜻입니다."},
{type:'practice', prompt:"'더 싸게 해주세요'를 표현해 보세요. (billiger = 더 싼)"},
]
},
],
};
// ═══════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════
let STATE = {
lang: LANG_CONFIG[localStorage.getItem('lg_selected_lang')] ? localStorage.getItem('lg_selected_lang') : 'uz',
nav: 'flash',
category: 'all',
vocabCat: 'all',
quizCat: 'all',
viewMode: 'grid', // 'single' | 'grid'
deck: [],
deckIdx: 0,
known: new Set(),
flipped: false,
// quiz
quizMode: 'setup', // setup | active | results
quizQs: [],
quizIdx: 0,
quizScore: 0,
quizWrong: [],
// lessons
lessonsDone: {},
currentLesson: null,
lessonStep: 0,
};
let LESSONS_DATA = LESSONS_DATA_BY_LANG[STATE.lang];
let VOCAB = [...VOCAB_DATA[STATE.lang]];
// ═══════════════════════════════════════════════════════
// PERSISTENCE (per-language — known words / lesson progress don't carry across languages)
// ═══════════════════════════════════════════════════════
function loadProgress() {
try {
let raw = localStorage.getItem('lg_progress_' + STATE.lang);
if (!raw && STATE.lang === 'uz' && localStorage.getItem('uz_progress')) {
// One-time migration from the pre-multi-language key (always held Uzbek data).
raw = localStorage.getItem('uz_progress');
localStorage.setItem('lg_progress_uz', raw);
}
const p = JSON.parse(raw || '{}');
STATE.known = p.known ? new Set(p.known) : new Set();
STATE.lessonsDone = p.lessonsDone || {};
} catch { STATE.known = new Set(); STATE.lessonsDone = {}; }
}
// AI로 생성한 카드는 VOCAB_DATA(하드코딩 기본 단어장)에 없으므로 서버에 별도 저장/로드한다
// (2026-07-21) — 안 그러면 새로고침·언어전환마다 사라짐. progress와 동일하게 URL의
// :userId는 서버가 무시하고 세션으로 유저를 판별(레거시 route param).
async function loadCustomCards() {
try {
const res = await fetch('/api/language/custom-cards/session?lang=' + STATE.lang);
const data = await res.json();
if (data.success && Array.isArray(data.cards) && data.cards.length) {
const existing = new Set(VOCAB.map(v => v.word));
const fresh = data.cards.filter(c => !existing.has(c.word));
VOCAB = [...VOCAB, ...fresh];
buildDeck(STATE.category);
if (STATE.nav === 'vocab') renderVocabTable();
}
} catch {}
}
// 저장 실패를 삼키면 안 된다 — 예전엔 .catch(()=>{})로 조용히 버리면서 호출부는
// "✅ N개 추가되었습니다"를 띄웠다. 세션 만료 같은 이유로 POST가 실패해도 사용자는 성공한
// 줄 알고, 새로고침하면 카드가 없다. "추가해도 자꾸 없어진다"의 정확한 모양이다.
// 이제 성공 여부를 돌려주고 호출부가 그에 맞는 문구를 띄운다.
async function saveCustomCards(cards) {
if (!cards.length) return true;
try {
const res = await fetch('/api/language/custom-cards/session', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lang: STATE.lang, cards }),
});
if (!res.ok) throw new Error('HTTP ' + res.status + (res.status === 401 ? ' (로그인 만료)' : ''));
const data = await res.json();
if (!data?.success) throw new Error(data?.error || '알 수 없는 오류');
return true;
} catch (e) {
alert('⚠️ 새 단어가 서버에 저장되지 않았습니다: ' + e.message
+ '\n지금 화면에는 보이지만 새로고침하면 사라집니다.');
return false;
}
}
function saveProgress() {
try {
localStorage.setItem('lg_progress_' + STATE.lang, JSON.stringify({
known: [...STATE.known],
lessonsDone: STATE.lessonsDone,
}));
// also sync to server (fire-and-forget). The URL segment is a leftover route param —
// the server derives the actual user from the session cookie, not this value.
fetch('/api/language/progress/session', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ lang: STATE.lang, known: [...STATE.known], lessonsDone: STATE.lessonsDone })
}).catch(()=>{});
} catch {}
updateStatChip();
}
// ═══════════════════════════════════════════════════════
// THEME
// ═══════════════════════════════════════════════════════
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('homeclaw_theme', t);
}
// ═══════════════════════════════════════════════════════
// NAV
// ═══════════════════════════════════════════════════════
function setNav(nav) {
STATE.nav = nav;
['flash','vocab','quiz','lessons'].forEach(n => {
document.getElementById('nav-'+n).classList.toggle('active', n===nav);
document.getElementById('panel-'+n).classList.toggle('active', n===nav);
const sb = document.getElementById('sb-'+n);
if (sb) sb.style.display = n===nav ? '' : 'none';
});
if (nav==='vocab') renderVocabTable();
if (nav==='lessons') renderLessonGrid();
if (nav==='quiz') resetQuizView();
}
// ═══════════════════════════════════════════════════════
// FLASHCARD
// ═══════════════════════════════════════════════════════
function buildDeck(cat) {
STATE.deck = cat==='all' ? [...VOCAB] : VOCAB.filter(v=>v.cat===cat);
STATE.deckIdx = 0;
STATE.flipped = false;
document.getElementById('card-inner').classList.remove('flipped');
renderFlashView();
}
function setCategory(cat) {
STATE.category = cat;
document.querySelectorAll('[data-cat]').forEach(b => b.classList.toggle('active', b.dataset.cat===cat));
buildDeck(cat);
document.getElementById('flash-title').textContent = '플래시카드 — ' + (CAT_LABELS[cat]||'전체');
}
function renderFlashView() {
if (STATE.viewMode === 'grid') {
document.getElementById('flash-counter').textContent = STATE.deck.length ? `${STATE.deck.length}개 전체` : '0개';
renderGrid();
} else {
renderCard();
}
}
function toggleViewMode() {
STATE.viewMode = STATE.viewMode === 'single' ? 'grid' : 'single';
document.getElementById('card-wrap-single').style.display = STATE.viewMode === 'single' ? '' : 'none';
document.getElementById('card-grid').style.display = STATE.viewMode === 'grid' ? '' : 'none';
document.getElementById('view-toggle-btn').textContent = STATE.viewMode === 'single' ? '🀄 타일로 보기' : '🔄 한장씩 보기';
renderFlashView();
}
function renderGrid() {
const showRom = document.getElementById('show-rom').checked;
const d = STATE.deck;
const grid = document.getElementById('card-grid');
if (!d.length) {
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;color:var(--muted)">단어 없음</div>';
return;
}
grid.innerHTML = d.map(v => `
<div class="tile-stage" onclick="this.querySelector('.tile-inner').classList.toggle('flipped')">
<div class="tile-inner">
<div class="tile-face tile-front">
<span class="tile-cat">${escHtml(CAT_LABELS[v.cat]||'')}</span>
<div class="tile-uz">${escHtml(v.word)}</div>
${showRom?`<div class="tile-rom">[${escHtml(v.rom)}]</div>`:''}
<button class="tts-btn tile-tts-btn" data-uz="${escHtml(v.word)}" onclick="event.stopPropagation();speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
</div>
<div class="tile-face tile-back">
<div class="tile-ko">${escHtml(v.ko)}</div>
</div>
</div>
</div>
`).join('');
}
function renderCard() {
const d = STATE.deck;
if (!d.length) {
document.getElementById('card-uz').textContent = '단어 없음';
document.getElementById('card-rom').textContent = '';
document.getElementById('card-ko').textContent = '—';
document.getElementById('flash-counter').textContent = '0 / 0';
return;
}
const idx = STATE.deckIdx;
const v = d[idx];
const showRom = document.getElementById('show-rom').checked;
document.getElementById('card-uz').textContent = v.word;
document.getElementById('card-rom').textContent = showRom ? `[${v.rom}]` : '';
document.getElementById('card-ko').textContent = v.ko;
document.getElementById('flash-counter').textContent = `${idx+1} / ${d.length}`;
document.getElementById('card-counter').textContent = `카드 ${idx+1}번`;
updateKnownBar();
}
function flipCard() {
STATE.flipped = !STATE.flipped;
document.getElementById('card-inner').classList.toggle('flipped', STATE.flipped);
}
function nextCard(skip_unknown) {
STATE.flipped = false;
document.getElementById('card-inner').classList.remove('flipped');
const d = STATE.deck;
// find next non-known (if in unknown mode) or just next
let idx = STATE.deckIdx + 1;
if (idx >= d.length) {
if (STATE.known.size === d.length) {
// all done
document.getElementById('card-uz').textContent = '🎉 완료!';
document.getElementById('card-rom').textContent = '모든 카드를 다 외웠어요';
document.getElementById('card-ko').textContent = '';
return;
}
idx = 0;
}
STATE.deckIdx = idx;
renderCard();
}
function markKnown() {
const v = STATE.deck[STATE.deckIdx];
if (v) STATE.known.add(v.word);
saveProgress();
nextCard();
}
function markUnknown() {
nextCard();
}
function updateKnownBar() {
const deckKnown = STATE.deck.filter(v=>STATE.known.has(v.word)).length;
document.getElementById('known-count').textContent = deckKnown;
document.getElementById('unknown-count').textContent = STATE.deck.length - deckKnown;
}
function shuffleDeck() {
for (let i=STATE.deck.length-1;i>0;i--){
const j=Math.floor(Math.random()*(i+1));
[STATE.deck[i],STATE.deck[j]]=[STATE.deck[j],STATE.deck[i]];
}
STATE.deckIdx = 0;
STATE.flipped = false;
document.getElementById('card-inner').classList.remove('flipped');
renderFlashView();
}
function resetDeck() {
buildDeck(STATE.category);
}
async function generateMoreCards() {
const cat = STATE.category === 'all' ? 'greetings' : STATE.category;
const btn = document.querySelector('[onclick="generateMoreCards()"]');
if (btn) { btn.textContent = '⏳ 생성 중...'; btn.disabled = true; }
try {
const res = await fetch('/api/language/generate-flashcards', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({category: cat, count: 10, lang: STATE.lang})
});
const data = await res.json();
if (data.success && data.cards.length) {
const existing = new Set(VOCAB.map(v=>v.word));
const newCards = data.cards.filter(c=>!existing.has(c.word));
VOCAB = [...VOCAB, ...newCards];
buildDeck(STATE.category);
// 저장을 기다린 뒤에 성공을 알린다. 실패하면 saveCustomCards가 자기 경고를 띄우므로
// 여기서 "추가되었습니다"를 겹쳐 띄우지 않는다.
if (await saveCustomCards(newCards)) {
alert(`✅ ${newCards.length}개 새 단어가 추가되었습니다!`);
}
}
} catch(e) { alert('카드 생성 실패: '+e.message); }
finally {
if (btn) { btn.textContent = '✨ AI 카드 생성'; btn.disabled = false; }
}
}
// ═══════════════════════════════════════════════════════
// VOCAB TABLE
// ═══════════════════════════════════════════════════════
function setVocabCat(cat) {
STATE.vocabCat = cat;
document.querySelectorAll('[data-vcat]').forEach(b => b.classList.toggle('active', b.dataset.vcat===cat));
renderVocabTable();
}
function renderVocabTable() {
const q = (document.getElementById('vocab-search').value||'').toLowerCase();
const cat = STATE.vocabCat;
const showRom = document.getElementById('vocab-show-rom').checked;
document.getElementById('vocab-rom-th').style.display = showRom ? '' : 'none';
const rows = VOCAB.filter(v => {
if (cat !== 'all' && v.cat !== cat) return false;
if (q) return v.word.toLowerCase().includes(q) || v.ko.includes(q) || v.rom.includes(q);
return true;
});
document.getElementById('vocab-count').textContent = `${rows.length}개`;
document.getElementById('vocab-tbody').innerHTML = rows.map(v => `
<tr>
<td>
<div style="display:flex;align-items:center;gap:6px">
<div>
<div class="vocab-uz">${escHtml(v.word)}</div>
${showRom?`<div class="vocab-rom">[${escHtml(v.rom)}]</div>`:''}
</div>
<button class="tts-btn" data-uz="${escHtml(v.word)}" onclick="speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
</div>
</td>
${showRom?`<td style="color:var(--muted);font-size:12px;font-style:italic">[${escHtml(v.rom)}]</td>`:''}
<td>${escHtml(v.ko)}</td>
<td><span class="vocab-cat">${escHtml(CAT_LABELS[v.cat]||v.cat)}</span></td>
</tr>
`).join('');
}
// ═══════════════════════════════════════════════════════
// QUIZ
// ═══════════════════════════════════════════════════════
function setQuizCat(cat) {
STATE.quizCat = cat;
document.querySelectorAll('[data-qcat]').forEach(b => b.classList.toggle('active', b.dataset.qcat===cat));
}
function getQuizPool() {
const cat = STATE.quizCat;
return cat==='all' ? VOCAB : VOCAB.filter(v=>v.cat===cat);
}
function shuffle(arr) {
const a = [...arr];
for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];}
return a;
}
function startQuiz() {
const pool = getQuizPool();
if (pool.length < 4) { alert('최소 4개 이상의 단어가 필요합니다.'); return; }
const count = Number(document.getElementById('quiz-count-sel').value);
const qs = shuffle(pool).slice(0, Math.min(count, pool.length));
STATE.quizQs = qs.map(card => {
const wrong = shuffle(pool.filter(v=>v.ko!==card.ko)).slice(0,3).map(v=>v.ko);
const opts = shuffle([card.ko, ...wrong]);
return {card, opts, correct: card.ko};
});
STATE.quizIdx = 0;
STATE.quizScore = 0;
STATE.quizWrong = [];
STATE.quizMode = 'active';
showQuizQuestion();
}
function showQuizQuestion() {
const type = document.getElementById('quiz-type-sel').value;
const {quizQs, quizIdx} = STATE;
const q = quizQs[quizIdx];
document.getElementById('quiz-setup').style.display = 'none';
document.getElementById('quiz-results').style.display = 'none';
document.getElementById('quiz-active').style.display = '';
document.getElementById('quiz-word').textContent = q.card.word;
document.getElementById('quiz-sub').textContent = q.card.rom ? `[${q.card.rom}]` : '';
document.getElementById('quiz-q-label').textContent = `문제 ${quizIdx+1} / ${quizQs.length}`;
document.getElementById('quiz-score-live').textContent = `점수: ${STATE.quizScore}`;
document.getElementById('quiz-prog-fill').style.width = (quizIdx/quizQs.length*100)+'%';
document.getElementById('quiz-feedback').textContent = '';
document.getElementById('quiz-feedback').className = 'quiz-feedback';
if (type === 'multiple') {
document.getElementById('quiz-options').style.display = 'grid';
document.getElementById('quiz-fill-row').style.display = 'none';
document.getElementById('quiz-options').innerHTML = q.opts.map(o=>`
<button class="quiz-opt" onclick="submitQuizMultiple('${o.replace(/'/g,"\\'")}', this)">${o}</button>
`).join('');
} else {
document.getElementById('quiz-options').style.display = 'none';
document.getElementById('quiz-fill-row').style.display = 'flex';
const inp = document.getElementById('quiz-fill-input');
inp.value = '';
inp.className = 'quiz-fill';
inp.disabled = false;
setTimeout(()=>inp.focus(), 50);
}
}
function submitQuizMultiple(answer, btn) {
const q = STATE.quizQs[STATE.quizIdx];
const correct = answer === q.correct;
document.querySelectorAll('.quiz-opt').forEach(b => b.disabled = true);
btn.classList.add(correct ? 'correct' : 'wrong');
if (!correct) {
document.querySelectorAll('.quiz-opt').forEach(b => { if (b.textContent === q.correct) b.classList.add('correct'); });
STATE.quizWrong.push(q.card);
} else {
STATE.quizScore++;
}
const fb = document.getElementById('quiz-feedback');
fb.textContent = correct ? '✅ 정답!' : `❌ 정답: ${q.correct}`;
fb.className = 'quiz-feedback ' + (correct?'correct':'wrong');
setTimeout(()=>{ STATE.quizIdx++; if(STATE.quizIdx>=STATE.quizQs.length) showQuizResults(); else showQuizQuestion(); }, 1000);
}
async function submitQuizFill() {
const q = STATE.quizQs[STATE.quizIdx];
const inp = document.getElementById('quiz-fill-input');
const answer = inp.value.trim();
if (!answer) return;
inp.disabled = true;
// Quick local check first
const norm = s => s.replace(/[^a-z0-9가-힣]/gi,'').toLowerCase();
let correct = norm(answer) === norm(q.correct);
if (!correct && answer.length > 0) {
// Ask server for fuzzy check
try {
const r = await fetch('/api/language/check-answer', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({answer, correct: q.correct, lang: STATE.lang})
});
const d = await r.json();
if (d.success) correct = d.result === 'correct';
} catch {}
}
inp.classList.add(correct ? 'correct' : 'wrong');
if (correct) { STATE.quizScore++; } else { STATE.quizWrong.push(q.card); }
const fb = document.getElementById('quiz-feedback');
fb.textContent = correct ? '✅ 정답!' : `❌ 정답: ${q.correct}`;
fb.className = 'quiz-feedback ' + (correct?'correct':'wrong');
setTimeout(()=>{ STATE.quizIdx++; if(STATE.quizIdx>=STATE.quizQs.length) showQuizResults(); else showQuizQuestion(); }, 1200);
}
function showQuizResults() {
STATE.quizMode = 'results';
document.getElementById('quiz-active').style.display = 'none';
document.getElementById('quiz-results').style.display = '';
const n = STATE.quizScore, total = STATE.quizQs.length;
document.getElementById('result-score').textContent = n;
document.getElementById('result-denom').textContent = `/ ${total}`;
document.getElementById('result-msg').textContent = n===total ? '🎉 완벽해요!' : n >= total*0.7 ? '👏 잘 했어요!' : '💪 더 연습해봐요!';
document.getElementById('wrong-list').innerHTML = STATE.quizWrong.map(c=>`
<div class="wrong-item"><span class="wi-uz">${c.word}</span><span style="color:var(--muted)">→</span><span class="wi-ko">${c.ko}</span></div>
`).join('') || '<div style="color:var(--muted);font-size:13px;text-align:center">오답 없음 🎉</div>';
document.getElementById('btn-review-wrong').style.display = STATE.quizWrong.length ? '' : 'none';
document.getElementById('quiz-prog-fill').style.width = '100%';
}
function resetQuizView() {
document.getElementById('quiz-setup').style.display = '';
document.getElementById('quiz-active').style.display = 'none';
document.getElementById('quiz-results').style.display = 'none';
}
function reviewWrong() {
if (!STATE.quizWrong.length) return;
STATE.quizQs = STATE.quizWrong.map(card => {
const pool = VOCAB.filter(v=>v.ko!==card.ko);
const wrong = shuffle(pool).slice(0,3).map(v=>v.ko);
const opts = shuffle([card.ko, ...wrong]);
return {card, opts, correct: card.ko};
});
STATE.quizIdx = 0; STATE.quizScore = 0; STATE.quizWrong = [];
showQuizQuestion();
}
// ═══════════════════════════════════════════════════════
// LESSONS
// ═══════════════════════════════════════════════════════
function renderLessonGrid() {
const grid = document.getElementById('lessons-grid');
const navList = document.getElementById('lesson-nav-list');
grid.innerHTML = LESSONS_DATA.map(l => {
const done = !!STATE.lessonsDone[l.id];
return `<div class="lesson-card${done?' active':''}" onclick="openLesson(${l.id})">
<span class="lesson-done-badge">${done?'✅':''}</span>
<h3>레슨 ${l.id}. ${l.title}</h3>
<p>${l.desc}</p>
<div class="lesson-prog-bar"><div class="lesson-prog-fill" style="width:${done?100:0}%"></div></div>
</div>`;
}).join('');
navList.innerHTML = LESSONS_DATA.map(l => {
const done = !!STATE.lessonsDone[l.id];
return `<div class="lg-lesson-item${done?' done':''}" onclick="openLesson(${l.id})" id="nav-lesson-${l.id}">
<span class="lg-done-badge">${done?'✅':'○'}</span>
<span>레슨 ${l.id}. ${l.title}</span>
</div>`;
}).join('');
}
function openLesson(id) {
const lesson = LESSONS_DATA.find(l=>l.id===id);
if (!lesson) return;
STATE.currentLesson = lesson;
STATE.lessonStep = 0;
document.getElementById('lesson-detail-title').textContent = `레슨 ${id}. ${lesson.title}`;
document.getElementById('lessons-grid-view').style.display = 'none';
document.getElementById('lesson-detail-view').style.display = 'flex';
renderLessonStep();
}
function closeLessonDetail() {
document.getElementById('lessons-grid-view').style.display = '';
document.getElementById('lesson-detail-view').style.display = 'none';
renderLessonGrid();
}
function renderLessonStep() {
const lesson = STATE.currentLesson;
if (!lesson) return;
const step = lesson.steps[STATE.lessonStep];
const container = document.getElementById('lesson-steps');
let html = '';
if (step.type === 'explain') {
html = `<div class="step-explain"><div class="step-label">설명</div>${step.text}</div>`;
} else if (step.type === 'example') {
html = `<div class="step-example">
<div style="display:flex;align-items:center;gap:8px">
<div class="ex-uz" id="step-uz-text">${step.word}</div>
<button class="tts-btn" id="step-tts-btn" onclick="speakStep()" title="발음 듣기">🔊</button>
</div>
<div class="ex-rom">[${step.rom}]</div>
<div class="ex-ko">${step.ko}</div>
</div>`;
} else if (step.type === 'practice') {
html = `<div class="step-practice">
<div class="prac-q">✏️ ${step.prompt}</div>
<input type="text" placeholder="직접 써보세요..." onkeydown="if(event.key==='Enter')nextLessonStep()">
</div>`;
}
container.innerHTML = html;
document.getElementById('step-counter').textContent = `${STATE.lessonStep+1} / ${lesson.steps.length}`;
document.getElementById('btn-prev-step').disabled = STATE.lessonStep === 0;
document.getElementById('btn-next-step').textContent = STATE.lessonStep === lesson.steps.length-1 ? '✅ 완료' : '다음 →';
}
function speakStep() {
const el = document.getElementById('step-uz-text');
const btn = document.getElementById('step-tts-btn');
if (el) speak(el.textContent, btn);
}
function nextLessonStep() {
const lesson = STATE.currentLesson;
if (!lesson) return;
if (STATE.lessonStep < lesson.steps.length - 1) {
STATE.lessonStep++;
renderLessonStep();
} else {
STATE.lessonsDone[lesson.id] = true;
saveProgress();
closeLessonDetail();
}
}
function prevLessonStep() {
if (STATE.lessonStep > 0) { STATE.lessonStep--; renderLessonStep(); }
}
function resetLessons() {
if (!confirm('모든 레슨 진도를 초기화할까요?')) return;
STATE.lessonsDone = {};
saveProgress();
renderLessonGrid();
}
function askAI() {
const lesson = STATE.currentLesson;
const langName = LANG_CONFIG[STATE.lang].name;
const msg = lesson ? `${langName} "레슨 ${lesson.id}: ${lesson.title}" 관련 질문입니다. 더 자세히 설명해주세요.` : `${langName}에 대해 질문이 있어요.`;
window.location.href = '/?msg=' + encodeURIComponent(msg);
}
// ═══════════════════════════════════════════════════════
// TTS
// ═══════════════════════════════════════════════════════
let _ttsGender = 'female';
let _ttsAudio = null;
let _ttsBtn = null;
function setGender(g) {
_ttsGender = g;
document.getElementById('gender-female').classList.toggle('active', g==='female');
document.getElementById('gender-male').classList.toggle('active', g==='male');
}
async function speak(text, btn) {
if (!text) return;
// Stop any playing audio
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
if (_ttsBtn) { _ttsBtn.classList.remove('playing'); _ttsBtn = null; }
const activBtn = btn || null;
if (activBtn) { activBtn.classList.add('playing'); _ttsBtn = activBtn; }
try {
const res = await fetch('/api/language/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, gender: _ttsGender, lang: STATE.lang })
});
if (!res.ok) throw new Error('TTS 실패');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
_ttsAudio = new Audio(url);
_ttsAudio.onended = () => {
if (_ttsBtn) { _ttsBtn.classList.remove('playing'); _ttsBtn = null; }
URL.revokeObjectURL(url);
};
_ttsAudio.play();
} catch(e) {
if (activBtn) activBtn.classList.remove('playing');
console.error('TTS error:', e);
}
}
function speakCurrent() {
const v = STATE.deck[STATE.deckIdx];
if (v) speak(v.word, document.getElementById('card-tts-btn'));
}
// ═══════════════════════════════════════════════════════
// INTERPRETER (real-time ko ⇄ uz chat panel + voice/call mode)
// ═══════════════════════════════════════════════════════
let _interpBusy = false;
function toggleInterpreter(force) {
const panel = document.getElementById('lg-interp');
const btn = document.getElementById('interp-toggle-btn');
const open = typeof force === 'boolean' ? force : !panel.classList.contains('open');
panel.classList.toggle('open', open);
btn.classList.toggle('active', open);
if (open) toggleTeacher(false); // only one side panel at a time
if (window.innerWidth <= 1024) {
document.getElementById('mob-backdrop').classList.toggle('show', open);
}
if (open) setTimeout(() => document.getElementById('lg-interp-input')?.focus(), 50);
}
function interpAppend(html) {
const msgs = document.getElementById('lg-interp-msgs');
const empty = document.getElementById('lg-interp-empty');
if (empty) empty.remove();
const wrap = document.createElement('div');
wrap.innerHTML = html;
const node = wrap.firstElementChild;
msgs.appendChild(node);
msgs.scrollTop = msgs.scrollHeight;
return node;
}
// Calls /api/language/interpret, renders the reply bubble (with TTS button for
// either language), and returns {translated, targetLang} for callers (call mode)
// that need to auto-play the result. Returns null on failure.
async function interpretAndRender(text) {
const loading = interpAppend(`<div class="im-loading">💬 통역 중...</div>`);
try {
const res = await fetch('/api/language/interpret', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, lang: STATE.lang })
});
const d = await res.json();
loading.remove();
if (!d.success) {
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">통역 실패: ${(d.error||'').toString().slice(0,120)}</div>`);
return null;
}
const targetLang = d.source_lang === 'ko' ? STATE.lang : 'ko';
const langLabel = targetLang === 'ko' ? '🇰🇷 한국어' : `${LANG_CONFIG[STATE.lang].flag} ${LANG_CONFIG[STATE.lang].name}`;
const translated = String(d.translated || '');
const rom = String(d.romanization || '').replace(/</g,'&lt;');
const node = interpAppend(`<div class="im-bubble im-reply">
<div class="im-lang">${langLabel}</div>
<div>${translated.replace(/</g,'&lt;')}<button class="tts-btn im-tts" title="발음 듣기">🔊</button></div>
${rom ? `<div class="im-rom">[${rom}]</div>` : ''}
</div>`);
const ttsBtn = node.querySelector('.im-tts');
if (ttsBtn) ttsBtn.onclick = () => speakLang(translated, targetLang, ttsBtn);
return { translated, targetLang };
} catch (e) {
loading.remove();
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">네트워크 오류: ${String(e.message||e).slice(0,120)}</div>`);
return null;
}
}
async function sendInterpret() {
if (_interpBusy) return;
const inp = document.getElementById('lg-interp-input');
const text = inp.value.trim();
if (!text) return;
inp.value = '';
inp.style.height = '38px';
interpAppend(`<div class="im-bubble im-user">${text.replace(/</g,'&lt;')}</div>`);
_interpBusy = true;
document.getElementById('lg-interp-send-btn').disabled = true;
try { await interpretAndRender(text); }
finally {
_interpBusy = false;
document.getElementById('lg-interp-send-btn').disabled = false;
}
}
// ═══════════════════════════════════════════════════════
// TEACHER (general Q&A chat — grammar/usage/culture, not translation)
// ═══════════════════════════════════════════════════════
let _teacherBusy = false;
let TEACHER_HISTORY = []; // [{role:'user'|'assistant', content}] — resent each turn, capped client-side too
function toggleTeacher(force) {
const panel = document.getElementById('lg-teacher');
const btn = document.getElementById('teacher-toggle-btn');
const open = typeof force === 'boolean' ? force : !panel.classList.contains('open');
panel.classList.toggle('open', open);
btn.classList.toggle('active', open);
if (open) toggleInterpreter(false); // only one side panel at a time
if (window.innerWidth <= 1024) {
document.getElementById('mob-backdrop').classList.toggle('show', open);
}
if (open) setTimeout(() => document.getElementById('lg-teacher-input')?.focus(), 50);
}
function teacherAppend(html) {
const msgs = document.getElementById('lg-teacher-msgs');
const empty = document.getElementById('lg-teacher-empty');
if (empty) empty.remove();
const wrap = document.createElement('div');
wrap.innerHTML = html;
const node = wrap.firstElementChild;
msgs.appendChild(node);
msgs.scrollTop = msgs.scrollHeight;
return node;
}
async function sendTeacherMsg() {
if (_teacherBusy) return;
const inp = document.getElementById('lg-teacher-input');
const text = inp.value.trim();
if (!text) return;
inp.value = '';
inp.style.height = '38px';
teacherAppend(`<div class="im-bubble im-user">${escHtml(text)}</div>`);
_teacherBusy = true;
document.getElementById('lg-teacher-send-btn').disabled = true;
const loading = teacherAppend(`<div class="im-loading">💬 생각 중...</div>`);
try {
const res = await fetch('/api/language/ask-teacher', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, history: TEACHER_HISTORY, lang: STATE.lang })
});
const d = await res.json();
loading.remove();
if (!d.success) {
teacherAppend(`<div class="im-bubble im-reply" style="color:#f87171">답변 실패: ${escHtml((d.error||'').toString().slice(0,120))}</div>`);
return;
}
const reply = String(d.reply || '');
const node = teacherAppend(`<div class="im-bubble im-reply">
${renderTeacherMd(reply)}
<button class="tts-btn im-tts" title="발음 듣기" style="margin-top:4px">🔊</button>
</div>`);
const ttsBtn = node.querySelector('.im-tts');
if (ttsBtn) ttsBtn.onclick = () => speakLang(reply, 'ko', ttsBtn);
TEACHER_HISTORY.push({ role: 'user', content: text }, { role: 'assistant', content: reply });
if (TEACHER_HISTORY.length > 20) TEACHER_HISTORY = TEACHER_HISTORY.slice(-20);
} catch (e) {
loading.remove();
teacherAppend(`<div class="im-bubble im-reply" style="color:#f87171">네트워크 오류: ${escHtml(String(e.message||e).slice(0,120))}</div>`);
} finally {
_teacherBusy = false;
document.getElementById('lg-teacher-send-btn').disabled = false;
}
}
// speakLang: TTS for either language. target lang (uz/de/...) → /api/language/tts
// (edge-tts, per-language voice), ko → /api/voice/tts (configured Korean engine,
// currently OmniVoice GPU). Returns a Promise that resolves when playback finishes
// (used by call mode to know when it's safe to start listening again).
let _ttsLangAudio = null;
function speakLang(text, lang, btn) {
return new Promise(async (resolve) => {
if (!text) { resolve(); return; }
if (_ttsLangAudio) { try { _ttsLangAudio.pause(); } catch {} _ttsLangAudio = null; }
if (btn) btn.classList.add('playing');
try {
const url = lang === 'ko' ? '/api/voice/tts' : '/api/language/tts';
const body = lang === 'ko' ? { text } : { text, gender: _ttsGender, lang };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('TTS 실패');
const blob = await res.blob();
const objUrl = URL.createObjectURL(blob);
_ttsLangAudio = new Audio(objUrl);
_ttsLangAudio.onended = () => { if (btn) btn.classList.remove('playing'); URL.revokeObjectURL(objUrl); resolve(); };
_ttsLangAudio.onerror = () => { if (btn) btn.classList.remove('playing'); resolve(); };
_ttsLangAudio.play();
} catch (e) {
if (btn) btn.classList.remove('playing');
console.error('speakLang error:', e);
resolve();
}
});
}
// ── Single-shot mic input (fills the textarea, doesn't auto-send) ──
let _micRecorder = null, _micStream = null, _micChunks = [];
async function toggleMicInput() {
const btn = document.getElementById('lg-mic-btn');
if (_micRecorder && _micRecorder.state === 'recording') {
_micRecorder.stop();
return;
}
try {
_micStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
} catch (e) {
alert('마이크 권한이 필요합니다: ' + e.message);
return;
}
_micChunks = [];
_micRecorder = new MediaRecorder(_micStream, { mimeType: 'audio/webm' });
_micRecorder.ondataavailable = e => { if (e.data.size > 0) _micChunks.push(e.data); };
_micRecorder.onstop = async () => {
btn.classList.remove('recording');
try { _micStream.getTracks().forEach(t => t.stop()); } catch {}
const blob = new Blob(_micChunks, { type: 'audio/webm' });
if (blob.size < 1000) return;
btn.disabled = true;
try {
const res = await fetch('/api/voice/stt', { method: 'POST', headers: { 'Content-Type': 'audio/webm' }, body: blob });
const d = await res.json();
if (d.success && d.text) {
const inp = document.getElementById('lg-interp-input');
inp.value = (inp.value ? inp.value + ' ' : '') + d.text;
inp.focus();
}
} catch (e) { console.error('STT error:', e); }
finally { btn.disabled = false; }
};
_micRecorder.start();
btn.classList.add('recording');
}
// ── Call mode: continuous listen → STT → translate → speak → repeat ──
const CALL_RMS_ON = 0.020, CALL_RMS_OFF = 0.012;
const CALL_SPEECH_CONFIRM_FRAMES = 6, CALL_SILENCE_HANGOVER_MS = 900;
let callActive = false, callWakeLock = null, callMicStream = null, callAudioCtx = null, callAnalyser = null;
let callRecorder = null, callChunks = [], callState = 'idle', callSpeechFrames = 0, callSilenceMs = 0, callVadRaf = null;
const _callVadData = new Uint8Array(512);
async function callAcquireWakeLock() {
if (!('wakeLock' in navigator)) return;
try { callWakeLock = await navigator.wakeLock.request('screen'); callWakeLock.addEventListener('release', () => { callWakeLock = null; }); } catch {}
}
document.addEventListener('visibilitychange', () => {
if (callActive && callWakeLock === null && document.visibilityState === 'visible') callAcquireWakeLock();
});
function setCallStatus(text) { const el = document.getElementById('lg-call-status'); if (el) el.textContent = text || ''; }
function toggleCall() { if (callActive) hangupCall(); else startCall(); }
async function startCall() {
try {
callMicStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
} catch (e) {
alert('마이크 권한이 필요합니다: ' + e.message);
return;
}
callActive = true;
callAcquireWakeLock();
toggleInterpreter(true);
document.getElementById('lg-call-btn').classList.add('active');
document.getElementById('lg-call-btn').textContent = '📵';
callAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
const source = callAudioCtx.createMediaStreamSource(callMicStream);
callAnalyser = callAudioCtx.createAnalyser();
callAnalyser.fftSize = 512;
source.connect(callAnalyser);
beginCallListening();
}
function hangupCall() {
callActive = false;
if (callVadRaf) cancelAnimationFrame(callVadRaf);
callVadRaf = null;
try { callRecorder && callRecorder.state !== 'inactive' && callRecorder.stop(); } catch {}
try { callMicStream && callMicStream.getTracks().forEach(t => t.stop()); } catch {}
try { callAudioCtx && callAudioCtx.close(); } catch {}
try { callWakeLock && callWakeLock.release(); } catch {}
callWakeLock = null; callMicStream = null; callAudioCtx = null; callAnalyser = null; callRecorder = null;
callState = 'idle';
setCallStatus('');
const btn = document.getElementById('lg-call-btn');
if (btn) { btn.classList.remove('active'); btn.textContent = '📞'; }
}
function beginCallListening() {
if (!callActive) return;
callState = 'listening';
callSpeechFrames = 0; callSilenceMs = 0;
setCallStatus('🎙️ 듣는 중...');
callChunks = [];
callRecorder = new MediaRecorder(callMicStream, { mimeType: 'audio/webm' });
callRecorder.ondataavailable = e => { if (e.data.size > 0) callChunks.push(e.data); };
callRecorder.onstop = onCallRecordingStop;
callRecorder.start();
runCallVadLoop();
}
function runCallVadLoop() {
if (!callActive || (callState !== 'listening' && callState !== 'speaking')) return;
callAnalyser.getByteTimeDomainData(_callVadData);
let sumSq = 0;
for (let i = 0; i < _callVadData.length; i++) { const v = (_callVadData[i] - 128) / 128; sumSq += v * v; }
const rms = Math.sqrt(sumSq / _callVadData.length);
const FRAME_MS = 16;
if (callState === 'listening') {
if (rms > CALL_RMS_ON) { callSpeechFrames++; if (callSpeechFrames > CALL_SPEECH_CONFIRM_FRAMES) { callState = 'speaking'; setCallStatus('🗣️ 말하는 중...'); } }
else callSpeechFrames = 0;
} else if (callState === 'speaking') {
if (rms < CALL_RMS_OFF) { callSilenceMs += FRAME_MS; if (callSilenceMs > CALL_SILENCE_HANGOVER_MS) { stopCallListeningAndProcess(); return; } }
else callSilenceMs = 0;
}
callVadRaf = requestAnimationFrame(runCallVadLoop);
}
function stopCallListeningAndProcess() {
if (callState === 'processing') return;
callState = 'processing';
setCallStatus('💬 통역 중...');
try { callRecorder.stop(); } catch {}
}
async function onCallRecordingStop() {
if (!callActive) return;
const blob = new Blob(callChunks, { type: 'audio/webm' });
if (blob.size < 2000) { beginCallListening(); return; }
try {
const sttRes = await fetch('/api/voice/stt', { method: 'POST', headers: { 'Content-Type': 'audio/webm' }, body: blob });
const sttData = await sttRes.json();
const text = (sttData.text || '').trim();
if (!text) { beginCallListening(); return; }
interpAppend(`<div class="im-bubble im-user">🎙️ ${text.replace(/</g,'&lt;')}</div>`);
const result = await interpretAndRender(text);
if (result && callActive) {
setCallStatus('🔊 재생 중...');
await speakLang(result.translated, result.targetLang, null);
}
} catch (e) {
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">오류: ${String(e.message || e).slice(0,120)}</div>`);
} finally {
if (callActive) beginCallListening();
}
}
// ═══════════════════════════════════════════════════════
// STATS
// ═══════════════════════════════════════════════════════
function updateStatChip() {
document.getElementById('stat-known').textContent = `알고 있는 단어 ${STATE.known.size}개`;
const done = Object.values(STATE.lessonsDone).filter(Boolean).length;
document.getElementById('hdr-progress').textContent = `레슨 ${done}/${LESSONS_DATA.length} 완료 · 어휘 ${VOCAB.length}개`;
}
// ═══════════════════════════════════════════════════════
// LANGUAGE SWITCH
// ═══════════════════════════════════════════════════════
function applyLangUI() {
const cfg = LANG_CONFIG[STATE.lang];
document.getElementById('doc-title').textContent = `${cfg.flag} ${cfg.title}`;
document.title = `${cfg.flag} ${cfg.title}`;
document.getElementById('hdr-flag').textContent = cfg.flag;
document.getElementById('hdr-title').textContent = cfg.title;
document.getElementById('vocab-word-th').textContent = cfg.name;
document.getElementById('gender-female').textContent = `👩 ${cfg.voices.female.label}`;
document.getElementById('gender-male').textContent = `👨 ${cfg.voices.male.label}`;
document.getElementById('interp-lang-label').textContent = cfg.interpLabel;
document.getElementById('lg-interp-input').placeholder = cfg.interpPlaceholder;
document.getElementById('teacher-title').textContent = `👨‍🏫 ${cfg.name} 선생님`;
// Only the still-empty placeholder hint needs updating — an in-progress chat keeps its history.
const interpEmpty = document.getElementById('lg-interp-empty');
if (interpEmpty) interpEmpty.innerHTML = cfg.interpEmpty;
}
function renderLangSwitcher() {
const el = document.getElementById('lang-switcher');
el.innerHTML = Object.keys(LANG_CONFIG).map(code => {
const cfg = LANG_CONFIG[code];
const activeCls = code === STATE.lang ? ' active' : '';
return `<button class="lg-lang-btn${activeCls}" onclick="setLanguage('${code}')" title="${escHtml(cfg.name)}">${cfg.flag}</button>`;
}).join('');
}
function setLanguage(lang) {
if (!LANG_CONFIG[lang] || lang === STATE.lang) return;
STATE.lang = lang;
localStorage.setItem('lg_selected_lang', lang);
LESSONS_DATA = LESSONS_DATA_BY_LANG[lang];
VOCAB = [...VOCAB_DATA[lang]];
loadCustomCards();
applyCustomCategories(lang);
syncCustomCategories(lang);
STATE.category = 'all'; STATE.vocabCat = 'all'; STATE.quizCat = 'all';
STATE.currentLesson = null;
loadProgress();
resetQuizView();
applyLangUI();
renderLangSwitcher();
renderCategoryButtons();
buildDeck('all');
document.getElementById('flash-title').textContent = '플래시카드 — 전체';
if (STATE.nav === 'vocab') renderVocabTable();
if (STATE.nav === 'lessons') {
document.getElementById('lessons-grid-view').style.display = '';
document.getElementById('lesson-detail-view').style.display = 'none';
renderLessonGrid();
}
updateStatChip();
}
// ═══════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════
loadProgress();
loadCustomCards();
applyCustomCategories(STATE.lang);
syncCustomCategories(STATE.lang);
renderCategoryButtons();
buildDeck('all');
applyLangUI();
renderLangSwitcher();
updateStatChip();
</script>
</body>
</html>