"어휘카드를 추가해도 자꾸 없어진다" 리포트. 카드는 custom-cards.json으로
이미 서버에 있었는데 **카테고리는 localStorage에만** 있었다. 브라우저
데이터를 지우거나 다른 기기에서 열면 카테고리 버튼이 통째로 사라지고,
그 카테고리로 만든 카드는 분류 탭에서 안 보인다(전체 탭에만 남아
"없어진" 것처럼 보인다).
custom-categories.json에 언어별로 저장한다. 병합 방식인 게 중요하다 —
클라이언트가 보낸 목록으로 덮어쓰면 localStorage가 빈 새 기기에서 앱을
한 번 여는 것만으로 서버 카테고리가 전부 날아간다. 빈 배열을 보내도
기존 값이 유지되는 걸 확인했다. 로컬에만 있던 카테고리는 첫 접속 때
서버로 올라가므로 지금 브라우저에 남아 있는 건 손실 없이 넘어간다.
같이 고친 것: saveCustomCards가 .catch(()=>{})로 실패를 삼키면서 호출부는
"✅ N개 추가되었습니다"를 띄우고 있었다. 세션 만료로 POST가 401이 나도
사용자는 성공한 줄 알고, 새로고침하면 카드가 없다 — 리포트된 증상과
정확히 같은 모양이다. 이제 저장을 기다린 뒤 결과에 맞는 문구를 띄운다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2047 lines
114 KiB
HTML
2047 lines
114 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 '>' 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*>\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*>\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,'<');
|
||
const node = interpAppend(`<div class="im-bubble im-reply">
|
||
<div class="im-lang">${langLabel}</div>
|
||
<div>${translated.replace(/</g,'<')}<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,'<')}</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,'<')}</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>
|