feat(flashcards): глобальный quick-add FAB + виджет «повтори карточку»

Backend:
- POST /api/flashcards/quick — добавить карточку из любой точки; колода по
  выбору или автоколода «Быстрые карточки» (создаётся при первом обращении)
- GET /api/flashcards/random — случайная карточка из всего пула пользователя

Frontend:
- /js/flashcard-fab.js — плавающая кнопка «запомнить» на всех страницах
  (учебник, лаборатория, симуляция…). Поповер: вопрос/ответ/колода, Ctrl+Enter.
  Гейт по фиче-флагу flashcards; исключены classroom/login/error/сама /flashcards.
  Загружается лениво из sidebar.js (на 45 страницах с шапкой).
- dashboard: виджет #w-flashcard в колонке прогресса — флип-карта (вопрос↔ответ),
  кнопка «Другая», счётчик пула, CTA при пустом пуле; слушает событие
  flashcard:added для авто-обновления.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-31 09:38:23 +03:00
parent d4ab7993c5
commit 1dcc4cbf6e
5 changed files with 388 additions and 0 deletions
+109
View File
@@ -245,6 +245,39 @@
.cont-sub { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
.cont-pct { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: var(--violet); }
/* ── flashcard review widget ── */
.fcw-card { perspective: 1000px; cursor: pointer; }
.fcw-inner {
position: relative; transform-style: preserve-3d;
transition: transform 0.5s cubic-bezier(.34,1.1,.64,1); min-height: 118px;
}
.fcw-card.flipped .fcw-inner { transform: rotateY(180deg); }
.fcw-face {
position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden;
border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
border: 1.5px solid rgba(15,23,42,0.08); box-sizing: border-box;
}
.fcw-front { background: linear-gradient(135deg, rgba(155,93,229,0.06), rgba(6,214,224,0.05)); }
.fcw-back { background: linear-gradient(135deg, rgba(6,214,100,0.07), rgba(6,214,224,0.05)); transform: rotateY(180deg); }
.fcw-deck { font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; color: var(--violet); }
.fcw-back .fcw-deck { color: #059652; }
.fcw-text { flex: 1; font-size: 0.92rem; font-weight: 600; color: var(--text); line-height: 1.35;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.fcw-hint { font-size: 0.68rem; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
.fcw-hint svg { width: 12px; height: 12px; }
.fcw-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
.fcw-count { font-size: 0.72rem; color: var(--text-3); font-weight: 600; }
.fcw-btn {
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 99px;
border: 1.5px solid rgba(155,93,229,0.3); background: rgba(155,93,229,0.06); color: var(--violet);
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700; cursor: pointer;
transition: all 0.15s; text-decoration: none;
}
.fcw-btn:hover { background: rgba(155,93,229,0.14); border-color: var(--violet); }
.fcw-btn svg { width: 13px; height: 13px; stroke: currentColor; }
.fcw-empty { text-align: center; padding: 16px 12px; color: var(--text-3); }
.fcw-empty p { font-size: 0.82rem; margin-bottom: 10px; }
/* ── subjects progress bars ── */
.subj-prog-row {
display: flex; align-items: center; gap: 14px;
@@ -1460,6 +1493,14 @@
<!-- Col 3: Progress -->
<div class="widget" id="w-progress-col">
<!-- Flashcard review (random card from pool) -->
<div id="w-flashcard" style="display:none;margin-bottom:18px">
<div class="w-head">
<div class="w-title">Повтори карточку</div>
<a class="w-more" href="/flashcards">Все карточки</a>
</div>
<div id="fcw-body"></div>
</div>
<!-- Combined Activity Widget (heatmap + streak calendar) -->
<div id="w-activity" style="display:none">
<div class="w-head">
@@ -3888,8 +3929,76 @@
loadContinueWidget();
loadWeakWidget();
loadTheoryWidget();
loadFlashcardWidget();
}
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
let _fcwTotal = 0;
async function loadFlashcardWidget() {
if (isTeacher) return;
const w = document.getElementById('w-flashcard');
if (!w || w.dataset.cfgHidden) return;
try {
const r = await LS.api('/api/flashcards/random');
renderFlashcardWidget(r);
w.style.display = '';
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
}
function renderFlashcardWidget(r) {
const body = document.getElementById('fcw-body');
if (!body) return;
if (!r || !r.card) {
_fcwTotal = 0;
body.innerHTML = `<div class="fcw-empty">
<p>Пока нет карточек. Создавай их в любой точке системы кнопкой внизу справа.</p>
<button class="fcw-btn" type="button" onclick="document.getElementById('fc-fab')?.click()">
${lci('plus','width:13px;height:13px')} Создать карточку
</button>
</div>`;
reIcons();
return;
}
_fcwTotal = r.total || 0;
const c = r.card;
const back = (c.back || '').trim() || '(ответ не заполнен)';
const col = c.deck_color || '#9B5DE5';
body.innerHTML = `
<div class="fcw-card" onclick="fcwFlip(this)">
<div class="fcw-inner">
<div class="fcw-face fcw-front">
<div class="fcw-deck" style="color:${esc(col)}">${esc(c.deck_title || 'Карточка')}</div>
<div class="fcw-text">${esc(c.front)}</div>
<div class="fcw-hint">${lci('rotate-cw','width:12px;height:12px')} нажми, чтобы перевернуть</div>
</div>
<div class="fcw-face fcw-back">
<div class="fcw-deck">Ответ</div>
<div class="fcw-text">${esc(back)}</div>
</div>
</div>
</div>
<div class="fcw-actions">
<button class="fcw-btn" type="button" onclick="fcwNext(event)">
${lci('shuffle','width:13px;height:13px')} Другая
</button>
<span class="fcw-count">${_fcwTotal} ${_fcwTotal === 1 ? 'карточка' : (_fcwTotal % 10 >= 2 && _fcwTotal % 10 <= 4 && (_fcwTotal % 100 < 12 || _fcwTotal % 100 > 14) ? 'карточки' : 'карточек')} в пуле</span>
</div>`;
reIcons();
}
function fcwFlip(el) { el.classList.toggle('flipped'); }
async function fcwNext(e) {
e.stopPropagation();
try {
const r = await LS.api('/api/flashcards/random');
renderFlashcardWidget(r);
} catch {}
}
// Обновлять виджет, когда карточку добавили через глобальный FAB
window.addEventListener('flashcard:added', () => { if (!isTeacher) loadFlashcardWidget(); });
/* ══ INIT ═════════════════════════════════════════════════════════════ */
window.addEventListener('pageshow', e => { if (e.persisted) location.reload(); });