- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 5초 제한 우회) - 카카오 "나에게 보내기" OAuth 알림 (kakao-notify.ts) - 채널 설정 UI + userMap으로 다중 사용자 계정 매핑 - 탐정앱: 탭(메모/체크리스트/타임라인), 우선순위, 검색, 보고서/고소장/접수안내 생성 - 모바일 브레이크포인트 640px → 1024px (갤럭시 폴드 대응) - 프롬프트 파일 제한 상향 (USER.md 3000, SOUL.md/AGENTS.md 4000) - 교통앱 마지막 선택 지역 localStorage 저장/복원 - docs/kakao-setup.md 작성 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1173 lines
64 KiB
HTML
1173 lines
64 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<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="cherry_logo.png">
|
||
<link rel="stylesheet" href="styles.css">
|
||
<script>
|
||
try {
|
||
var _t = localStorage.getItem('cherryclaw_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)); }
|
||
.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; }
|
||
|
||
/* ── 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; }
|
||
|
||
/* ── 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:0;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;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:400;
|
||
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">🇺🇿</span>
|
||
<h1>우즈베크어 학습</h1>
|
||
<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>
|
||
<a href="/" style="font-size:12px;color:var(--muted);text-decoration:none;margin-left:4px">← 메인</a>
|
||
</div>
|
||
|
||
<div id="mob-backdrop" onclick="mobCloseFilter()"></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>
|
||
<button class="lg-cat-btn active" onclick="setCategory('all')" data-cat="all">전체</button>
|
||
<button class="lg-cat-btn" onclick="setCategory('greetings')" data-cat="greetings">인사</button>
|
||
<button class="lg-cat-btn" onclick="setCategory('numbers')" data-cat="numbers">숫자</button>
|
||
<button class="lg-cat-btn" onclick="setCategory('food')" data-cat="food">음식</button>
|
||
<button class="lg-cat-btn" onclick="setCategory('verbs')" data-cat="verbs">동사</button>
|
||
<button class="lg-cat-btn" onclick="setCategory('travel')" data-cat="travel">여행</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>
|
||
<button class="lg-cat-btn active" onclick="setVocabCat('all')" data-vcat="all">전체</button>
|
||
<button class="lg-cat-btn" onclick="setVocabCat('greetings')" data-vcat="greetings">인사</button>
|
||
<button class="lg-cat-btn" onclick="setVocabCat('numbers')" data-vcat="numbers">숫자</button>
|
||
<button class="lg-cat-btn" onclick="setVocabCat('food')" data-vcat="food">음식</button>
|
||
<button class="lg-cat-btn" onclick="setVocabCat('verbs')" data-vcat="verbs">동사</button>
|
||
<button class="lg-cat-btn" onclick="setVocabCat('travel')" data-vcat="travel">여행</button>
|
||
</div>
|
||
|
||
<div class="lg-nav-content" id="sb-quiz" style="display:none">
|
||
<div class="lg-section-label">퀴즈 설정</div>
|
||
<button class="lg-cat-btn active" onclick="setQuizCat('all')" data-qcat="all">전체</button>
|
||
<button class="lg-cat-btn" onclick="setQuizCat('greetings')" data-qcat="greetings">인사</button>
|
||
<button class="lg-cat-btn" onclick="setQuizCat('numbers')" data-qcat="numbers">숫자</button>
|
||
<button class="lg-cat-btn" onclick="setQuizCat('food')" data-qcat="food">음식</button>
|
||
<button class="lg-cat-btn" onclick="setQuizCat('verbs')" data-qcat="verbs">동사</button>
|
||
<button class="lg-cat-btn" onclick="setQuizCat('travel')" data-qcat="travel">여행</button>
|
||
</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="renderCard()" style="accent-color:#22c55e">발음 표시
|
||
</label>
|
||
</div>
|
||
<div class="card-wrap">
|
||
<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>
|
||
|
||
<!-- ═══ 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>우즈베크어</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-direction">우즈베크어의 한국어 의미는?</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 -->
|
||
</div><!-- /lg-layout -->
|
||
|
||
<script>
|
||
// ── 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 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<=640)mobCloseFilter();};
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// DATA
|
||
// ═══════════════════════════════════════════════════════
|
||
const CAT_LABELS = { greetings:'인사', numbers:'숫자', food:'음식', verbs:'동사', travel:'여행' };
|
||
|
||
const VOCAB_BASE = [
|
||
// 인사
|
||
{uz:'Salom', rom:'살롬', ko:'안녕하세요', cat:'greetings'},
|
||
{uz:'Xayr', rom:'하이르', ko:'안녕히 가세요', cat:'greetings'},
|
||
{uz:'Rahmat', rom:'라흐맛', ko:'감사합니다', cat:'greetings'},
|
||
{uz:'Iltimos', rom:'일티모스', ko:'부탁합니다', cat:'greetings'},
|
||
{uz:'Ha', rom:'하', ko:'네', cat:'greetings'},
|
||
{uz:'Yoʻq', rom:'요으q', ko:'아니요', cat:'greetings'},
|
||
{uz:'Kechirasiz', rom:'케치라시즈', ko:'실례합니다', cat:'greetings'},
|
||
{uz:'Tushunmadim', rom:'투슈나딤', ko:'이해 못 했습니다', cat:'greetings'},
|
||
{uz:'Yaxshimisiz?', rom:'야흐쉬미시즈', ko:'잘 지내시나요?', cat:'greetings'},
|
||
{uz:'Yaxshi', rom:'야흐쉬', ko:'좋아요', cat:'greetings'},
|
||
// 숫자
|
||
{uz:'bir', rom:'비르', ko:'1', cat:'numbers'},
|
||
{uz:'ikki', rom:'익키', ko:'2', cat:'numbers'},
|
||
{uz:'uch', rom:'우치', ko:'3', cat:'numbers'},
|
||
{uz:'toʻrt', rom:'토으르트', ko:'4', cat:'numbers'},
|
||
{uz:'besh', rom:'베쉬', ko:'5', cat:'numbers'},
|
||
{uz:'olti', rom:'올티', ko:'6', cat:'numbers'},
|
||
{uz:'yetti', rom:'예티', ko:'7', cat:'numbers'},
|
||
{uz:'sakkiz', rom:'사끼즈', ko:'8', cat:'numbers'},
|
||
{uz:'toʻqqiz',rom:'토으q기즈', ko:'9', cat:'numbers'},
|
||
{uz:"oʻn", rom:'오으느', ko:'10', cat:'numbers'},
|
||
{uz:"oʻn bir",rom:'오으느 비르', ko:'11', cat:'numbers'},
|
||
{uz:"oʻn ikki",rom:'오으느 익키',ko:'12', cat:'numbers'},
|
||
{uz:"oʻn besh",rom:'오으느 베쉬',ko:'15', cat:'numbers'},
|
||
{uz:'yigirma',rom:'이기르마', ko:'20', cat:'numbers'},
|
||
{uz:'ellik', rom:'엘릭', ko:'50', cat:'numbers'},
|
||
{uz:'yuz', rom:'유즈', ko:'100', cat:'numbers'},
|
||
// 음식
|
||
{uz:'non', rom:'논', ko:'빵', cat:'food'},
|
||
{uz:'suv', rom:'수브', ko:'물', cat:'food'},
|
||
{uz:'gosht', rom:'고슈트', ko:'고기', cat:'food'},
|
||
{uz:'tuxum', rom:'투훔', ko:'달걀', cat:'food'},
|
||
{uz:'sabzavot', rom:'사브자보트', ko:'채소', cat:'food'},
|
||
{uz:'meva', rom:'메바', ko:'과일', cat:'food'},
|
||
{uz:'choy', rom:'초이', ko:'차', cat:'food'},
|
||
{uz:'sharbat', rom:'샤르밧', ko:'주스', cat:'food'},
|
||
{uz:'guruch', rom:'구루치', ko:'쌀', cat:'food'},
|
||
{uz:'osh', rom:'오쉬', ko:'필라프', cat:'food'},
|
||
// 동사
|
||
{uz:'bormoq', rom:'보르모q', ko:'가다', cat:'verbs'},
|
||
{uz:'kelmoq', rom:'켈모q', ko:'오다', cat:'verbs'},
|
||
{uz:'yemoq', rom:'예모q', ko:'먹다', cat:'verbs'},
|
||
{uz:'ichmoq', rom:'이치모q', ko:'마시다', cat:'verbs'},
|
||
{uz:"koʻrmoq", rom:'코으르모q', ko:'보다', cat:'verbs'},
|
||
{uz:'gapirmoq', rom:'가피르모q', ko:'말하다', cat:'verbs'},
|
||
{uz:"oʻqimoq", rom:'오으끼모q', ko:'읽다', cat:'verbs'},
|
||
{uz:'yozmoq', rom:'요즈모q', ko:'쓰다', cat:'verbs'},
|
||
{uz:'sotib olmoq',rom:'소팁 올모q',ko:'사다', cat:'verbs'},
|
||
{uz:'yurmoq', rom:'유르모q', ko:'걷다', cat:'verbs'},
|
||
// 여행
|
||
{uz:'stansiya', rom:'스탄시야', ko:'역', cat:'travel'},
|
||
{uz:'aeroport', rom:'아에로포르트', ko:'공항', cat:'travel'},
|
||
{uz:'mehmonxona', rom:'메흐몬호나', ko:'호텔', cat:'travel'},
|
||
{uz:'bozor', rom:'보조르', ko:'시장', cat:'travel'},
|
||
{uz:'shifoxona', rom:'쉬포호나', ko:'병원', cat:'travel'},
|
||
{uz:'narxi', rom:'나르히', ko:'가격', cat:'travel'},
|
||
{uz:'chiqish', rom:'치끼쉬', ko:'출구', cat:'travel'},
|
||
{uz:'kirish', rom:'키리쉬', ko:'입구', cat:'travel'},
|
||
{uz:'taksit', rom:'탁시트', ko:'택시', cat:'travel'},
|
||
{uz:'xarita', rom:'하리타', ko:'지도', cat:'travel'},
|
||
];
|
||
|
||
const LESSONS_DATA = [
|
||
{ id:1, title:'인사와 자기소개', cats:['greetings'], desc:'Salom부터 자기소개까지',
|
||
steps:[
|
||
{type:'explain', text:'우즈베크어 인사는 하루 중 언제든 "Salom [살롬]"을 씁니다. 공식적인 상황에서는 "Assalomu alaykum [아살로무 알라이쿰]"을 쓰기도 해요.'},
|
||
{type:'example', uz:'Salom! Men Kimman.', rom:'살롬! 멘 Kim만.', ko:'안녕하세요! 저는 Kim입니다.'},
|
||
{type:'explain', text:'"Men [이름]man" 구조로 자기소개를 합니다. "Men" = 저는, "[이름]man" = ~입니다. SOV 어순이라 한국어와 비슷해요!'},
|
||
{type:'example', uz:'Men koreyalikman.', rom:'멘 코레야릭만.', ko:'저는 한국 사람입니다.'},
|
||
{type:'practice', prompt:'자신을 소개해 보세요: Men ___man.'},
|
||
{type:'example', uz:'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', uz:'bir, ikki, uch, toʻrt, besh', rom:'비르, 익키, 우치, 토으르트, 베쉬', ko:'1, 2, 3, 4, 5'},
|
||
{type:'example', uz:"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', uz:"Menga osh bering.", rom:'메나 오쉬 베링.', ko:'필라프 주세요.'},
|
||
{type:'example', uz:"Narxi qancha?", rom:'나르히 칸차?', ko:'가격이 얼마예요?'},
|
||
{type:'example', uz:"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', uz:'bormoq → boraman → bordim', rom:'보르모q → 보라만 → 보르딤', ko:'가다 → 가요 → 갔어요'},
|
||
{type:'example', uz:'kelmoq → kelaman → keldim', rom:'켈모q → 켈라만 → 켈딤', ko:'오다 → 와요 → 왔어요'},
|
||
{type:'explain', text:'부정형은 -ma-를 삽입합니다: boraman (가요) → bormayman (안 가요)'},
|
||
{type:'example', uz:'Men yemoyman.', rom:'멘 예모이만.', ko:'저는 먹지 않아요.'},
|
||
{type:'practice', prompt:"'저는 마셔요'를 우즈베크어로 쓰세요. (ich + aman)"},
|
||
]
|
||
},
|
||
{ id:5, title:'여행 필수 표현', cats:['travel'], desc:'공항·호텔·택시에서 쓰는 말',
|
||
steps:[
|
||
{type:'explain', text:'여행 중 가장 많이 쓰는 표현들입니다. 방향 묻기, 가격 확인, 위치 찾기가 핵심이에요.'},
|
||
{type:'example', uz:'Mehmonxona qayerda?', rom:'메흐몬호나 카예르다?', ko:'호텔이 어디에 있나요?'},
|
||
{type:'example', uz:'Aeroport qancha?', rom:'아에로포르트 칸차?', ko:'공항까지 얼마예요?'},
|
||
{type:'example', uz:'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', uz:"oʻn bir, oʻn ikki, oʻn besh", rom:'오으느 비르, 오으느 익키, 오으느 베쉬', ko:'11, 12, 15'},
|
||
{type:'example', uz:'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', uz:"Bugun ob-havo qanday?", rom:'부군 오브-하보 칸다이?', ko:'오늘 날씨 어때요?'},
|
||
{type:'example', uz:"Hozir yaxshi his qilyapman.", rom:'호지르 야흐쉬 히스 킬야프만.', ko:'지금 기분이 좋아요.'},
|
||
{type:'example', uz:"Ertaga uchrashaylik.", rom:'에르타가 우치라샤이릭.', ko:'내일 만나요.'},
|
||
{type:'practice', prompt:"'저는 피곤해요'를 표현해 보세요. (charchagan = 피곤한)"},
|
||
]
|
||
},
|
||
{ id:8, title:'쇼핑과 흥정', cats:['travel','numbers'], desc:'시장에서 가격 흥정하기',
|
||
steps:[
|
||
{type:'explain', text:'우즈베키스탄 시장에서는 흥정이 일반적입니다. 가격을 묻고, 깎고, 사는 표현을 익혀봐요.'},
|
||
{type:'example', uz:"Bu qancha turadi?", rom:'부 칸차 투라디?', ko:'이것은 얼마예요?'},
|
||
{type:'example', uz:"Qimmat! Arzonroq bering.", rom:'킴맛! 아르존로q 베링.', ko:'비싸요! 좀 깎아주세요.'},
|
||
{type:'example', uz:"Yaxshi, olamiz.", rom:'야흐쉬, 올라미즈.', ko:'좋아요, 살게요.'},
|
||
{type:'explain', text:"'qimmat [킴맛]'은 비싸다, 'arzon [아르존]'은 싸다. -roq를 붙이면 '더 ~'라는 비교급이에요."},
|
||
{type:'practice', prompt:"'더 싸게 해주세요'를 표현해 보세요."},
|
||
]
|
||
},
|
||
];
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// STATE
|
||
// ═══════════════════════════════════════════════════════
|
||
let VOCAB = [...VOCAB_BASE];
|
||
let STATE = {
|
||
nav: 'flash',
|
||
category: 'all',
|
||
vocabCat: 'all',
|
||
quizCat: 'all',
|
||
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,
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// PERSISTENCE
|
||
// ═══════════════════════════════════════════════════════
|
||
function loadProgress() {
|
||
try {
|
||
const p = JSON.parse(localStorage.getItem('uz_progress') || '{}');
|
||
if (p.known) STATE.known = new Set(p.known);
|
||
if (p.lessonsDone) STATE.lessonsDone = p.lessonsDone;
|
||
} catch {}
|
||
}
|
||
function saveProgress() {
|
||
try {
|
||
localStorage.setItem('uz_progress', JSON.stringify({
|
||
known: [...STATE.known],
|
||
lessonsDone: STATE.lessonsDone,
|
||
}));
|
||
// also sync to server (fire-and-forget)
|
||
fetch('/api/language/progress/papa', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ known: [...STATE.known], lessonsDone: STATE.lessonsDone })
|
||
}).catch(()=>{});
|
||
} catch {}
|
||
updateStatChip();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// THEME
|
||
// ═══════════════════════════════════════════════════════
|
||
function setTheme(t) {
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
localStorage.setItem('cherryclaw_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');
|
||
renderCard();
|
||
}
|
||
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 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.uz;
|
||
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.uz);
|
||
saveProgress();
|
||
nextCard();
|
||
}
|
||
function markUnknown() {
|
||
nextCard();
|
||
}
|
||
function updateKnownBar() {
|
||
const deckKnown = STATE.deck.filter(v=>STATE.known.has(v.uz)).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');
|
||
renderCard();
|
||
}
|
||
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})
|
||
});
|
||
const data = await res.json();
|
||
if (data.success && data.cards.length) {
|
||
const existing = new Set(VOCAB.map(v=>v.uz));
|
||
const newCards = data.cards.filter(c=>!existing.has(c.uz));
|
||
VOCAB = [...VOCAB, ...newCards];
|
||
buildDeck(STATE.category);
|
||
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.uz.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">${v.uz}</div>
|
||
${showRom?`<div class="vocab-rom">[${v.rom}]</div>`:''}
|
||
</div>
|
||
<button class="tts-btn" data-uz="${v.uz.replace(/"/g,'"')}" onclick="speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
|
||
</div>
|
||
</td>
|
||
${showRom?`<td style="color:var(--muted);font-size:12px;font-style:italic">[${v.rom}]</td>`:''}
|
||
<td>${v.ko}</td>
|
||
<td><span class="vocab-cat">${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.uz;
|
||
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})
|
||
});
|
||
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.uz}</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.uz}</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 msg = lesson ? `우즈베크어 "레슨 ${lesson.id}: ${lesson.title}" 관련 질문입니다. 더 자세히 설명해주세요.` : '우즈베크어에 대해 질문이 있어요.';
|
||
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 })
|
||
});
|
||
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.uz, document.getElementById('card-tts-btn'));
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 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}개`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// INIT
|
||
// ═══════════════════════════════════════════════════════
|
||
loadProgress();
|
||
buildDeck('all');
|
||
updateStatChip();
|
||
</script>
|
||
</body>
|
||
</html>
|