Files
homeclaw/web-ui/language-app.html
T
kimandClaude Sonnet 4.6 d74614cee1 v3.2.0: 카카오톡 연동 + 탐정앱 업그레이드 + 모바일 대응 강화
- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 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>
2026-06-26 11:15:27 +09:00

1173 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🇺🇿 우즈베크어 학습</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,'&quot;')}" 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>