feat(trainer): add GitHub activity heatmap, 4-option quiz mode, active typing, and duplicate checks

This commit is contained in:
2026-09-23 10:02:29 +00:00
parent d31c77fdd0
commit 2294121777
16 changed files with 4318 additions and 685 deletions
+385
View File
@@ -1905,6 +1905,382 @@ body {
justify-content: center;
}
/* Duplicate Warning Banner */
.duplicate-warning-box {
margin-top: 10px;
padding: 10px 14px;
background: rgba(234, 179, 8, 0.12);
border: 1px solid rgba(234, 179, 8, 0.35);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
animation: fadeIn 0.2s ease-in-out;
}
.dup-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #facc15;
}
.dup-icon {
font-size: 16px;
}
/* Multiple Choice Quiz Mode */
.quiz-card {
max-width: 680px;
margin: 0 auto;
}
.quiz-hero {
text-align: center;
padding: 20px 0 16px 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
}
.quiz-prompt-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.quiz-term-display {
font-size: 32px;
font-weight: 800;
color: var(--text-main);
letter-spacing: -0.02em;
}
.quiz-context-hint {
margin-top: 8px;
font-size: 14px;
color: var(--text-muted);
font-style: italic;
}
.quiz-options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
.quiz-option-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
background: var(--bg-main);
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-main);
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.quiz-option-btn:hover:not(:disabled) {
border-color: var(--primary);
background: var(--bg-surface-hover);
transform: translateY(-1px);
}
.quiz-option-btn:disabled {
cursor: default;
}
.quiz-opt-num {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg-surface);
border: 1px solid var(--border-color);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
flex-shrink: 0;
}
.quiz-opt-text {
flex: 1;
line-height: 1.35;
}
.quiz-option-btn.opt-correct {
background: rgba(34, 197, 94, 0.15) !important;
border-color: var(--success) !important;
color: var(--success) !important;
}
.quiz-option-btn.opt-correct .quiz-opt-num {
background: var(--success);
color: #ffffff;
border-color: var(--success);
}
.quiz-option-btn.opt-wrong {
background: rgba(239, 68, 68, 0.15) !important;
border-color: var(--danger) !important;
color: var(--danger) !important;
animation: shake 0.3s ease;
}
.quiz-option-btn.opt-wrong .quiz-opt-num {
background: var(--danger);
color: #ffffff;
border-color: var(--danger);
}
.quiz-next-bar {
margin-top: 16px;
}
/* Active Typing Mode */
.typing-hero {
text-align: center;
padding: 16px 0 20px 0;
}
.typing-prompt-ru {
font-size: 28px;
font-weight: 800;
color: var(--primary);
margin: 8px 0;
}
.typing-context-hint {
font-size: 14px;
color: var(--text-muted);
font-style: italic;
background: var(--bg-main);
padding: 8px 12px;
border-radius: var(--radius-sm);
display: inline-block;
max-width: 90%;
margin-top: 4px;
}
/* Analytics & Activity Heatmap Modal */
.stats-modal-window {
max-width: 820px;
width: 95%;
}
.stats-hero-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.stat-kpi-card {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px 12px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.streak-kpi {
border-color: rgba(249, 115, 22, 0.4);
background: rgba(249, 115, 22, 0.05);
}
.kpi-icon {
font-size: 24px;
}
.kpi-value {
font-size: 26px;
font-weight: 800;
line-height: 1.1;
}
.kpi-label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.kpi-sub {
font-size: 11px;
color: var(--text-dim);
}
.text-accent {
color: #38bdf8;
}
/* GitHub Heatmap Section */
.heatmap-section {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 20px;
}
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
flex-wrap: wrap;
gap: 8px;
}
.heatmap-header h4 {
font-size: 15px;
font-weight: 700;
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-dim);
}
.legend-cell {
width: 12px;
height: 12px;
border-radius: 2px;
display: inline-block;
}
.heatmap-container-wrap {
overflow-x: auto;
padding-bottom: 6px;
}
.heatmap-grid {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, 12px);
gap: 4px;
min-width: 600px;
}
.heatmap-cell {
width: 12px;
height: 12px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: transform 0.1s ease;
position: relative;
}
.heatmap-cell:hover {
transform: scale(1.3);
z-index: 10;
outline: 1px solid var(--text-main);
}
/* Heatmap Levels (GitHub-style intensity) */
.lvl-0 { background: rgba(255, 255, 255, 0.06); }
.lvl-1 { background: #166534; } /* 1 review: soft green */
.lvl-2 { background: #22c55e; } /* 2-3 reviews: vibrant green */
.lvl-3 { background: #4ade80; } /* 4+ reviews: bright intense green */
[data-theme="light"] .lvl-0 { background: #e2e8f0; }
[data-theme="light"] .lvl-1 { background: #86efac; }
[data-theme="light"] .lvl-2 { background: #22c55e; }
[data-theme="light"] .lvl-3 { background: #15803d; }
.heatmap-footer-note {
margin-top: 10px;
color: var(--text-dim);
font-size: 11px;
}
/* Segmented Mastery Progress Bar */
.mastery-section {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
}
.mastery-section h4 {
font-size: 15px;
font-weight: 700;
margin-bottom: 12px;
}
.segmented-progress-bar {
display: flex;
height: 14px;
background: var(--bg-surface);
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: 12px;
border: 1px solid var(--border-color);
}
.prog-segment {
height: 100%;
transition: width 0.3s ease;
}
.seg-mastered { background: #22c55e; }
.seg-review { background: #38bdf8; }
.seg-learning { background: #f59e0b; }
.seg-new { background: #64748b; }
.mastery-legend-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 12px;
}
.m-legend-item {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.dot-mastered { background: #22c55e; }
.dot-review { background: #38bdf8; }
.dot-learning { background: #f59e0b; }
.dot-new { background: #64748b; }
.dot-leech { background: #ef4444; }
.text-danger { color: #ef4444; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
/* Responsive */
@media (max-width: 640px) {
.navbar {
@@ -1939,12 +2315,14 @@ body {
min-width: 0;
width: 100%;
}
.fc-flip-btn-wrap {
display: block;
}
.srs-topbar {
padding: 10px 12px;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.srs-topbar-left, .srs-topbar-right {
justify-content: space-between;
@@ -1955,6 +2333,12 @@ body {
.srs-rating-bar {
grid-template-columns: 1fr 1fr;
}
.quiz-options-grid {
grid-template-columns: 1fr;
}
.stats-hero-grid {
grid-template-columns: 1fr 1fr;
}
.export-grid {
grid-template-columns: 1fr;
}
@@ -1972,3 +2356,4 @@ body {
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+73
View File
@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a"/>
<stop offset="50%" stop-color="#1e293b"/>
<stop offset="100%" stop-color="#090d16"/>
</linearGradient>
<linearGradient id="accentGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#38bdf8"/>
<stop offset="100%" stop-color="#0284c7"/>
</linearGradient>
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#34d399"/>
<stop offset="100%" stop-color="#059669"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="16" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Base Rounded Container -->
<rect width="512" height="512" rx="115" fill="url(#bgGrad)"/>
<rect width="510" height="510" x="1" y="1" rx="114" fill="none" stroke="#334155" stroke-width="2"/>
<rect width="500" height="500" x="6" y="6" rx="110" fill="none" stroke="url(#accentGrad)" stroke-width="3" stroke-opacity="0.4"/>
<!-- Glowing Accent Light -->
<circle cx="256" cy="180" r="140" fill="#38bdf8" opacity="0.12" filter="url(#glow)"/>
<!-- Open Book Symbol -->
<g transform="translate(96, 150)">
<!-- Book Left Page -->
<path d="M152 40 C110 20, 45 22, 10 32 C4 34, 0 39, 0 46 L0 236 C0 244, 7 250, 15 248 C48 240, 110 238, 152 258 Z"
fill="#1e293b" stroke="#38bdf8" stroke-width="8" stroke-linejoin="round"/>
<!-- Book Right Page -->
<path d="M168 40 C210 20, 275 22, 310 32 C316 34, 320 39, 320 46 L320 236 C320 244, 313 250, 305 248 C272 240, 210 238, 168 258 Z"
fill="#1e293b" stroke="#38bdf8" stroke-width="8" stroke-linejoin="round"/>
<!-- Spine -->
<path d="M160 36 L160 262" stroke="#38bdf8" stroke-width="10" stroke-linecap="round"/>
<!-- Left Page Text Lines -->
<line x1="36" y1="84" x2="124" y2="76" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
<line x1="36" y1="124" x2="124" y2="116" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
<line x1="36" y1="164" x2="104" y2="156" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
<!-- Right Page Text Lines -->
<line x1="196" y1="76" x2="284" y2="84" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
<line x1="196" y1="116" x2="284" y2="124" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
<line x1="196" y1="156" x2="254" y2="164" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
<!-- Bookmark Ribbon -->
<path d="M152 26 L152 140 L160 128 L168 140 L168 26 Z" fill="url(#emeraldGrad)"/>
</g>
<!-- SRS / Spaced Repetition Learning Sparkle / Cap atop -->
<g transform="translate(256, 120)">
<circle cx="0" cy="0" r="14" fill="#38bdf8"/>
<path d="M-8 0 L8 0 M0 -8 L0 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
<circle cx="-38" cy="12" r="6" fill="#34d399"/>
<circle cx="38" cy="12" r="6" fill="#34d399"/>
</g>
<!-- Bottom Accent Text "NOTES" -->
<text x="256" y="446"
font-family="system-ui, -apple-system, sans-serif"
font-size="34"
font-weight="900"
letter-spacing="6"
fill="#f8fafc"
text-anchor="middle">NOTES</text>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+673 -83
View File
@@ -1,65 +1,93 @@
<!DOCTYPE html>
<html lang="ru">
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes — Персональный тренажёр английского языка</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Notes — Персональный тренажер и словарь английского языка</title>
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>">
<!-- PWA meta and icons -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Notes English">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
</head>
<body>
<!-- Login Modal / Screen -->
<div id="auth-screen" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-header">
<div class="app-logo">📖</div>
<h2>Вход в Notes</h2>
<p>Персональный словарь и тренажёр языка</p>
<!-- Login Overlay / Page -->
<div id="login-overlay" class="login-overlay hidden">
<div class="login-card">
<div class="login-header">
<div class="login-logo">📖</div>
<h1>Notes</h1>
<p>Персональный словарь и тренажёр английского языка</p>
</div>
<form id="login-form">
<form id="login-form" class="login-form">
<div class="form-group">
<label for="username">Имя пользователя</label>
<input type="text" id="username" autocomplete="username" required placeholder="admin" autofocus>
<label for="login-username">Имя пользователя</label>
<input type="text" id="login-username" class="input-styled" autocomplete="username" required autofocus placeholder="admin">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" autocomplete="current-password" required placeholder="••••••••">
<label for="login-password">Пароль</label>
<input type="password" id="login-password" class="input-styled" autocomplete="current-password" required placeholder="••••••••">
</div>
<div id="auth-error" class="error-banner hidden"></div>
<button type="submit" id="login-btn" class="btn btn-primary btn-block">Войти в систему</button>
<div id="login-error" class="login-error hidden"></div>
<button type="submit" id="login-submit-btn" class="btn btn-primary btn-block">
Войти в словарь
</button>
</form>
<div class="auth-footer">
<span>Защищённый контур: notes.qpid.pro</span>
<div class="login-footer">
<small>Безопасное хранилище словаря • notes.qpid.pro</small>
</div>
</div>
</div>
<!-- Main Application UI -->
<div id="app-container" class="app-container hidden">
<!-- Top Navigation -->
<!-- Main Application Shell -->
<div id="app-shell" class="app-shell">
<!-- Top Navigation Bar -->
<header class="navbar">
<div class="nav-brand">
<div class="nav-brand" id="nav-brand-home" title="На главную">
<span class="brand-icon">📖</span>
<span class="brand-title">Notes</span>
<span class="brand-sub">English Lexicon</span>
</div>
<div class="nav-stats" id="nav-stats">
<span class="stat-badge" title="Всего слов в словаре">Всего: <strong id="stat-total">0</strong></span>
<span class="stat-badge stat-today" title="Добавлено сегодня">Сегодня: <strong id="stat-today">+0</strong></span>
<div class="nav-center">
<div class="nav-stats" id="nav-stats">
<span class="stat-badge" title="Всего слов в словаре">Всего: <strong id="stat-total">0</strong></span>
<span class="stat-badge stat-today" title="Добавлено сегодня">Сегодня: <strong id="stat-today">+0</strong></span>
</div>
</div>
<div class="nav-actions">
<button id="srs-trainer-btn" class="btn btn-trainer" title="Интервальный тренажер (Flashcards, 4 варианта, диктант, ввод)">
<span class="trainer-icon">🎓</span>
<span class="trainer-text">Тренажёр SRS</span>
<span id="srs-badge" class="srs-due-badge">0</span>
</button>
<button id="stats-open-btn" class="btn btn-outline btn-sm stat-open-btn" title="Активность (GitHub Heatmap) и аналитика">
<span>📊 Статистика</span>
</button>
<button id="export-open-btn" class="btn btn-outline btn-sm" title="Экспорт словаря">
<span>📤 Экспорт</span>
</button>
<button id="theme-toggle" class="icon-btn" title="Переключить тему">🌓</button>
<div class="user-profile">
<span id="user-display-name" class="user-badge">user</span>
<button id="change-pwd-open-btn" class="btn btn-sm btn-outline" title="Сменить пароль">🔑 Пароль</button>
<button id="logout-btn" class="btn btn-sm btn-outline" title="Выйти из аккаунта">Выход</button>
</div>
</div>
</header>
<main class="main-content">
<!-- VIEW 1: Main Dictionary & Capture View -->
<main class="main-content" id="dictionary-view">
<!-- Search & Translation Section -->
<section class="search-section">
<div class="search-box-wrapper">
@@ -70,6 +98,15 @@
<button id="translate-btn" class="btn btn-primary translate-action-btn">Перевести</button>
</div>
<!-- Duplicate Warning Notification -->
<div id="duplicate-warning-box" class="duplicate-warning-box hidden">
<div class="dup-left">
<span class="dup-icon">⚠️</span>
<span class="dup-text">Это слово уже есть в вашем словаре: <strong id="dup-term-label"></strong> (<em id="dup-trans-label"></em>)</span>
</div>
<button id="dup-open-btn" class="btn btn-xs btn-outline">Посмотреть / Дополнить</button>
</div>
<div class="hotkey-hints">
<span class="hint-item"><kbd>Enter</kbd> перевод</span>
<span class="hint-item"><kbd>Ctrl</kbd>+<kbd>Enter</kbd> сохранить</span>
@@ -78,58 +115,51 @@
</div>
</div>
<!-- Loader -->
<div id="lookup-spinner" class="lookup-spinner hidden">
<div class="spinner"></div>
<span>Ищем толкование, произношение и перевод...</span>
</div>
<!-- Live Translation & Dictionary Lookup Result Card -->
<div id="lookup-card" class="lookup-card hidden">
<div class="lookup-header">
<div class="lookup-term-group">
<h2 id="res-term" class="res-term">Word</h2>
<span id="res-phonetic" class="res-phonetic">/wɜːd/</span>
<button id="audio-play-btn" class="icon-btn audio-btn hidden" title="Прослушать произношение">🔊</button>
</div>
<span id="res-pos" class="badge-pos">Noun</span>
</div>
<!-- Live Result Card -->
<div id="result-card" class="result-card hidden">
<div class="result-header">
<div class="term-title-row">
<h2 id="res-term" class="res-term">reluctant</h2>
<span id="res-phonetic" class="res-phonetic badge-pill">/rɪˈlʌktənt/</span>
<span id="res-pos" class="res-pos badge-pill">Adjective</span>
<button id="play-audio-btn" class="audio-btn" title="Прослушать произношение">
🔊 <span class="audio-text">Слушать</span>
</button>
<!-- English Definition if available -->
<div id="res-definition-wrap" class="definition-wrap hidden">
<div class="def-label">Толкование (EN):</div>
<div id="res-definition" class="res-definition"></div>
</div>
<!-- Primary Translation Field (Editable before saving) -->
<div class="form-group edit-translation-group">
<label for="edit-translation">Перевод на русский язык:</label>
<div class="input-with-button">
<input type="text" id="edit-translation" class="input-styled" placeholder="Русский перевод...">
</div>
</div>
<div id="res-definition-box" class="definition-box">
<div class="def-label">Толкование (English):</div>
<p id="res-definition" class="def-text"></p>
<!-- Context & Extra Details Box -->
<div class="context-group">
<label for="edit-context">Контекстное предложение (где встретилось выражение):</label>
<textarea id="edit-context" class="input-styled textarea-context" rows="2" placeholder="He was reluctant to admit his mistake during the team retrospective..."></textarea>
</div>
<div class="card-edit-grid">
<div class="field-group">
<label for="edit-translation">Перевод на русский <span class="req">*</span></label>
<input type="text" id="edit-translation" class="input-styled" placeholder="Основной перевод...">
<div id="translation-suggestions" class="suggestions-chips"></div>
</div>
<div class="field-group">
<label for="edit-context">Контекстное предложение (где встретилось?)</label>
<input type="text" id="edit-context" class="input-styled" placeholder="Например: The switch drops the frame if the VLAN ID is invalid.">
</div>
<!-- Organization: Collection & Tags Row -->
<div class="meta-inputs-grid">
<div class="field-row">
<div class="field-group flex-2">
<label for="edit-tags">Теги (через запятую)</label>
<input type="text" id="edit-tags" class="input-styled" placeholder="general, phrasal-verb, networking">
<div class="quick-tags" id="quick-tags-bar">
<span class="tag-chip" data-tag="general">#general</span>
<span class="tag-chip" data-tag="phrasal-verb">#phrasal-verb</span>
<span class="tag-chip" data-tag="idiom">#idiom</span>
<span class="tag-chip" data-tag="daily">#daily</span>
<span class="tag-chip" data-tag="tech">#tech</span>
<span class="tag-chip" data-tag="work">#work</span>
</div>
<div class="form-group flex-1">
<label for="edit-collection">📁 Коллекция (папка):</label>
<input type="text" id="edit-collection" list="collections-datalist" class="input-styled" value="General" placeholder="General, IT, Work...">
<datalist id="collections-datalist"></datalist>
</div>
<div class="field-group flex-1">
<label for="edit-notes">Заметка / Мнемоника</label>
<div class="form-group flex-1">
<label for="edit-tags">🏷️ Теги (через запятую):</label>
<input type="text" id="edit-tags" class="input-styled" placeholder="#phrasal-verb, #work, #daily" value="general">
</div>
<div class="form-group flex-1">
<label for="edit-notes">💡 Заметка / Ассоциация:</label>
<input type="text" id="edit-notes" class="input-styled" placeholder="Ассоциация...">
</div>
</div>
@@ -152,15 +182,32 @@
</div>
<div class="list-controls">
<button id="batch-toggle-btn" class="btn btn-sm btn-outline batch-mode-btn" title="Режим множественного выбора">
☑️ Выбрать
</button>
<div class="search-mini-box">
<input type="text" id="filter-input" placeholder="Фильтр по слову, переводу, контексту...">
</div>
</div>
</div>
<!-- Collections Filter Bar -->
<div class="collections-filter-wrap">
<div class="filter-header-bar">
<span class="filter-title">📁 Коллекции:</span>
<button id="new-collection-btn" class="btn btn-xs btn-ghost">+ Новая папка</button>
</div>
<div id="collections-filter-bar" class="filter-chips-row">
<button class="col-chip active" data-col="">Все коллекции</button>
</div>
</div>
<!-- Active Tags Filter -->
<div id="tags-filter-bar" class="tags-filter-bar">
<button class="filter-chip active" data-tag="">Все теги</button>
<div class="tags-filter-wrap">
<span class="filter-title">🏷️ Теги:</span>
<div id="tags-filter-bar" class="filter-chips-row">
<button class="filter-chip active" data-tag="">Все теги</button>
</div>
</div>
<!-- Cards Grid/List -->
@@ -173,32 +220,446 @@
</div>
</section>
</main>
<!-- VIEW 2: SRS Trainer Interactive View -->
<section id="srs-view" class="srs-view hidden">
<!-- SRS Top Bar -->
<header class="srs-topbar">
<div class="srs-topbar-left">
<button id="srs-exit-btn" class="btn btn-sm btn-outline">← В словарь</button>
<div class="srs-mode-switch">
<button class="srs-tab active" data-mode="flashcards">📇 Карточки</button>
<button class="srs-tab" data-mode="quiz">🎯 4 Варианта</button>
<button class="srs-tab" data-mode="typing">✍️ Набор слова</button>
<button class="srs-tab" data-mode="cloze">🧩 Пропуски</button>
<button class="srs-tab" data-mode="dictation">🎧 Диктант</button>
</div>
</div>
<div class="srs-topbar-center">
<div class="srs-progress-container">
<div id="srs-progress-fill" class="srs-progress-fill" style="width: 0%;"></div>
</div>
<span id="srs-counter" class="srs-counter">Карточка 0 из 0</span>
</div>
<div class="srs-topbar-right">
<button id="srs-mix-btn" class="btn btn-sm btn-ghost" title="Умный микс дня: 10 новых + 10 повторов + 5 трудных слов">
<span id="srs-mix-label">🎲 Микс дня: ВКЛ</span>
</button>
<button id="srs-dir-btn" class="btn btn-sm btn-secondary" title="Сменить направление">
<span id="srs-dir-label">EN ➔ RU</span> ⇄
</button>
</div>
</header>
<!-- SRS Main Interactive Arena -->
<div class="srs-arena">
<!-- Loading State -->
<div id="srs-loading" class="srs-state-box hidden">
<div class="spinner"></div>
<p>Загрузка карточек для тренировки...</p>
</div>
<!-- Empty State -->
<div id="srs-empty" class="srs-state-box hidden">
<div class="srs-big-icon">🎉</div>
<h3>На сегодня всё повторено!</h3>
<p>В очереди нет слов, требующих интервального повторения.</p>
<div class="srs-empty-buttons">
<button id="srs-train-all-btn" class="btn btn-primary btn-lg">Тренировать все слова вне очереди</button>
<button id="srs-empty-back-btn" class="btn btn-outline btn-lg">Вернуться в словарь</button>
</div>
</div>
<!-- MODE 1: Flashcards -->
<div id="srs-mode-flashcards" class="srs-mode-container hidden">
<div class="flashcard-wrapper" id="flashcard-flip-wrap">
<div class="flashcard-inner" id="flashcard-inner">
<!-- Card Front -->
<div class="flashcard-face flashcard-front" id="flashcard-front">
<div class="fc-badge-row">
<span class="fc-dir-badge" id="fc-front-dir-badge">EN ➔ RU</span>
<span class="fc-pos-badge" id="fc-front-pos">Adjective</span>
</div>
<div class="fc-center">
<h1 class="fc-main-term" id="fc-front-main">reluctant</h1>
<div class="fc-phonetic-row" id="fc-front-phonetic-wrap">
<span class="res-phonetic" id="fc-front-phonetic">/rɪˈlʌktənt/</span>
<button id="fc-front-audio-btn" class="icon-btn audio-round-btn" title="Озвучить (Ctrl+P / 🔊)">🔊</button>
</div>
<div class="fc-hint-wrapper">
<button id="fc-hint-btn" class="btn btn-xs btn-ghost">💡 Показать контекст</button>
<div id="fc-hint-box" class="fc-hint-content hidden"></div>
</div>
</div>
<div class="fc-footer-hint">
<kbd>Space</kbd> или клик по карточке — перевернуть
</div>
</div>
<!-- Card Back -->
<div class="flashcard-face flashcard-back" id="flashcard-back">
<div class="fc-badge-row">
<span class="fc-dir-badge">ОТВЕТ</span>
<span class="fc-pos-badge" id="fc-back-pos">Adjective</span>
</div>
<div class="fc-back-content">
<div class="fc-back-top">
<h2 class="fc-back-term" id="fc-back-term">reluctant</h2>
<button id="fc-back-audio-btn" class="icon-btn audio-round-btn" title="Озвучить">🔊</button>
</div>
<div class="fc-back-translation" id="fc-back-translation">неохотный, нежелающий</div>
<div class="fc-back-detail-box" id="fc-back-def-box">
<span class="detail-label">Толкование:</span>
<p id="fc-back-def" class="detail-text"></p>
</div>
<div class="fc-back-detail-box" id="fc-back-context-box">
<span class="detail-label">Контекст:</span>
<p id="fc-back-context" class="detail-text"></p>
</div>
<div class="fc-back-detail-box" id="fc-back-notes-box">
<span class="detail-label">Заметка:</span>
<p id="fc-back-notes" class="detail-text"></p>
</div>
</div>
</div>
</div>
</div>
<!-- Flip button (mobile accessible) -->
<div id="fc-flip-btn-wrap" class="fc-flip-btn-wrap">
<button id="fc-flip-btn" class="btn btn-primary btn-block btn-lg">Показать ответ (Space)</button>
</div>
<!-- Rating Buttons -->
<div id="fc-rating-bar" class="srs-rating-bar hidden">
<button class="btn-rate btn-rate-1" data-rating="1" title="Горячая клавиша: 1">
<span class="rate-key">[1]</span>
<span class="rate-name">Снова</span>
<span class="rate-time">&lt; 1 мин</span>
</button>
<button class="btn-rate btn-rate-2" data-rating="2" title="Горячая клавиша: 2">
<span class="rate-key">[2]</span>
<span class="rate-name">Трудно</span>
<span class="rate-time">1 день</span>
</button>
<button class="btn-rate btn-rate-3" data-rating="3" title="Горячая клавиша: 3">
<span class="rate-key">[3]</span>
<span class="rate-name">Хорошо</span>
<span class="rate-time">3 дня</span>
</button>
<button class="btn-rate btn-rate-4" data-rating="4" title="Горячая клавиша: 4">
<span class="rate-key">[4]</span>
<span class="rate-name">Легко</span>
<span class="rate-time">7 дней</span>
</button>
</div>
</div>
<!-- MODE 2: Multiple Choice Quiz (4 options) -->
<div id="srs-mode-quiz" class="srs-mode-container hidden">
<div class="trainer-card quiz-card">
<div class="trainer-header">
<span class="mode-tag">🎯 Выбор из 4 вариантов</span>
<span class="trainer-pos" id="quiz-pos"></span>
</div>
<div class="quiz-hero">
<div class="quiz-prompt-row">
<h2 id="quiz-term-display" class="quiz-term-display">word</h2>
<button id="quiz-audio-btn" class="icon-btn audio-round-btn" title="Озвучить">🔊</button>
</div>
<div id="quiz-phonetic-display" class="res-phonetic"></div>
<div id="quiz-context-hint" class="quiz-context-hint hidden"></div>
</div>
<div class="quiz-options-grid" id="quiz-options-grid">
<!-- 4 option buttons generated dynamically -->
</div>
<div id="quiz-feedback-box" class="trainer-feedback-box hidden"></div>
<div id="quiz-details-box" class="trainer-revealed-details hidden"></div>
<div id="quiz-next-bar" class="quiz-next-bar hidden">
<button id="quiz-next-btn" class="btn btn-primary btn-lg btn-block">Следующее слово (Enter / Space)</button>
</div>
</div>
</div>
<!-- MODE 3: Active Typing Mode (Ru -> En) -->
<div id="srs-mode-typing" class="srs-mode-container hidden">
<div class="trainer-card">
<div class="trainer-header">
<span class="mode-tag">✍️ Набор слова на английском</span>
<span class="trainer-pos" id="typing-pos"></span>
</div>
<div class="typing-hero">
<span class="hint-label">Переведите на английский язык:</span>
<h2 id="typing-prompt-ru" class="typing-prompt-ru">слово</h2>
<div id="typing-context-hint" class="typing-context-hint hidden"></div>
</div>
<form id="typing-form" class="trainer-form">
<div class="input-action-row">
<input type="text" id="typing-input" class="input-styled input-hero" placeholder="Напечатайте английское слово или связку..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus>
<button type="submit" id="typing-submit-btn" class="btn btn-primary">Проверить</button>
</div>
</form>
<div id="typing-feedback-box" class="trainer-feedback-box hidden"></div>
<div id="typing-details-box" class="trainer-revealed-details hidden"></div>
<!-- Rating bar for typing -->
<div id="typing-rating-bar" class="srs-rating-bar hidden"></div>
</div>
</div>
<!-- MODE 4: Cloze Deletion -->
<div id="srs-mode-cloze" class="srs-mode-container hidden">
<div class="trainer-card">
<div class="trainer-header">
<span class="mode-tag">🧩 Пропуски в контексте</span>
<span class="trainer-pos" id="cloze-pos"></span>
</div>
<div class="cloze-sentence-display" id="cloze-sentence-display">
The switch <span class="gap-placeholder">_____</span> the frame if invalid.
</div>
<div class="cloze-translation-hint">
<span class="hint-label">Перевод целевого слова:</span>
<strong id="cloze-target-translation">неохотный</strong>
</div>
<form id="cloze-form" class="trainer-form">
<div class="input-action-row">
<input type="text" id="cloze-input" class="input-styled input-hero" placeholder="Введите пропущенное слово..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus>
<button type="submit" id="cloze-submit-btn" class="btn btn-primary">Проверить</button>
</div>
</form>
<div id="cloze-feedback-box" class="trainer-feedback-box hidden"></div>
<div id="cloze-details-box" class="trainer-revealed-details hidden"></div>
<!-- Rating bar for cloze -->
<div id="cloze-rating-bar" class="srs-rating-bar hidden"></div>
</div>
</div>
<!-- MODE 5: Audio Dictation -->
<div id="srs-mode-dictation" class="srs-mode-container hidden">
<div class="trainer-card">
<div class="trainer-header">
<span class="mode-tag">🎧 Аудио-диктант</span>
<button id="dictation-replay-btn" class="btn btn-sm btn-ghost audio-replay-btn">
🔊 Повторить звук <span class="kbd-sub"><kbd>Space</kbd></span>
</button>
</div>
<div class="dictation-hero">
<button id="dictation-big-play-btn" class="dictation-pulse-btn" title="Слушать произношение">
🔊
</button>
<p class="dictation-instructions">Слушайте произношение нативного диктора и напечатайте слово</p>
</div>
<div class="dictation-hint-wrap">
<button id="dictation-toggle-hint-btn" class="btn btn-xs btn-ghost">💡 Показать перевод-подсказку</button>
<span id="dictation-hint-text" class="dictation-hint-text hidden"></span>
</div>
<form id="dictation-form" class="trainer-form">
<div class="input-action-row">
<input type="text" id="dictation-input" class="input-styled input-hero" placeholder="Напечатайте услышанное слово на английском..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus>
<button type="submit" id="dictation-submit-btn" class="btn btn-primary">Проверить</button>
</div>
</form>
<div id="dictation-feedback-box" class="trainer-feedback-box hidden"></div>
<div id="dictation-details-box" class="trainer-revealed-details hidden"></div>
<!-- Rating bar for dictation -->
<div id="dictation-rating-bar" class="srs-rating-bar hidden"></div>
</div>
</div>
<!-- Session Complete Summary Screen -->
<div id="srs-summary" class="srs-summary-card hidden">
<div class="summary-trophy">🏆</div>
<h2>Сессия завершена!</h2>
<p class="summary-lead">Вы успешно закрепили знания и освежили долговременную память.</p>
<div class="summary-metrics">
<div class="metric-card">
<span class="metric-val" id="sum-count">0</span>
<span class="metric-desc">Повторено слов</span>
</div>
<div class="metric-card">
<span class="metric-val text-success" id="sum-retention">100%</span>
<span class="metric-desc">Успешность (Хорошо/Легко)</span>
</div>
<div class="metric-card">
<span class="metric-val text-primary" id="sum-streak">🔥 1</span>
<span class="metric-desc">Серия дней</span>
</div>
</div>
<div class="summary-rates-bar" id="sum-rates-breakdown"></div>
<div class="summary-buttons">
<button id="sum-repeat-btn" class="btn btn-primary btn-lg">🔄 Повторить ещё сессию</button>
<button id="sum-return-btn" class="btn btn-outline btn-lg">📚 Вернуться в словарь</button>
</div>
</div>
</div>
</section>
<!-- Sticky Bulk Action Bar for Batch Selection Mode -->
<div id="batch-action-bar" class="batch-action-bar hidden">
<div class="batch-left">
<label class="batch-check-label">
<input type="checkbox" id="batch-select-all" class="batch-master-checkbox">
<span id="batch-count-label">Выбрано: 0</span>
</label>
</div>
<div class="batch-actions">
<button id="batch-move-btn" class="btn btn-sm btn-outline">📁 В коллекцию</button>
<button id="batch-tag-btn" class="btn btn-sm btn-outline">🏷️ Добавить тег</button>
<button id="batch-delete-btn" class="btn btn-sm btn-danger">🗑️ Удалить</button>
<button id="batch-cancel-btn" class="btn btn-sm btn-ghost">✕ Отмена</button>
</div>
</div>
</div>
<!-- Analytics & Activity Heatmap Modal -->
<div id="stats-modal" class="modal-backdrop hidden">
<div class="modal-window stats-modal-window">
<div class="modal-header">
<h3>📊 Аналитика обучения & Активность</h3>
<button id="close-stats-modal-btn" class="icon-btn">✕</button>
</div>
<div class="modal-body">
<!-- Streak & Core Stats Hero -->
<div class="stats-hero-grid">
<div class="stat-kpi-card streak-kpi">
<div class="kpi-icon">🔥</div>
<div class="kpi-value" id="kpi-current-streak">0</div>
<div class="kpi-label">Текущая серия дней</div>
<div class="kpi-sub" id="kpi-longest-streak">Рекорд: 0 дней</div>
</div>
<div class="stat-kpi-card">
<div class="kpi-icon">🎯</div>
<div class="kpi-value text-success" id="kpi-retention-rate">100%</div>
<div class="kpi-label">Точность запоминания</div>
<div class="kpi-sub" id="kpi-total-reviews">Всего повторений: 0</div>
</div>
<div class="stat-kpi-card">
<div class="kpi-icon">📚</div>
<div class="kpi-value" id="kpi-total-words">0</div>
<div class="kpi-label">Слов в словаре</div>
<div class="kpi-sub" id="kpi-today-added">Сегодня добавлено: +0</div>
</div>
<div class="stat-kpi-card">
<div class="kpi-icon">🎓</div>
<div class="kpi-value text-accent" id="kpi-cefr-level">A1</div>
<div class="kpi-label">Оценка уровня</div>
<div class="kpi-sub" id="kpi-cefr-desc">Начальный словарный запас</div>
</div>
</div>
<!-- GitHub-Style Contribution Heatmap -->
<div class="heatmap-section">
<div class="heatmap-header">
<h4>🟩 Календарь активности (GitHub-style Heatmap)</h4>
<div class="heatmap-legend">
<span>Меньше</span>
<span class="legend-cell lvl-0" title="0 повторений"></span>
<span class="legend-cell lvl-1" title="1 повторение"></span>
<span class="legend-cell lvl-2" title="2-3 повторения"></span>
<span class="legend-cell lvl-3" title="4+ повторений"></span>
<span>Больше</span>
</div>
</div>
<div class="heatmap-container-wrap">
<div id="heatmap-grid" class="heatmap-grid">
<!-- Heatmap cells generated dynamically -->
</div>
</div>
<div class="heatmap-footer-note">
<small>Цвет ячейки показывает количество пройденных тестов и интервальных повторений за день.</small>
</div>
</div>
<!-- Mastery Distribution Progress -->
<div class="mastery-section">
<h4>📈 Стадии освоения слов (SM-2 SRS)</h4>
<div class="segmented-progress-bar">
<div id="prog-mastered" class="prog-segment seg-mastered" style="width: 0%;" title="Выучено (интервал > 21 дня)"></div>
<div id="prog-review" class="prog-segment seg-review" style="width: 0%;" title="Повторение"></div>
<div id="prog-learning" class="prog-segment seg-learning" style="width: 0%;" title="В процессе (1-3 повтора)"></div>
<div id="prog-new" class="prog-segment seg-new" style="width: 100%;" title="Новые слова"></div>
</div>
<div class="mastery-legend-row">
<div class="m-legend-item"><span class="dot dot-mastered"></span> Выучено: <strong id="cnt-mastered">0</strong></div>
<div class="m-legend-item"><span class="dot dot-review"></span> Закрепление: <strong id="cnt-review">0</strong></div>
<div class="m-legend-item"><span class="dot dot-learning"></span> В процессе: <strong id="cnt-learning">0</strong></div>
<div class="m-legend-item"><span class="dot dot-new"></span> Новые: <strong id="cnt-new">0</strong></div>
<div class="m-legend-item text-danger"><span class="dot dot-leech"></span> Трудные слова: <strong id="cnt-leeches">0</strong></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="close-stats-btn" class="btn btn-primary">Закрыть</button>
</div>
</div>
</div>
<!-- Edit Card Modal -->
<div id="edit-modal" class="modal-backdrop hidden">
<div class="modal-window">
<div class="modal-header">
<h3>Редактирование карточки</h3>
<h3>✏️ Редактировать карточку</h3>
<button id="close-modal-btn" class="icon-btn">✕</button>
</div>
<div class="modal-body">
<input type="hidden" id="modal-card-id">
<div class="form-group">
<label>Слово / Фраза</label>
<input type="text" id="modal-term" class="input-styled">
<label>Слово / Выражение (EN)</label>
<input type="text" id="modal-term" class="input-styled" required>
</div>
<div class="form-group">
<label>Перевод на русский</label>
<input type="text" id="modal-translation" class="input-styled">
<label>Перевод (RU)</label>
<input type="text" id="modal-translation" class="input-styled" required>
</div>
<div class="form-group">
<label>Контекстное предложение</label>
<input type="text" id="modal-context" class="input-styled">
<textarea id="modal-context" class="input-styled" rows="2"></textarea>
</div>
<div class="field-row">
<div class="form-group flex-1">
<label>Транскрипция (IPA)</label>
<input type="text" id="modal-phonetic" class="input-styled">
</div>
<div class="form-group flex-1">
<label>Часть речи</label>
<input type="text" id="modal-pos" class="input-styled">
</div>
</div>
<div class="form-group">
<label>Теги</label>
<input type="text" id="modal-tags" class="input-styled">
<label>Английское толкование</label>
<textarea id="modal-def" class="input-styled" rows="2"></textarea>
</div>
<div class="field-row">
<div class="form-group flex-1">
<label>Коллекция (Папка)</label>
<input type="text" id="modal-collection" class="input-styled">
</div>
<div class="form-group flex-1">
<label>Теги</label>
<input type="text" id="modal-tags" class="input-styled">
</div>
</div>
<div class="form-group">
<label>Заметки / Мнемоника</label>
@@ -212,6 +673,135 @@
</div>
</div>
<!-- Export Modal -->
<div id="export-modal" class="modal-backdrop hidden">
<div class="modal-window export-modal-window">
<div class="modal-header">
<h3>📤 Экспорт словаря</h3>
<button id="close-export-modal-btn" class="icon-btn">✕</button>
</div>
<div class="modal-body">
<p class="modal-lead" id="export-modal-desc">Выберите удобный формат для выгрузки карточек:</p>
<div class="export-grid">
<div class="export-tile" data-format="csv">
<div class="export-tile-icon">📊</div>
<div class="export-tile-info">
<h4>CSV (Excel / Sheets)</h4>
<p>Таблица со всеми полями, переводами и примерами (UTF-8 BOM для русского языка).</p>
</div>
<button class="btn btn-sm btn-primary">Скачать</button>
</div>
<div class="export-tile" data-format="anki">
<div class="export-tile-icon">🗃️</div>
<div class="export-tile-info">
<h4>Колода для Anki (.tsv)</h4>
<p>Готовый файл для импорта в Anki с транскрипцией, озвучкой и контекстом.</p>
</div>
<button class="btn btn-sm btn-primary">Скачать</button>
</div>
<div class="export-tile" data-format="json">
<div class="export-tile-icon">📋</div>
<div class="export-tile-info">
<h4>JSON (Полный архив)</h4>
<p>Полная структурированная резервная копия данных словаря.</p>
</div>
<button class="btn btn-sm btn-primary">Скачать</button>
</div>
<div class="export-tile" data-format="md">
<div class="export-tile-icon">📝</div>
<div class="export-tile-info">
<h4>Markdown (.md)</h4>
<p>Красивый структурированный конспект для Obsidian, Notion или чтения.</p>
</div>
<button class="btn btn-sm btn-primary">Скачать</button>
</div>
</div>
</div>
<div class="modal-footer">
<span id="export-scope-label" class="text-dim">Объём экспорта: все карточки</span>
<button id="export-cancel-btn" class="btn btn-outline">Закрыть</button>
</div>
</div>
</div>
<!-- Move Collection Modal -->
<div id="move-col-modal" class="modal-backdrop hidden">
<div class="modal-window modal-sm">
<div class="modal-header">
<h3>📁 Переместить в коллекцию</h3>
<button id="close-move-modal-btn" class="icon-btn">✕</button>
</div>
<div class="modal-body">
<p class="modal-lead" id="move-modal-count-label">Выберите коллекцию для выбранных карточек:</p>
<div class="form-group">
<label for="move-col-input">Название коллекции</label>
<input type="text" id="move-col-input" list="move-collections-datalist" class="input-styled" placeholder="Например: Разговорный">
<datalist id="move-collections-datalist"></datalist>
</div>
<div class="quick-tags" id="move-col-quick-list"></div>
</div>
<div class="modal-footer">
<button id="move-col-cancel-btn" class="btn btn-outline">Отмена</button>
<button id="move-col-confirm-btn" class="btn btn-primary">Переместить</button>
</div>
</div>
</div>
<!-- New Collection Modal -->
<div id="new-col-modal" class="modal-backdrop hidden">
<div class="modal-window modal-sm">
<div class="modal-header">
<h3>📁 Новая коллекция</h3>
<button id="close-new-col-modal-btn" class="icon-btn">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-col-name-input">Название новой коллекции</label>
<input type="text" id="new-col-name-input" class="input-styled" placeholder="Например: Сериалы / Netflix">
</div>
</div>
<div class="modal-footer">
<button id="new-col-cancel-btn" class="btn btn-outline">Отмена</button>
<button id="new-col-save-btn" class="btn btn-primary">Создать</button>
</div>
</div>
</div>
<!-- Change Password Modal -->
<div id="change-password-modal" class="modal-backdrop hidden">
<div class="modal-window modal-sm">
<div class="modal-header">
<h3>🔑 Смена пароля</h3>
<button id="close-change-pwd-btn" class="icon-btn">✕</button>
</div>
<form id="change-password-form">
<div class="modal-body">
<div class="form-group">
<label for="current-password-input">Текущий пароль</label>
<input type="password" id="current-password-input" class="input-styled" autocomplete="current-password" required placeholder="••••••••">
</div>
<div class="form-group">
<label for="new-password-input">Новый пароль</label>
<input type="password" id="new-password-input" class="input-styled" autocomplete="new-password" required minlength="6" placeholder="Минимум 6 символов">
</div>
<div class="form-group">
<label for="confirm-password-input">Подтвердите новый пароль</label>
<input type="password" id="confirm-password-input" class="input-styled" autocomplete="new-password" required minlength="6" placeholder="Повторите новый пароль">
</div>
<div id="change-pwd-error" class="error-banner hidden"></div>
<div id="change-pwd-success" class="success-banner hidden"></div>
</div>
<div class="modal-footer">
<button type="button" id="change-pwd-cancel-btn" class="btn btn-outline">Отмена</button>
<button type="submit" id="change-pwd-save-btn" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<!-- Audio Element for native streaming -->
<audio id="global-audio-player" preload="none"></audio>
+1498 -450
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "Notes English — Personal Vocabulary",
"short_name": "Notes",
"description": "Персональный словарь английского языка с переводом, транскрипцией и произношением.",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"lang": "ru",
"orientation": "portrait-primary",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["education", "productivity"],
"screenshots": []
}
+101
View File
@@ -0,0 +1,101 @@
// Service Worker for Notes English PWA (Stage 2)
// Strategy: Cache-first for static assets, Network-first for API calls.
const CACHE_NAME = 'notes-v2.0';
const OFFLINE_URL = '/';
const PRECACHE_URLS = [
'/',
'/static/css/style.css',
'/static/js/app.js',
'/manifest.json',
];
// -----------------------------------------------------------------------
// Install: pre-cache core assets
// -----------------------------------------------------------------------
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_URLS);
}).then(() => self.skipWaiting())
);
});
// -----------------------------------------------------------------------
// Activate: delete old caches
// -----------------------------------------------------------------------
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
}).then(() => self.clients.claim())
);
});
// -----------------------------------------------------------------------
// Fetch: strategy based on request type
// -----------------------------------------------------------------------
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip cross-origin requests
if (url.origin !== location.origin) return;
// API calls → Network-first (never serve API from cache)
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request).catch(() => {
return new Response(
JSON.stringify({ error: 'Offline. Please check your connection.' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
})
);
return;
}
// Static assets (CSS, JS, icons) → Cache-first
if (
url.pathname.startsWith('/static/') ||
url.pathname === '/manifest.json' ||
url.pathname === '/sw.js'
) {
event.respondWith(
caches.match(request).then((cached) => {
return cached || fetch(request).then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
});
})
);
return;
}
// HTML navigation (SPA root) → Network-first with offline fallback
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => {
return caches.match(OFFLINE_URL).then((cached) => {
return cached || new Response('<h1>Offline</h1><p>Please check your connection.</p>', {
headers: { 'Content-Type': 'text/html' },
});
});
})
);
});