fix & feat: resolve user reports #1 through #8 (TTS voice & speed, modals, scrollbar, CEFR hint, SRS grammar exclusion, global word lookup)
This commit is contained in:
1 parent
8cffae46fa
commit
7871191c90
12 files changed
+443
-103
No files matched your search
@@ -12,7 +12,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/feedback", tags=["Feedback & Bug Tracker"])
|
router = APIRouter(prefix="/api/feedback", tags=["Feedback & Bug Tracker"])
|
||||||
|
|
||||||
# Path to workspace memory file for seamless Antigravity AI reading
|
# Paths to markdown reports files for seamless Antigravity AI reading
|
||||||
|
DATA_REPORTS_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "USER_REPORTS.md"
|
||||||
MEMORY_REPORTS_FILE = Path("/home/ai-projects/memory/USER_REPORTS.md")
|
MEMORY_REPORTS_FILE = Path("/home/ai-projects/memory/USER_REPORTS.md")
|
||||||
|
|
||||||
|
|
||||||
@@ -97,8 +98,13 @@ def sync_reports_to_markdown(conn):
|
|||||||
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
|
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
MEMORY_REPORTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
content = "\n".join(lines)
|
||||||
MEMORY_REPORTS_FILE.write_text("\n".join(lines), encoding="utf-8")
|
for target in [DATA_REPORTS_FILE, MEMORY_REPORTS_FILE]:
|
||||||
|
try:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(content, encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not write to {target}: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not sync reports to markdown: {e}")
|
logger.warning(f"Could not sync reports to markdown: {e}")
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -24,6 +24,7 @@ async def api_srs_queue(
|
|||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
limit: int = 25,
|
limit: int = 25,
|
||||||
mix: bool = False,
|
mix: bool = False,
|
||||||
|
exclude_grammar: bool = True,
|
||||||
user: dict = Depends(require_user),
|
user: dict = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -46,6 +47,13 @@ async def api_srs_queue(
|
|||||||
col_filter += " AND tags LIKE ?"
|
col_filter += " AND tags LIKE ?"
|
||||||
col_params.append(f"%{tag.strip()}%")
|
col_params.append(f"%{tag.strip()}%")
|
||||||
|
|
||||||
|
# Exclude grammar cards from standard SRS vocabulary training unless grammar collection/tag is specifically requested
|
||||||
|
wants_grammar = (collection and ("грамматика" in collection.lower() or "grammar" in collection.lower())) or \
|
||||||
|
(tag and "grammar" in tag.lower())
|
||||||
|
if exclude_grammar and not wants_grammar:
|
||||||
|
col_filter += " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%'))"
|
||||||
|
col_filter += " AND (tags IS NULL OR tags NOT LIKE '%grammar%')"
|
||||||
|
|
||||||
if mix:
|
if mix:
|
||||||
# 1. New cards (up to 10)
|
# 1. New cards (up to 10)
|
||||||
new_rows = conn.execute(
|
new_rows = conn.execute(
|
||||||
@@ -117,6 +125,7 @@ async def api_srs_quiz(
|
|||||||
collection: Optional[str] = None,
|
collection: Optional[str] = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
count: int = 10,
|
count: int = 10,
|
||||||
|
exclude_grammar: bool = True,
|
||||||
user: dict = Depends(require_user),
|
user: dict = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -132,15 +141,22 @@ async def api_srs_quiz(
|
|||||||
col_filter += " AND tags LIKE ?"
|
col_filter += " AND tags LIKE ?"
|
||||||
col_params.append(f"%{tag.strip()}%")
|
col_params.append(f"%{tag.strip()}%")
|
||||||
|
|
||||||
|
wants_grammar = (collection and ("грамматика" in collection.lower() or "grammar" in collection.lower())) or \
|
||||||
|
(tag and "grammar" in tag.lower())
|
||||||
|
if exclude_grammar and not wants_grammar:
|
||||||
|
col_filter += " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%'))"
|
||||||
|
col_filter += " AND (tags IS NULL OR tags NOT LIKE '%grammar%')"
|
||||||
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?",
|
f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?",
|
||||||
[user["id"]] + col_params + [count],
|
[user["id"]] + col_params + [count],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
target_cards = [dict(r) for r in rows]
|
target_cards = [dict(r) for r in rows]
|
||||||
|
|
||||||
# Fetch pool of all user cards for generating smart distractors
|
# Fetch pool of user cards for generating smart distractors (exclude grammar)
|
||||||
|
distractor_filter = " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%')) AND (tags IS NULL OR tags NOT LIKE '%grammar%')" if exclude_grammar and not wants_grammar else ""
|
||||||
all_rows = conn.execute(
|
all_rows = conn.execute(
|
||||||
"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?",
|
f"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?{distractor_filter}",
|
||||||
(user["id"],),
|
(user["id"],),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
all_cards = [dict(r) for r in all_rows]
|
all_cards = [dict(r) for r in all_rows]
|
||||||
|
|||||||
@@ -347,6 +347,25 @@
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-color) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-drawer.active {
|
.app-drawer.active {
|
||||||
@@ -684,9 +703,11 @@ body.drawer-open {
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
|
max-height: min(90vh, 90dvh);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -695,6 +716,7 @@ body.drawer-open {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
@@ -707,6 +729,19 @@ body.drawer-open {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-color) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
@@ -715,12 +750,44 @@ body.drawer-open {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-sm {
|
.modal-sm {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Audio Control Group & Slow Speed Controls */
|
||||||
|
.audio-ctrl-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-slow-btn {
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px !important;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-slow-btn:hover {
|
||||||
|
background: var(--bg-surface-elevated);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Navigation Additions */
|
/* Navigation Additions */
|
||||||
.nav-center {
|
.nav-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -322,7 +322,10 @@
|
|||||||
<div class="fc-back-detail-box" id="fc-back-context-box">
|
<div class="fc-back-detail-box" id="fc-back-context-box">
|
||||||
<div class="fc-detail-header-row" style="display: flex; align-items: center; justify-content: space-between;">
|
<div class="fc-detail-header-row" style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
<span class="detail-label">Контекст:</span>
|
<span class="detail-label">Контекст:</span>
|
||||||
<button type="button" id="fc-back-context-audio-btn" class="icon-btn audio-xs-btn" title="Озвучить предложение контекста">🔊</button>
|
<span class="audio-ctrl-group">
|
||||||
|
<button type="button" id="fc-back-context-audio-btn" class="icon-btn audio-xs-btn" title="Озвучить пример (1.0x)">🔊</button>
|
||||||
|
<button type="button" id="fc-back-context-audio-slow-btn" class="icon-btn audio-xs-btn audio-slow-btn" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="fc-back-context" class="detail-text"></p>
|
<p id="fc-back-context" class="detail-text"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2017,9 +2020,12 @@
|
|||||||
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
|
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
|
||||||
🌐 Перевести предложение
|
🌐 Перевести предложение
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="popup-audio-sentence-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn" title="Озвучить всё предложение нейросетью">
|
<button type="button" id="popup-audio-sentence-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn" title="Озвучить всё предложение (1.0x)">
|
||||||
🔊 Озвучить
|
🔊 Озвучить
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" id="popup-audio-sentence-slow-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn audio-slow-btn" title="Озвучить предложение медленно (0.7x)">
|
||||||
|
🐢 0.7x
|
||||||
|
</button>
|
||||||
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
|
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
|
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
|
||||||
|
|||||||
+64
-4
@@ -7,7 +7,7 @@ import { api } from './modules/api.js';
|
|||||||
import { initTheme, openModal, closeModal, showToast, playTextSpeech, switchView } from './modules/ui.js';
|
import { initTheme, openModal, closeModal, showToast, playTextSpeech, switchView } from './modules/ui.js';
|
||||||
import { initDictionary, loadInitialData } from './modules/dictionary.js';
|
import { initDictionary, loadInitialData } from './modules/dictionary.js';
|
||||||
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
|
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
|
||||||
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
|
import { initReader, loadBookshelf, openBook, openWordPopup, extractContextSentence } from './modules/reader.js';
|
||||||
import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js';
|
import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js';
|
||||||
import { initGrammar, loadGrammarUnits, openGrammarUnit } from './modules/grammar.js';
|
import { initGrammar, loadGrammarUnits, openGrammarUnit } from './modules/grammar.js';
|
||||||
import { initLessonModule, openLessonConfigurator } from './modules/lesson.js';
|
import { initLessonModule, openLessonConfigurator } from './modules/lesson.js';
|
||||||
@@ -416,13 +416,73 @@ export function initNavigationDrawer() {
|
|||||||
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeDrawer);
|
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeDrawer);
|
||||||
if (drawerBackdrop) drawerBackdrop.addEventListener('click', closeDrawer);
|
if (drawerBackdrop) drawerBackdrop.addEventListener('click', closeDrawer);
|
||||||
|
|
||||||
// Escape Key Support
|
// Global Escape Key & Modal Dismiss Support
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && drawer && drawer.classList.contains('active')) {
|
if (e.key === 'Escape') {
|
||||||
closeDrawer();
|
// 1. If navigation drawer is open, close it
|
||||||
|
if (drawer && drawer.classList.contains('active')) {
|
||||||
|
closeDrawer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2. Close any open modal backdrop (topmost visible modal)
|
||||||
|
const openModals = Array.from(document.querySelectorAll('.modal-backdrop:not(.hidden)'));
|
||||||
|
if (openModals.length > 0) {
|
||||||
|
const topModal = openModals[openModals.length - 1];
|
||||||
|
topModal.classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dismiss modals when clicking on dark backdrop outside modal window
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target && e.target.classList && e.target.classList.contains('modal-backdrop')) {
|
||||||
|
e.target.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Global Text Selection Lookup & Add-to-Dictionary (Anywhere on Page)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function handleGlobalTextSelection(e) {
|
||||||
|
if (e && e.target && e.target.closest('#reader-word-popup, button, input, textarea, select, .modal-window, #feedback-widget, .icon-btn, .opt-btn, .tab-item, .drawer-nav-item')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed) return;
|
||||||
|
const raw = selection.toString().trim();
|
||||||
|
if (!raw || raw.length < 2 || raw.length > 50) return;
|
||||||
|
|
||||||
|
// Clean punctuation / quotes
|
||||||
|
const clean = raw.replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
|
||||||
|
// Require Latin characters (English vocabulary)
|
||||||
|
if (!clean || !/^[A-Za-z\s'-]+$/.test(clean)) return;
|
||||||
|
|
||||||
|
if (selection.rangeCount === 0) return;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 && rect.height === 0) return;
|
||||||
|
|
||||||
|
const containerElem = range.startContainer?.nodeType === 1 ? range.startContainer : range.startContainer?.parentElement;
|
||||||
|
// Avoid re-triggering if already inside reader word span (which handles its own click)
|
||||||
|
if (containerElem?.closest('.reader-word')) return;
|
||||||
|
|
||||||
|
const fullText = containerElem?.innerText || containerElem?.textContent || '';
|
||||||
|
const contextSentence = extractContextSentence(fullText, clean);
|
||||||
|
|
||||||
|
const virtualTarget = {
|
||||||
|
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||||
|
isConnected: true
|
||||||
|
};
|
||||||
|
|
||||||
|
openWordPopup(clean, contextSentence, virtualTarget, fullText);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleGlobalTextSelection);
|
||||||
|
document.addEventListener('touchend', (e) => {
|
||||||
|
setTimeout(() => handleGlobalTextSelection(e), 150);
|
||||||
|
});
|
||||||
|
|
||||||
// Touch Swipe Left to Dismiss (Mobile & Tablet)
|
// Touch Swipe Left to Dismiss (Mobile & Tablet)
|
||||||
let touchStartX = 0;
|
let touchStartX = 0;
|
||||||
let touchCurrentX = 0;
|
let touchCurrentX = 0;
|
||||||
|
|||||||
@@ -4,19 +4,22 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { showToast, escapeHtml } from './ui.js';
|
import { showToast, escapeHtml, playTextSpeech } from './ui.js';
|
||||||
|
|
||||||
let currentLevel = 'A1';
|
let currentLevel = 'A1';
|
||||||
let currentMode = 'all';
|
let currentMode = 'all';
|
||||||
let currentQuiz = [];
|
let currentQuiz = [];
|
||||||
let currentQuestionIdx = 0;
|
let currentQuestionIdx = 0;
|
||||||
let isAnswered = false;
|
let isAnswered = false;
|
||||||
|
let hintUsedThisQuestion = false;
|
||||||
|
|
||||||
const sessionStats = {
|
const sessionStats = {
|
||||||
total: 0,
|
total: 0,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
mistakes: 0,
|
mistakes: 0,
|
||||||
ingested: 0,
|
ingested: 0,
|
||||||
|
score: 0,
|
||||||
|
hintsUsed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -391,6 +394,8 @@ export async function startCefrQuiz(level = 'A1', mode = 'all') {
|
|||||||
sessionStats.correct = 0;
|
sessionStats.correct = 0;
|
||||||
sessionStats.mistakes = 0;
|
sessionStats.mistakes = 0;
|
||||||
sessionStats.ingested = 0;
|
sessionStats.ingested = 0;
|
||||||
|
sessionStats.score = 0;
|
||||||
|
sessionStats.hintsUsed = 0;
|
||||||
|
|
||||||
showCefrScreen('quiz');
|
showCefrScreen('quiz');
|
||||||
|
|
||||||
@@ -459,6 +464,8 @@ function renderCurrentQuestion() {
|
|||||||
const cardContainer = document.getElementById('cefr-cloze-card');
|
const cardContainer = document.getElementById('cefr-cloze-card');
|
||||||
if (!cardContainer) return;
|
if (!cardContainer) return;
|
||||||
|
|
||||||
|
hintUsedThisQuestion = false;
|
||||||
|
|
||||||
// Stylize the sentence blank: replace ___ with a visible blank node
|
// Stylize the sentence blank: replace ___ with a visible blank node
|
||||||
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
|
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
|
||||||
|
|
||||||
@@ -473,10 +480,15 @@ function renderCurrentQuestion() {
|
|||||||
<p class="cefr-sentence-text">${formattedSentence}</p>
|
<p class="cefr-sentence-text">${formattedSentence}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cefr-clue-box">
|
<div class="cefr-clue-box" id="cefr-clue-box">
|
||||||
<span class="clue-icon">💡</span>
|
<button type="button" class="btn btn-xs btn-outline cefr-hint-btn" id="cefr-hint-btn" title="Показать подсказку с переводом слова (-0.2 балла)">
|
||||||
<span class="clue-label">Значение:</span>
|
💡 Подсказка (-0.2 балла)
|
||||||
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
|
</button>
|
||||||
|
<div id="cefr-hint-content" class="cefr-hint-content hidden">
|
||||||
|
<span class="clue-icon">💡</span>
|
||||||
|
<span class="clue-label">Значение:</span>
|
||||||
|
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cefr-options-grid" id="cefr-options-grid">
|
<div class="cefr-options-grid" id="cefr-options-grid">
|
||||||
@@ -491,6 +503,17 @@ function renderCurrentQuestion() {
|
|||||||
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
|
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Hook up hint toggle button
|
||||||
|
const hintBtn = cardContainer.querySelector('#cefr-hint-btn');
|
||||||
|
const hintContent = cardContainer.querySelector('#cefr-hint-content');
|
||||||
|
if (hintBtn && hintContent) {
|
||||||
|
hintBtn.addEventListener('click', () => {
|
||||||
|
hintUsedThisQuestion = true;
|
||||||
|
hintBtn.classList.add('hidden');
|
||||||
|
hintContent.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Attach click listeners to option buttons
|
// Attach click listeners to option buttons
|
||||||
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
|
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -524,6 +547,10 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
|||||||
|
|
||||||
if (res.correct) {
|
if (res.correct) {
|
||||||
sessionStats.correct++;
|
sessionStats.correct++;
|
||||||
|
const pts = hintUsedThisQuestion ? 0.8 : 1.0;
|
||||||
|
sessionStats.score += pts;
|
||||||
|
if (hintUsedThisQuestion) sessionStats.hintsUsed++;
|
||||||
|
|
||||||
clickedBtn.classList.add('opt-correct');
|
clickedBtn.classList.add('opt-correct');
|
||||||
if (blankTarget) {
|
if (blankTarget) {
|
||||||
blankTarget.textContent = res.correct_option;
|
blankTarget.textContent = res.correct_option;
|
||||||
@@ -533,16 +560,28 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
|||||||
if (feedbackBox) {
|
if (feedbackBox) {
|
||||||
feedbackBox.className = 'cefr-feedback-box feedback-correct';
|
feedbackBox.className = 'cefr-feedback-box feedback-correct';
|
||||||
feedbackBox.innerHTML = `
|
feedbackBox.innerHTML = `
|
||||||
<div class="fb-title">✓ Верно! Отличный выбор.</div>
|
<div class="fb-title">✓ Верно! ${pts === 0.8 ? 'Получено: <strong>+0.8 балла</strong> (с подсказкой)' : 'Получено: <strong>+1.0 балл</strong>'}</div>
|
||||||
<div class="fb-meta">
|
<div class="fb-meta" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
||||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||||
|
<span class="audio-ctrl-group">
|
||||||
|
<button type="button" class="icon-btn audio-xs-btn play-cefr-word-audio-btn" title="Озвучить слово (1.0x)">🔊</button>
|
||||||
|
<button type="button" class="icon-btn audio-xs-btn audio-slow-btn play-cefr-word-slow-btn" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
|
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
|
||||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||||
${renderOptionsBreakdown(res.options_breakdown)}
|
${renderOptionsBreakdown(res.options_breakdown)}
|
||||||
`;
|
`;
|
||||||
feedbackBox.classList.remove('hidden');
|
feedbackBox.classList.remove('hidden');
|
||||||
|
feedbackBox.querySelector('.play-cefr-word-audio-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playTextSpeech(res.correct_option, 'en', 1.0);
|
||||||
|
});
|
||||||
|
feedbackBox.querySelector('.play-cefr-word-slow-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playTextSpeech(res.correct_option, 'en', 0.75);
|
||||||
|
});
|
||||||
attachBreakdownListeners(feedbackBox, sentence);
|
attachBreakdownListeners(feedbackBox, sentence);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -550,6 +589,12 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
|||||||
// Two words were ingested: the correct target word and the clicked distractor
|
// Two words were ingested: the correct target word and the clicked distractor
|
||||||
sessionStats.ingested += 2;
|
sessionStats.ingested += 2;
|
||||||
|
|
||||||
|
// Automatically reveal clue on mistake so user sees the meaning
|
||||||
|
const hBtn = document.getElementById('cefr-hint-btn');
|
||||||
|
const hCnt = document.getElementById('cefr-hint-content');
|
||||||
|
if (hBtn) hBtn.classList.add('hidden');
|
||||||
|
if (hCnt) hCnt.classList.remove('hidden');
|
||||||
|
|
||||||
clickedBtn.classList.add('opt-wrong');
|
clickedBtn.classList.add('opt-wrong');
|
||||||
|
|
||||||
// Highlight the true correct option
|
// Highlight the true correct option
|
||||||
@@ -623,7 +668,8 @@ function renderQuizSummary() {
|
|||||||
const correct = sessionStats.correct;
|
const correct = sessionStats.correct;
|
||||||
const mistakes = sessionStats.mistakes;
|
const mistakes = sessionStats.mistakes;
|
||||||
const ingested = sessionStats.ingested;
|
const ingested = sessionStats.ingested;
|
||||||
const pct = Math.round((correct / total) * 100);
|
const earnedScore = Math.round(sessionStats.score * 10) / 10;
|
||||||
|
const pct = Math.round((earnedScore / total) * 100);
|
||||||
|
|
||||||
const sumScore = document.getElementById('cefr-sum-score');
|
const sumScore = document.getElementById('cefr-sum-score');
|
||||||
const sumLead = document.getElementById('cefr-sum-lead');
|
const sumLead = document.getElementById('cefr-sum-lead');
|
||||||
@@ -633,8 +679,8 @@ function renderQuizSummary() {
|
|||||||
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
||||||
|
|
||||||
if (sumScore) sumScore.textContent = `${pct}%`;
|
if (sumScore) sumScore.textContent = `${pct}%`;
|
||||||
if (sumCorrect) sumCorrect.textContent = `${correct} / ${total}`;
|
if (sumCorrect) sumCorrect.textContent = `${earnedScore} / ${total} баллов (${correct} верных)`;
|
||||||
if (sumMistakes) sumMistakes.textContent = mistakes;
|
if (sumMistakes) sumMistakes.innerHTML = `${mistakes} ${sessionStats.hintsUsed > 0 ? `<span style="font-size:12px;opacity:0.8;">(подсказок: ${sessionStats.hintsUsed})</span>` : ''}`;
|
||||||
if (sumIngested) sumIngested.textContent = ingested;
|
if (sumIngested) sumIngested.textContent = ingested;
|
||||||
|
|
||||||
if (sumLead) {
|
if (sumLead) {
|
||||||
|
|||||||
@@ -167,8 +167,7 @@ export function initDictionary() {
|
|||||||
audioPlayBtn.addEventListener('click', () => {
|
audioPlayBtn.addEventListener('click', () => {
|
||||||
const term = currentLookupData?.term || currentLookupData?.english_term;
|
const term = currentLookupData?.term || currentLookupData?.english_term;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
|
playTextSpeech(term, 'en');
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +401,10 @@ export function initDictionary() {
|
|||||||
${c.context_sentence ? `
|
${c.context_sentence ? `
|
||||||
<div class="card-context">
|
<div class="card-context">
|
||||||
“${escapeHtml(c.context_sentence)}”
|
“${escapeHtml(c.context_sentence)}”
|
||||||
<button class="icon-btn audio-play-btn play-context-audio-btn" data-id="${c.id}" title="Озвучить пример">🔊</button>
|
<span class="audio-ctrl-group">
|
||||||
|
<button class="icon-btn audio-play-btn play-context-audio-btn" data-id="${c.id}" title="Озвучить пример (1.0x)">🔊</button>
|
||||||
|
<button class="icon-btn audio-slow-btn play-context-slow-btn" data-id="${c.id}" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
|
||||||
|
</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<div class="card-bottom">
|
<div class="card-bottom">
|
||||||
<div class="card-tags">
|
<div class="card-tags">
|
||||||
@@ -416,13 +418,17 @@ export function initDictionary() {
|
|||||||
// Audio buttons
|
// Audio buttons
|
||||||
cardEl.querySelector('.play-term-audio-btn')?.addEventListener('click', (e) => {
|
cardEl.querySelector('.play-term-audio-btn')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (c.audio_url) playAudio(c.audio_url);
|
playTextSpeech(c.term, 'en', 1.0);
|
||||||
else playTextSpeech(c.term, 'en');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cardEl.querySelector('.play-context-audio-btn')?.addEventListener('click', (e) => {
|
cardEl.querySelector('.play-context-audio-btn')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
playTextSpeech(c.context_sentence, 'en');
|
playTextSpeech(c.context_sentence, 'en', 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
cardEl.querySelector('.play-context-slow-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playTextSpeech(c.context_sentence, 'en', 0.75);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit button
|
// Edit button
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { escapeHtml, escapeRegex, slugify, showToast, playAudio } from './ui.js';
|
import { escapeHtml, escapeRegex, slugify, showToast, playAudio, playTextSpeech } from './ui.js';
|
||||||
import { getAllCards, fetchCards } from './dictionary.js';
|
import { getAllCards, fetchCards } from './dictionary.js';
|
||||||
|
|
||||||
// Reader State & Appearance Settings
|
// Reader State & Appearance Settings
|
||||||
@@ -2251,6 +2251,7 @@ export function initReader() {
|
|||||||
|
|
||||||
const popupAudioBtn = document.getElementById('popup-audio-btn');
|
const popupAudioBtn = document.getElementById('popup-audio-btn');
|
||||||
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
|
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
|
||||||
|
const popupAudioSentenceSlowBtn = document.getElementById('popup-audio-sentence-slow-btn');
|
||||||
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
|
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
|
||||||
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
|
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
|
||||||
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
|
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
|
||||||
@@ -2265,9 +2266,7 @@ export function initReader() {
|
|||||||
popupAudioBtn.addEventListener('click', (e) => {
|
popupAudioBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!popupCurrentData || !popupCurrentData.term) return;
|
if (!popupCurrentData || !popupCurrentData.term) return;
|
||||||
const term = popupCurrentData.term;
|
playTextSpeech(popupCurrentData.term, 'en');
|
||||||
const url = popupCurrentData.audioUrl || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
|
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2275,9 +2274,15 @@ export function initReader() {
|
|||||||
popupAudioSentenceBtn.addEventListener('click', (e) => {
|
popupAudioSentenceBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
|
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
|
||||||
const sentence = popupCurrentData.contextSentence;
|
playTextSpeech(popupCurrentData.contextSentence, 'en', 1.0);
|
||||||
const url = `/api/tts?text=${encodeURIComponent(sentence)}&lang=en`;
|
});
|
||||||
playAudio(url);
|
}
|
||||||
|
|
||||||
|
if (popupAudioSentenceSlowBtn) {
|
||||||
|
popupAudioSentenceSlowBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
|
||||||
|
playTextSpeech(popupCurrentData.contextSentence, 'en', 0.75);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2286,8 +2291,7 @@ export function initReader() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
|
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
|
||||||
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
|
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
|
||||||
const url = `/api/tts?text=${encodeURIComponent(ruText)}&lang=ru`;
|
playTextSpeech(ruText, 'ru', 1.0);
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, cleanPromptTranslation } from './ui.js';
|
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, playTextSpeech, cleanPromptTranslation } from './ui.js';
|
||||||
import { getAllCards } from './dictionary.js';
|
import { getAllCards } from './dictionary.js';
|
||||||
|
|
||||||
let srsQueue = [];
|
let srsQueue = [];
|
||||||
@@ -53,6 +53,7 @@ export function initSrs() {
|
|||||||
const fcBackContextBox = document.getElementById('fc-back-context-box');
|
const fcBackContextBox = document.getElementById('fc-back-context-box');
|
||||||
const fcBackContext = document.getElementById('fc-back-context');
|
const fcBackContext = document.getElementById('fc-back-context');
|
||||||
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
|
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
|
||||||
|
const fcBackContextAudioSlowBtn = document.getElementById('fc-back-context-audio-slow-btn');
|
||||||
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
|
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
|
||||||
const fcBackNotes = document.getElementById('fc-back-notes');
|
const fcBackNotes = document.getElementById('fc-back-notes');
|
||||||
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
|
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
|
||||||
@@ -352,6 +353,9 @@ export function initSrs() {
|
|||||||
if (fcBackContextAudioBtn) {
|
if (fcBackContextAudioBtn) {
|
||||||
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
|
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
|
||||||
}
|
}
|
||||||
|
if (fcBackContextAudioSlowBtn) {
|
||||||
|
fcBackContextAudioSlowBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fcFrontAudioBtn) {
|
if (fcFrontAudioBtn) {
|
||||||
@@ -359,8 +363,7 @@ export function initSrs() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = srsQueue[srsCurrentIndex];
|
const card = srsQueue[srsCurrentIndex];
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
playTextSpeech(card.term, 'en', 1.0);
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,8 +372,7 @@ export function initSrs() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = srsQueue[srsCurrentIndex];
|
const card = srsQueue[srsCurrentIndex];
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
playTextSpeech(card.term, 'en', 1.0);
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,8 +381,16 @@ export function initSrs() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = srsQueue[srsCurrentIndex];
|
const card = srsQueue[srsCurrentIndex];
|
||||||
if (!card || !card.context_sentence) return;
|
if (!card || !card.context_sentence) return;
|
||||||
const url = card.context_audio_url || `/api/tts?text=${encodeURIComponent(card.context_sentence)}&lang=en`;
|
playTextSpeech(card.context_sentence, 'en', 1.0);
|
||||||
playAudio(url);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fcBackContextAudioSlowBtn) {
|
||||||
|
fcBackContextAudioSlowBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const card = srsQueue[srsCurrentIndex];
|
||||||
|
if (!card || !card.context_sentence) return;
|
||||||
|
playTextSpeech(card.context_sentence, 'en', 0.75);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +501,7 @@ export function initSrs() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (card.audio_url) playAudio(card.audio_url);
|
playTextSpeech(card.term, 'en');
|
||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
quizFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
quizFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
||||||
@@ -523,8 +533,7 @@ export function initSrs() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = srsQueue[srsCurrentIndex];
|
const card = srsQueue[srsCurrentIndex];
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
playTextSpeech(card.term, 'en', 1.0);
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +574,7 @@ export function initSrs() {
|
|||||||
const target = card.term.trim().toLowerCase();
|
const target = card.term.trim().toLowerCase();
|
||||||
const isMatch = userInput === target;
|
const isMatch = userInput === target;
|
||||||
|
|
||||||
if (card.audio_url) playAudio(card.audio_url);
|
playTextSpeech(card.term, 'en');
|
||||||
typingInput.disabled = true;
|
typingInput.disabled = true;
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
@@ -611,7 +620,7 @@ export function initSrs() {
|
|||||||
const target = card.term.trim().toLowerCase();
|
const target = card.term.trim().toLowerCase();
|
||||||
const isMatch = userInput === target;
|
const isMatch = userInput === target;
|
||||||
|
|
||||||
if (card.audio_url) playAudio(card.audio_url);
|
playTextSpeech(card.term, 'en');
|
||||||
clozeInput.disabled = true;
|
clozeInput.disabled = true;
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
@@ -640,7 +649,7 @@ export function initSrs() {
|
|||||||
dictationInput.value = '';
|
dictationInput.value = '';
|
||||||
dictationInput.disabled = false;
|
dictationInput.disabled = false;
|
||||||
|
|
||||||
const play = () => { if (card.audio_url) playAudio(card.audio_url); };
|
const play = () => { playTextSpeech(card.term, 'en'); };
|
||||||
dictationBigPlayBtn.onclick = play;
|
dictationBigPlayBtn.onclick = play;
|
||||||
dictationReplayBtn.onclick = play;
|
dictationReplayBtn.onclick = play;
|
||||||
play();
|
play();
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)
|
||||||
|
|
||||||
|
> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.
|
||||||
|
> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.
|
||||||
|
|
||||||
|
**Всего репортов:** 8 | **Последняя синхронизация:** сейчас
|
||||||
|
|
||||||
|
## 🔴 Активные репорты (Требуют внимания / В работе)
|
||||||
|
|
||||||
|
🎉 *Активных багов или задач нет! Все известные репорты закрыты.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Решенные / Закрытые задачи
|
||||||
|
|
||||||
|
- [x] **#8 💡 (2026-10-08 09:28:40)**: сделать кнопки при озвучивании предложения или текста 0.7-1 скорость. Предложения иногда неудобно слушать, так как диктор может сказать быстро, хочется замедлить *(Страница: #dictionary)*
|
||||||
|
- [x] **#7 🐛 (2026-10-08 09:22:52)**: Проверить, верно ли применяется голос озвучки, после смены его в меню настройки. Такое чувство, что не везде применяется озвучка *(Страница: #lesson)*
|
||||||
|
- [x] **#6 💡 (2026-10-08 09:21:19)**: в SRS тестах чтобы не было упражнений по грамматике, там должны быть карточки исключительно касающиеся слов. *(Страница: #dictionary)*
|
||||||
|
- [x] **#5 💡 (2026-10-08 09:19:07)**: В тестах не показывать подсказку (русский перевод) но сделать кнопку "подсказка", тогда перевод показывается нужного слова, но за это снимается бал. Например за вопрос дается 1 бал, так я получу с подсказкой 0.8 или около того *(Страница: #cefr/A1)*
|
||||||
|
- [x] **#4 💡 (2026-10-08 09:14:12)**: Чтобы можно было нажать на любой текст, будь то в тесте или еще где, (кроме элементов управление) и добавить в словарь. А то иногда встречаются места, где читаешь предложение и не понимаешь, что за слово там ... *(Страница: #cefr/A1)*
|
||||||
|
- [x] **#3 💡 (2026-10-08 09:04:56)**: В меню боковом измени скролбар на нестандартный. В текущей цветовой палитре и оформлении, стандартный скролбар сильно выделяется *(Страница: #dictionary)*
|
||||||
|
- [x] **#2 🐛 (2026-10-08 09:01:46)**: Вкладка "мой словарь", карандишик (изменить слово), окно открывается слишком большое, без масштабирования вкладки его невозможно закрыть. Еще бы добавить функцию закрытия окна по пустому месту или по кнопке Esc *(Страница: #dictionary)*
|
||||||
|
- [x] **#1 🐛 (2026-10-08 09:00:18)**: Вкладка "мой словарь", звук слов не воспроизводится, но предложения озвучиваются без проблем. *(Страница: #dictionary)*
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path("/home/ai-projects/services/study.qpid.pro/data/notes.db")
|
||||||
|
TARGET_MD = Path("/home/ai-projects/services/study.qpid.pro/data/USER_REPORTS.md")
|
||||||
|
MEMORY_MD = Path("/home/ai-projects/memory/USER_REPORTS.md")
|
||||||
|
|
||||||
|
def sync():
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print("Database not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT f.id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
|
||||||
|
FROM feedback_reports f
|
||||||
|
JOIN users u ON f.user_id = u.id
|
||||||
|
ORDER BY f.id DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)",
|
||||||
|
"",
|
||||||
|
"> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.",
|
||||||
|
"> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.",
|
||||||
|
"",
|
||||||
|
f"**Всего репортов:** {len(rows)} | **Последняя синхронизация:** сейчас",
|
||||||
|
"",
|
||||||
|
"## 🔴 Активные репорты (Требуют внимания / В работе)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
active_rows = [r for r in rows if r["status"] in ("new", "in_progress")]
|
||||||
|
resolved_rows = [r for r in rows if r["status"] in ("resolved", "closed")]
|
||||||
|
|
||||||
|
if not active_rows:
|
||||||
|
lines.append("🎉 *Активных багов или задач нет! Все известные репорты закрыты.*")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
for r in active_rows:
|
||||||
|
cat_emoji = "🐛 БАГ" if r["category"] == "bug" else ("💡 ФИЧА" if r["category"] == "feature" else "📝 ЗАМЕТКА")
|
||||||
|
status_icon = "🆕 Новый" if r["status"] == "new" else "⏳ В работе"
|
||||||
|
|
||||||
|
ctx_extra = ""
|
||||||
|
if r["context_data_json"]:
|
||||||
|
try:
|
||||||
|
c_dict = json.loads(r["context_data_json"])
|
||||||
|
parts = []
|
||||||
|
if "screen" in c_dict:
|
||||||
|
parts.append(f"Экран: {c_dict['screen']}")
|
||||||
|
if "device" in c_dict:
|
||||||
|
parts.append(f"Устройство: {c_dict['device']}")
|
||||||
|
if parts:
|
||||||
|
ctx_extra = f" ({', '.join(parts)})"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines.append(f"### #{r['id']} [{cat_emoji}] — {status_icon} (`{r['created_at']}`)")
|
||||||
|
lines.append(f"- **Пользователь**: `{r['username']}`")
|
||||||
|
if r["context_url"]:
|
||||||
|
lines.append(f"- **Раздел/Страница**: `{r['context_url']}`{ctx_extra}")
|
||||||
|
lines.append(f"- **Текст**:\n> {r['message'].strip()}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## ✅ Решенные / Закрытые задачи",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
if not resolved_rows:
|
||||||
|
lines.append("*Пока нет закрытых репортов.*")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
for r in resolved_rows:
|
||||||
|
cat_emoji = "🐛" if r["category"] == "bug" else ("💡" if r["category"] == "feature" else "📝")
|
||||||
|
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
content = "\n".join(lines)
|
||||||
|
TARGET_MD.write_text(content, encoding="utf-8")
|
||||||
|
MEMORY_MD.write_text(content, encoding="utf-8")
|
||||||
|
print(f"Synced {len(rows)} reports successfully!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sync()
|
||||||
+61
-54
@@ -2572,67 +2572,74 @@ def test_feedback_endpoints_workflow():
|
|||||||
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
assert login_resp.status_code == 200
|
assert login_resp.status_code == 200
|
||||||
|
|
||||||
# 3. Create a bug report
|
try:
|
||||||
bug_payload = {
|
# 3. Create a bug report
|
||||||
"category": "bug",
|
bug_payload = {
|
||||||
"message": "Кнопка в читалке не нажимается на мобилке",
|
"category": "bug",
|
||||||
"context_url": "#reader/book/1/ch/2",
|
"message": "Кнопка в читалке не нажимается на мобилке",
|
||||||
"context_data": {"screen": "390x844", "device": "Mobile"}
|
"context_url": "#reader/book/1/ch/2",
|
||||||
}
|
"context_data": {"screen": "390x844", "device": "Mobile"}
|
||||||
create_resp = client.post("/api/feedback", json=bug_payload)
|
}
|
||||||
assert create_resp.status_code == 201
|
create_resp = client.post("/api/feedback", json=bug_payload)
|
||||||
c_data = create_resp.json()
|
assert create_resp.status_code == 201
|
||||||
assert c_data["ok"] is True
|
c_data = create_resp.json()
|
||||||
report_id = c_data["id"]
|
assert c_data["ok"] is True
|
||||||
assert c_data["category"] == "bug"
|
report_id = c_data["id"]
|
||||||
assert c_data["status"] == "new"
|
assert c_data["category"] == "bug"
|
||||||
|
assert c_data["status"] == "new"
|
||||||
|
|
||||||
# 4. Create a feature request
|
# 4. Create a feature request
|
||||||
feat_payload = {
|
feat_payload = {
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"message": "Добавить темную тему OLED",
|
"message": "Добавить темную тему OLED",
|
||||||
"context_url": "#reader",
|
"context_url": "#reader",
|
||||||
"context_data": {"screen": "1920x1080", "device": "Desktop"}
|
"context_data": {"screen": "1920x1080", "device": "Desktop"}
|
||||||
}
|
}
|
||||||
create_resp2 = client.post("/api/feedback", json=feat_payload)
|
create_resp2 = client.post("/api/feedback", json=feat_payload)
|
||||||
assert create_resp2.status_code == 201
|
assert create_resp2.status_code == 201
|
||||||
report_id2 = create_resp2.json()["id"]
|
report_id2 = create_resp2.json()["id"]
|
||||||
|
|
||||||
# 5. List reports
|
# 5. List reports
|
||||||
list_resp = client.get("/api/feedback")
|
list_resp = client.get("/api/feedback")
|
||||||
assert list_resp.status_code == 200
|
assert list_resp.status_code == 200
|
||||||
reports = list_resp.json()
|
reports = list_resp.json()
|
||||||
assert len(reports) >= 2
|
assert len(reports) >= 2
|
||||||
r_ids = [r["id"] for r in reports]
|
r_ids = [r["id"] for r in reports]
|
||||||
assert report_id in r_ids
|
assert report_id in r_ids
|
||||||
assert report_id2 in r_ids
|
assert report_id2 in r_ids
|
||||||
|
|
||||||
# 6. Filter by category
|
# 6. Filter by category
|
||||||
bugs_resp = client.get("/api/feedback?category=bug")
|
bugs_resp = client.get("/api/feedback?category=bug")
|
||||||
assert bugs_resp.status_code == 200
|
assert bugs_resp.status_code == 200
|
||||||
assert all(r["category"] == "bug" for r in bugs_resp.json())
|
assert all(r["category"] == "bug" for r in bugs_resp.json())
|
||||||
|
|
||||||
# 7. Update status to resolved
|
# 7. Update status to resolved
|
||||||
patch_resp = client.patch(f"/api/feedback/{report_id}", json={"status": "resolved"})
|
patch_resp = client.patch(f"/api/feedback/{report_id}", json={"status": "resolved"})
|
||||||
assert patch_resp.status_code == 200
|
assert patch_resp.status_code == 200
|
||||||
assert patch_resp.json()["status"] == "resolved"
|
assert patch_resp.json()["status"] == "resolved"
|
||||||
|
|
||||||
# Verify updated
|
# Verify updated
|
||||||
get_updated = client.get("/api/feedback?status=resolved")
|
get_updated = client.get("/api/feedback?status=resolved")
|
||||||
assert get_updated.status_code == 200
|
assert get_updated.status_code == 200
|
||||||
assert any(r["id"] == report_id and r["status"] == "resolved" for r in get_updated.json())
|
assert any(r["id"] == report_id and r["status"] == "resolved" for r in get_updated.json())
|
||||||
|
|
||||||
# 8. Delete report
|
# 8. Delete report
|
||||||
del_resp = client.delete(f"/api/feedback/{report_id2}")
|
del_resp = client.delete(f"/api/feedback/{report_id2}")
|
||||||
assert del_resp.status_code == 200
|
assert del_resp.status_code == 200
|
||||||
assert del_resp.json()["ok"] is True
|
assert del_resp.json()["ok"] is True
|
||||||
|
|
||||||
# 9. Verify markdown sync file exists and has content
|
# 9. Verify markdown sync file exists and has content
|
||||||
reports_md = Path("/home/ai-projects/memory/USER_REPORTS.md")
|
reports_md = Path("/home/ai-projects/memory/USER_REPORTS.md")
|
||||||
assert reports_md.exists()
|
assert reports_md.exists()
|
||||||
content = reports_md.read_text(encoding="utf-8")
|
content = reports_md.read_text(encoding="utf-8")
|
||||||
assert "Пользовательские репорты" in content
|
assert "Пользовательские репорты" in content
|
||||||
assert "Кнопка в читалке не нажимается" in content
|
assert "Кнопка в читалке не нажимается" in content
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
from scripts.sync_reports import sync
|
||||||
|
sync()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user