feat: prevent duplicate cards, merge contexts, and display star badge when word is in library

This commit is contained in:
2026-10-05 15:15:06 +03:00
parent f52820fff6
commit a73bdc29fd
7 changed files with 355 additions and 24 deletions
+36 -2
View File
@@ -3040,8 +3040,42 @@ body {
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--success);
padding: 6px;
color: #f59e0b;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: var(--radius-sm);
padding: 6px 10px;
}
.popup-in-library-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.4);
padding: 2px 7px;
border-radius: 9999px;
margin-left: 6px;
letter-spacing: 0.02em;
vertical-align: middle;
}
.badge-library-star {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.4);
padding: 2px 8px;
border-radius: 9999px;
margin-left: 8px;
vertical-align: middle;
}
+6 -3
View File
@@ -5,7 +5,7 @@
<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?v=2.7">
<link rel="stylesheet" href="/static/css/style.css?v=2.8">
<!-- PWA meta and icons -->
<link rel="manifest" href="/manifest.json">
@@ -125,6 +125,7 @@
<div class="lookup-term-group">
<h2 id="res-term" class="res-term">Word</h2>
<span id="res-phonetic" class="res-phonetic">/wɜːd/</span>
<span id="res-in-library" class="badge-library-star hidden" title="Это слово уже есть в вашей библиотеке">⭐ В библиотеке</span>
<button id="audio-play-btn" class="icon-btn audio-btn hidden" style="display: none;" title="Прослушать произношение">🔊</button>
</div>
<span id="res-pos" class="badge-pos">Noun</span>
@@ -905,6 +906,7 @@
<div class="popup-term-wrap">
<strong id="popup-term" class="popup-term">word</strong>
<span id="popup-phonetic" class="popup-phonetic">/wɜːd/</span>
<span id="popup-in-library" class="popup-in-library-badge hidden" title="Это слово уже есть в вашей библиотеке">⭐ В библиотеке</span>
<button id="popup-audio-btn" class="icon-btn audio-btn" style="display: none;" title="Слушать произношение">🔊</button>
</div>
<button id="popup-close-btn" class="popup-close-btn">✕</button>
@@ -918,13 +920,14 @@
</div>
<div class="popup-footer">
<button id="popup-save-btn" class="btn btn-primary btn-sm btn-block">💾 Добавить в словарь</button>
<div id="popup-saved-badge" class="popup-saved-badge hidden">✅ Сохранено в словарь!</div>
<div id="popup-saved-badge" class="popup-saved-badge hidden">⭐ Слово уже в библиотеке</div>
<button id="popup-add-context-btn" class="btn btn-xs btn-outline btn-block hidden" style="margin-top: 6px;">+ Добавить эту цитату к карточке</button>
</div>
</div>
<!-- Audio Element for native streaming -->
<audio id="global-audio-player" preload="none"></audio>
<script src="/static/js/app.js?v=2.7"></script>
<script src="/static/js/app.js?v=2.8"></script>
</body>
</html>
+124 -7
View File
@@ -88,6 +88,7 @@ document.addEventListener('DOMContentLoaded', () => {
const readerWordPopup = document.getElementById('reader-word-popup');
const popupTerm = document.getElementById('popup-term');
const popupPhonetic = document.getElementById('popup-phonetic');
const popupInLibrary = document.getElementById('popup-in-library');
const popupAudioBtn = document.getElementById('popup-audio-btn');
const popupCloseBtn = document.getElementById('popup-close-btn');
const popupLoading = document.getElementById('popup-loading');
@@ -96,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => {
const popupContext = document.getElementById('popup-context');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
// Reader State
let currentBook = null;
@@ -116,6 +118,7 @@ document.addEventListener('DOMContentLoaded', () => {
const lookupCard = document.getElementById('lookup-card');
const resTerm = document.getElementById('res-term');
const resPhonetic = document.getElementById('res-phonetic');
const resInLibrary = document.getElementById('res-in-library');
const audioPlayBtn = document.getElementById('audio-play-btn');
const resPos = document.getElementById('res-pos');
const resDefinitionWrap = document.getElementById('res-definition-wrap');
@@ -580,15 +583,36 @@ document.addEventListener('DOMContentLoaded', () => {
resDefinitionWrap.classList.add('hidden');
}
editTranslation.value = data.translation || '';
editContext.value = '';
editNotes.value = '';
editCollection.value = selectedCollection || 'General';
const inLib = Boolean(data.in_library || foundDuplicateCard);
if (resInLibrary) {
resInLibrary.classList.toggle('hidden', !inLib);
}
if (inLib) {
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
const lib = data.library_card || foundDuplicateCard;
if (lib) {
if (lib.translation) editTranslation.value = lib.translation;
if (lib.context_sentence) editContext.value = lib.context_sentence;
if (lib.collection) editCollection.value = lib.collection;
if (lib.tags) editTags.value = lib.tags;
if (lib.notes) editNotes.value = lib.notes;
} else {
editTranslation.value = data.translation || '';
}
} else {
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
editTranslation.value = data.translation || '';
editContext.value = '';
editNotes.value = '';
editCollection.value = selectedCollection || 'General';
}
lookupCard.classList.remove('hidden');
editTranslation.focus();
}
// Save Card Action
saveCardBtn.addEventListener('click', saveNewCard);
document.addEventListener('keydown', (e) => {
@@ -2375,10 +2399,14 @@ document.addEventListener('DOMContentLoaded', () => {
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
if (!cleanTerm) return;
const lowerClean = cleanTerm.toLowerCase();
const existingCard = allCards && allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean);
popupCurrentData = {
term: cleanTerm,
contextSentence: contextSentence,
targetElement: targetElement,
existingCard: existingCard,
};
popupTerm.textContent = cleanTerm;
@@ -2387,9 +2415,27 @@ document.addEventListener('DOMContentLoaded', () => {
popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
popupLoading.classList.remove('hidden');
popupContent.classList.add('hidden');
popupSavedBadge.classList.add('hidden');
popupSaveBtn.classList.remove('hidden');
popupSaveBtn.disabled = false;
// Immediate check against already-loaded user cards
if (popupInLibrary) {
popupInLibrary.classList.toggle('hidden', !existingCard);
}
if (existingCard) {
popupSaveBtn.classList.add('hidden');
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
if (popupAddContextBtn && contextSentence && (!existingCard.context_sentence || !existingCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else if (popupAddContextBtn) {
popupAddContextBtn.classList.add('hidden');
}
} else {
popupSavedBadge.classList.add('hidden');
popupSaveBtn.classList.remove('hidden');
popupSaveBtn.disabled = false;
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
// Position popup near target element
readerWordPopup.classList.remove('hidden');
@@ -2407,6 +2453,27 @@ document.addEventListener('DOMContentLoaded', () => {
popupLoading.classList.add('hidden');
popupContent.classList.remove('hidden');
popupAudioBtn.classList.add('hidden');
const inLib = Boolean(data.in_library || existingCard);
const libCard = data.library_card || existingCard;
if (inLib) {
popupCurrentData.existingCard = libCard;
if (popupInLibrary) popupInLibrary.classList.remove('hidden');
popupSaveBtn.classList.add('hidden');
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
if (popupAddContextBtn && contextSentence && libCard && (!libCard.context_sentence || !libCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else if (popupAddContextBtn) {
popupAddContextBtn.classList.add('hidden');
}
} else {
if (popupInLibrary) popupInLibrary.classList.add('hidden');
popupSaveBtn.classList.remove('hidden');
popupSavedBadge.classList.add('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
positionWordPopup(targetElement);
} else {
popupTranslation.textContent = 'Ошибка перевода';
@@ -2491,8 +2558,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
if (resp.ok) {
const resData = await resp.json();
popupSaveBtn.classList.add('hidden');
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = resData.already_exists ? '⭐ Уже есть в библиотеке' : '✅ Сохранено в словарь!';
if (popupInLibrary) popupInLibrary.classList.remove('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
if (d.targetElement) {
d.targetElement.classList.add('status-learning');
@@ -2510,6 +2581,52 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (popupAddContextBtn) {
popupAddContextBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const payload = {
term: d.term,
translation: lookup.translation || lookup.translation_ru || popupTranslation.textContent,
context_sentence: d.contextSentence || '',
phonetic: lookup.phonetic || '',
audio_url: lookup.audio_url || '',
part_of_speech: lookup.part_of_speech || '',
definition_en: lookup.definition_en || '',
collection: 'Книги & Сериалы',
tags: `${bookTag}, reader`,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};
popupAddContextBtn.disabled = true;
popupAddContextBtn.textContent = 'Сохраняем...';
try {
const resp = await fetch('/api/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
popupAddContextBtn.classList.add('hidden');
popupSavedBadge.textContent = '✅ Цитата прикреплена к карточке!';
popupSavedBadge.classList.remove('hidden');
await fetchCards();
} else {
alert('Не удалось обновить контекст');
}
} catch (e) {
console.error('Add context error:', e);
} finally {
popupAddContextBtn.disabled = false;
popupAddContextBtn.textContent = '+ Добавить эту цитату к карточке';
}
});
}
// Phrase/Chunk Selection in Reader
if (readerContentBody) {
readerContentBody.addEventListener('mouseup', () => {