feat(reader): multi-word and phrasal verb detector in reader (Task 14)

This commit is contained in:
2026-10-06 15:16:02 +03:00
parent 37a1f1ab35
commit 1c3f03a64c
9 changed files with 738 additions and 16 deletions
+62 -1
View File
@@ -3847,10 +3847,71 @@ body {
padding: 4px;
}
.popup-close-btn:hover {
/* Phrasal Verb & Collocation Card in Word Popup */
.popup-phrasal-card {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(168, 85, 247, 0.08));
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: var(--radius-sm);
padding: 8px 10px;
margin-bottom: 10px;
animation: fadeIn 0.15s ease-out;
}
.phrasal-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.phrasal-tag {
font-size: 10px;
font-weight: 700;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.phrasal-term-row {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 2px;
}
.phrasal-term {
font-size: 14px;
color: var(--text-main);
}
.phrasal-raw {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
}
.phrasal-translation {
font-size: 12px;
font-weight: 600;
color: var(--success);
margin-bottom: 6px;
line-height: 1.35;
}
.phrasal-card-actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
.phrasal-action-btn {
font-size: 11px;
padding: 3px 8px;
width: 100%;
justify-content: center;
}
.popup-translation {
font-size: 15px;
font-weight: 600;
+20
View File
@@ -1061,6 +1061,26 @@
<div class="popup-body">
<div id="popup-loading" class="popup-loading hidden">Переводим...</div>
<div id="popup-content" class="popup-content">
<!-- Phrasal Verb & Idiom Detector Card -->
<div id="popup-phrasal-card" class="popup-phrasal-card hidden">
<div class="phrasal-card-header">
<span class="phrasal-tag">🔗 Фразовый глагол</span>
<span id="popup-phrasal-library-badge" class="popup-in-library-badge hidden">⭐ В библиотеке</span>
</div>
<div class="phrasal-card-body">
<div class="phrasal-term-row">
<strong id="popup-phrasal-term" class="phrasal-term">take off</strong>
<span id="popup-phrasal-raw" class="phrasal-raw">(в тексте: «took off»)</span>
</div>
<div id="popup-phrasal-translation" class="phrasal-translation">взлетать, снимать</div>
</div>
<div class="phrasal-card-actions">
<button type="button" id="popup-switch-phrasal-btn" class="btn btn-xs btn-primary phrasal-action-btn">
🔗 Переключить на фразовый глагол
</button>
</div>
</div>
<div class="popup-translation" id="popup-translation">перевод</div>
<div class="popup-context-box">
<div class="popup-context" id="popup-context">“Контекст из книги...”</div>
+10 -2
View File
@@ -96,13 +96,21 @@ export const api = {
// Dictionary Lookup & AI Sentence Translation
lookup: {
async search(term) {
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`);
async search(term, context = '') {
let url = `/api/lookup?term=${encodeURIComponent(term)}`;
if (context) {
url += `&context=${encodeURIComponent(context)}`;
}
const resp = await fetch(url);
if (resp.ok) {
return await resp.json();
}
throw new Error(`Lookup failed: ${resp.status}`);
},
async detectPhrasalVerb(sentence, word) {
const resp = await fetch(`/api/detect-phrasal-verb?sentence=${encodeURIComponent(sentence)}&word=${encodeURIComponent(word)}`);
return resp.ok ? await resp.json() : { detected: false, phrasal_verb: null };
},
async translateSentence(sentence, targetWord = '') {
const resp = await fetch('/api/translate-sentence', {
method: 'POST',
+95 -8
View File
@@ -1160,6 +1160,17 @@ export async function openWordPopup(term, contextSentence, targetElement) {
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupSentenceAiBar = document.querySelector('.popup-sentence-ai-bar');
const popupPhrasalCard = document.getElementById('popup-phrasal-card');
const popupPhrasalTerm = document.getElementById('popup-phrasal-term');
const popupPhrasalRaw = document.getElementById('popup-phrasal-raw');
const popupPhrasalTranslation = document.getElementById('popup-phrasal-translation');
const popupPhrasalLibraryBadge = document.getElementById('popup-phrasal-library-badge');
const popupSwitchPhrasalBtn = document.getElementById('popup-switch-phrasal-btn');
if (popupPhrasalCard) popupPhrasalCard.classList.add('hidden');
popupCurrentData.phrasalVerb = null;
popupCurrentData.isPhrasalMode = false;
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = '';
@@ -1202,9 +1213,27 @@ export async function openWordPopup(term, contextSentence, targetElement) {
positionWordPopup(targetElement);
try {
const data = await api.lookup.search(cleanTerm);
const data = await api.lookup.search(cleanTerm, contextSentence);
popupCurrentData.lookup = data;
// Phrasal verb detection
if (data.phrasal_verb) {
const pv = data.phrasal_verb;
popupCurrentData.phrasalVerb = pv;
if (popupPhrasalCard) {
popupPhrasalCard.classList.remove('hidden');
if (popupPhrasalTerm) popupPhrasalTerm.textContent = pv.term;
if (popupPhrasalRaw) popupPhrasalRaw.textContent = `(в тексте: «${pv.raw}»)`;
if (popupPhrasalTranslation) popupPhrasalTranslation.textContent = pv.translation;
if (popupPhrasalLibraryBadge) popupPhrasalLibraryBadge.classList.toggle('hidden', !pv.in_library);
if (popupSwitchPhrasalBtn) {
popupSwitchPhrasalBtn.textContent = `🔗 Переключить на «${pv.term}»`;
}
}
} else {
if (popupPhrasalCard) popupPhrasalCard.classList.add('hidden');
}
const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : '');
if (popupPhonetic) popupPhonetic.textContent = data.phonetic || '';
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
@@ -1924,23 +1953,81 @@ export function initReader() {
});
}
const popupSwitchPhrasalBtn = document.getElementById('popup-switch-phrasal-btn');
if (popupSwitchPhrasalBtn) {
popupSwitchPhrasalBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.phrasalVerb) return;
const pv = popupCurrentData.phrasalVerb;
if (!popupCurrentData.isPhrasalMode) {
// Switch to phrasal verb mode
popupCurrentData.isPhrasalMode = true;
if (popupTerm) popupTerm.textContent = pv.term;
if (popupTranslation) popupTranslation.textContent = pv.translation;
if (popupPhonetic) popupPhonetic.textContent = '';
const inLib = Boolean(pv.in_library);
if (popupInLibrary) popupInLibrary.classList.toggle('hidden', !inLib);
if (popupSaveBtn) {
popupSaveBtn.classList.toggle('hidden', inLib);
popupSaveBtn.textContent = `💾 Сохранить «${pv.term}» в словарь`;
}
if (popupSavedBadge) {
popupSavedBadge.classList.toggle('hidden', !inLib);
popupSavedBadge.textContent = '⭐ Фразовый глагол уже в библиотеке';
}
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
popupSwitchPhrasalBtn.textContent = `↩️ Вернуться к «${popupCurrentData.term}»`;
} else {
// Revert back to single word
popupCurrentData.isPhrasalMode = false;
const origTrans = popupCurrentData.lookup ? (popupCurrentData.lookup.translation || popupCurrentData.lookup.translation_ru || '') : '';
if (popupTerm) popupTerm.textContent = popupCurrentData.term;
if (popupTranslation) popupTranslation.textContent = origTrans;
if (popupPhonetic) popupPhonetic.textContent = popupCurrentData.lookup?.phonetic || '';
const inLib = Boolean(popupCurrentData.lookup?.in_library || popupCurrentData.existingCard);
if (popupInLibrary) popupInLibrary.classList.toggle('hidden', !inLib);
if (popupSaveBtn) {
popupSaveBtn.classList.toggle('hidden', inLib);
popupSaveBtn.textContent = '💾 Добавить в словарь';
}
if (popupSavedBadge) {
popupSavedBadge.classList.toggle('hidden', !inLib);
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
popupSwitchPhrasalBtn.textContent = `🔗 Переключить на «${pv.term}»`;
}
positionWordPopup(popupCurrentData.targetElement);
});
}
if (popupSaveBtn) {
popupSaveBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const isPv = Boolean(d.isPhrasalMode && d.phrasalVerb);
const termToSave = isPv ? d.phrasalVerb.term : d.term;
const transToSave = isPv
? d.phrasalVerb.translation
: (lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''));
const collectionToSave = isPv ? 'Фразовые глаголы & Идиомы' : 'Книги & Сериалы';
const tagsToSave = isPv ? `${bookTag}, phrasal-verbs, reader` : `${bookTag}, reader`;
const payload = {
term: d.term,
translation: lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''),
term: termToSave,
translation: transToSave,
context_sentence: d.contextSentence || '',
phonetic: lookup.phonetic || '',
audio_url: lookup.audio_url || '',
part_of_speech: lookup.part_of_speech || '',
phonetic: isPv ? '' : (lookup.phonetic || ''),
audio_url: isPv ? '' : (lookup.audio_url || ''),
part_of_speech: isPv ? 'verb phrase' : (lookup.part_of_speech || ''),
definition_en: lookup.definition_en || '',
collection: 'Книги & Сериалы',
tags: `${bookTag}, reader`,
collection: collectionToSave,
tags: tagsToSave,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};