feat(flashcards): фаза 1 полировки — хоткеи, поиск, drag-reorder, честные интервалы

- study: хоткеи Space/стрелки=флип, 1-4/←→=оценка
- превью интервалов = точная копия серверного SM-2 (было враньё «<1 мин»)
- поиск/фильтр карточек внутри колоды
- drag-reorder карточек + endpoint PUT /decks/:id/reorder (requireOwnership)
- flashcard_decks добавлен в ALLOWED_TABLES requireOwnership
- эмодзи в empty-state → inline SVG .ic
- deleteCard: нативный confirm() → LS.confirm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-31 09:53:03 +03:00
parent 1dcc4cbf6e
commit 29301ff87d
4 changed files with 209 additions and 28 deletions
+25 -1
View File
@@ -126,6 +126,30 @@ function addCardsBulk(req, res) {
res.json({ inserted });
}
/* ── PUT /api/flashcards/decks/:id/reorder ─────────────────────────────────
body: { order: [cardId, …] } — переписывает order_idx по позиции в массиве.
Принимаются только карточки, реально принадлежащие колоде владельца. */
function reorderCards(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
const { order } = req.body;
if (!Array.isArray(order) || !order.length)
return res.status(400).json({ error: 'order[] required' });
const owned = new Set(
db.prepare(`SELECT id FROM flashcard_cards WHERE deck_id = ?`).all(deck.id).map(r => r.id)
);
const stmt = db.prepare(`UPDATE flashcard_cards SET order_idx = ? WHERE id = ? AND deck_id = ?`);
const run = db.transaction(() => {
let idx = 0;
order.forEach(id => { if (owned.has(Number(id))) stmt.run(idx++, Number(id), deck.id); });
});
run();
res.json({ ok: true });
}
/* ── PUT /api/flashcards/cards/:id ─────────────────────────────────────── */
function updateCard(req, res) {
const uid = req.user.id;
@@ -298,7 +322,7 @@ function getRandom(req, res) {
module.exports = {
listDecks, createDeck, updateDeck, deleteDeck,
getCards, addCard, addCardsBulk, updateCard, deleteCard,
getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards,
getStudySession, submitReview, getStats,
quickAdd, getRandom,
};
+1 -1
View File
@@ -24,7 +24,7 @@ const db = require('../db/db');
* paramKey — req.params key for the record ID (default: 'id')
* adminBypass — admin role always passes (default: true)
*/
const ALLOWED_TABLES = new Set(['tests','classes','assignments','questions','courses','lessons','files','folders','shop_items','live_sessions']);
const ALLOWED_TABLES = new Set(['tests','classes','assignments','questions','courses','lessons','files','folders','shop_items','live_sessions','flashcard_decks']);
function requireOwnership({ table, fetchFn, ownerField, paramKey = 'id', adminBypass = true }) {
if (table && !ALLOWED_TABLES.has(table)) throw new Error(`requireOwnership: unknown table "${table}"`);
+2
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const fc = require('../controllers/flashcardController');
const { authMiddleware } = require('../middleware/auth');
const { requireOwnership } = require('../middleware/ownership');
router.use(authMiddleware);
@@ -14,6 +15,7 @@ router.delete('/decks/:id', fc.deleteDeck);
router.get ('/decks/:id/cards', fc.getCards);
router.post ('/decks/:id/cards', fc.addCard);
router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
router.get ('/decks/:id/study', fc.getStudySession);
router.put ('/cards/:id', fc.updateCard);
router.delete('/cards/:id', fc.deleteCard);
+181 -26
View File
@@ -80,6 +80,14 @@
.card-item { background: #fff; border: 1.5px solid var(--border); border-radius: 12px;
display: flex; gap: 0; overflow: hidden; }
.card-item.editing { border-color: var(--violet); }
.card-item.dragging { opacity: .45; }
.card-item.drag-over-top { box-shadow: inset 0 3px 0 0 var(--violet); }
.card-item.drag-over-bottom { box-shadow: inset 0 -3px 0 0 var(--violet); }
.card-drag { display: flex; align-items: center; padding: 0 6px; cursor: grab;
color: var(--text-3); flex-shrink: 0; border-right: 1px solid var(--border); }
.card-drag:active { cursor: grabbing; }
.card-drag:hover { color: var(--violet); background: var(--surface-2); }
.card-drag .ic { width: 18px; height: 18px; }
.card-side { flex: 1; padding: 12px 14px; min-width: 0; }
.card-divider { width: 1px; background: var(--border); flex-shrink: 0; }
.card-side-lbl { font-size: .68rem; font-weight: 700; text-transform: uppercase;
@@ -101,6 +109,15 @@
color: var(--text); outline: none; transition: .15s; }
.card-add-input:focus { border-color: var(--violet); }
/* card search */
.card-search-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px;
background: #fff; border: 1.5px solid var(--border); border-radius: 10px; padding: 0 12px; }
.card-search-bar:focus-within { border-color: var(--violet); }
.card-search-ic { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.card-search-input { flex: 1; border: none; outline: none; background: transparent; padding: 9px 0;
font-family: 'Manrope', sans-serif; font-size: .86rem; color: var(--text); }
.card-search-count { font-size: .72rem; color: var(--text-3); font-weight: 600; flex-shrink: 0; }
/* ── study mode ── */
#view-study { display: none; }
.study-wrap { max-width: 600px; margin: 0 auto; }
@@ -145,7 +162,12 @@
.sq-btn { padding: 10px 20px; border-radius: 10px; border: 2px solid transparent;
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .84rem; font-weight: 700;
transition: .18s; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.sq-btn .sq-top { display: flex; align-items: center; gap: 6px; }
.sq-btn .sq-days { font-size: .66rem; font-weight: 600; opacity: .65; }
.fc-kbd { font-family: 'Manrope', sans-serif; font-size: .62rem; font-weight: 800; line-height: 1;
padding: 2px 5px; border-radius: 5px; background: rgba(0,0,0,.08);
border: 1px solid rgba(0,0,0,.12); color: inherit; opacity: .8; }
.study-hint .fc-kbd { opacity: .9; }
.sq-btn-again { background: #FEE2E2; border-color: #FECACA; color: #DC2626; }
.sq-btn-again:hover { background: #FECACA; }
.sq-btn-hard { background: #FEF3C7; border-color: #FDE68A; color: #D97706; }
@@ -253,6 +275,11 @@
<button class="fc-btn fc-btn-ghost" onclick="openBulkModal()">Добавить список</button>
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
</div>
<div class="card-search-bar" id="card-search-bar" style="display:none">
<svg class="ic card-search-ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input class="card-search-input" id="card-search" placeholder="Поиск по карточкам…" oninput="onCardSearch(this.value)" />
<span class="card-search-count" id="card-search-count"></span>
</div>
<div class="card-list" id="card-list"></div>
<!-- Add card row -->
<div class="card-add-bar" style="margin-bottom:14px">
@@ -293,13 +320,13 @@
<span class="swipe-indicator swipe-left-ind" id="ind-left">ЕЩЁ РАЗ <svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
</div>
</div>
<div class="study-hint" id="study-flip-hint">Нажмите, чтобы увидеть ответ</div>
<div class="study-hint" id="study-flip-hint">Нажмите или <kbd class="fc-kbd">Пробел</kbd>, чтобы увидеть ответ</div>
<div class="study-btns" id="study-btns">
<button class="sq-btn sq-btn-again" onclick="answer(0)">Снова<span class="sq-days" id="sq-days-0">&lt;1 мин</span></button>
<button class="sq-btn sq-btn-hard" onclick="answer(3)">Трудно<span class="sq-days" id="sq-days-3"></span></button>
<button class="sq-btn sq-btn-good" onclick="answer(4)">Знаю<span class="sq-days" id="sq-days-4"></span></button>
<button class="sq-btn sq-btn-easy" onclick="answer(5)">Легко<span class="sq-days" id="sq-days-5"></span></button>
<button class="sq-btn sq-btn-again" onclick="answer(0)"><span class="sq-top">Снова<kbd class="fc-kbd">1</kbd></span><span class="sq-days" id="sq-days-0">1 день</span></button>
<button class="sq-btn sq-btn-hard" onclick="answer(3)"><span class="sq-top">Трудно<kbd class="fc-kbd">2</kbd></span><span class="sq-days" id="sq-days-3"></span></button>
<button class="sq-btn sq-btn-good" onclick="answer(4)"><span class="sq-top">Знаю<kbd class="fc-kbd">3</kbd></span><span class="sq-days" id="sq-days-4"></span></button>
<button class="sq-btn sq-btn-easy" onclick="answer(5)"><span class="sq-top">Легко<kbd class="fc-kbd">4</kbd></span><span class="sq-days" id="sq-days-5"></span></button>
</div>
<!-- done screen -->
@@ -379,6 +406,7 @@ let _curDeck = null;
let _cards = [];
let _editingDeckId = null;
let _deckColor = '#9B5DE5';
let _cardFilter = '';
(async () => {
/* ── auth ── */
@@ -402,9 +430,33 @@ let _deckColor = '#9B5DE5';
/* ════ Init ════ */
async function init() {
buildColorPicker();
bindStudyKeys();
await loadDecks();
}
/* ── keyboard shortcuts in study mode ──
Space/Enter/↑/↓ — перевернуть; после переворота 1-4 или ←/→ — оценка. */
function bindStudyKeys() {
document.addEventListener('keydown', (e) => {
if (document.getElementById('view-study')?.style.display !== 'block') return;
if (document.getElementById('study-done')?.style.display === 'block') return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
const flip = ['Space', 'Enter', 'ArrowUp', 'ArrowDown'];
if (!_studyFlipped) {
if (flip.includes(e.code) || e.key === ' ') { e.preventDefault(); flipCard(); }
else if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') { e.preventDefault(); flipCard(); }
return;
}
// flipped → grade
const map = { Digit1: 0, Digit2: 3, Digit3: 4, Digit4: 5,
Numpad1: 0, Numpad2: 3, Numpad3: 4, Numpad4: 5,
ArrowLeft: 0, ArrowRight: 5 };
if (e.code in map) { e.preventDefault(); answer(map[e.code]); }
});
}
async function loadDecks() {
const [decks, stats] = await Promise.all([
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
@@ -442,7 +494,7 @@ function renderDecks() {
const grid = document.getElementById('deck-grid');
if (!_decks.length) {
grid.innerHTML = `<div class="fc-empty" style="grid-column:1/-1">
<div class="fc-empty-icon">🃏</div>
<div class="fc-empty-icon"><svg class="ic" style="width:46px;height:46px" viewBox="0 0 24 24"><rect x="3" y="5" width="13" height="15" rx="2"/><path d="M8 5V3.5A1.5 1.5 0 0 1 9.5 2h9A1.5 1.5 0 0 1 20 3.5V16a1.5 1.5 0 0 1-1.5 1.5H16"/></svg></div>
<h3>Нет колод</h3>
<p>Создайте первую колоду карточек</p>
<button class="fc-btn fc-btn-primary" onclick="openNewDeckModal()">+ Создать колоду</button>
@@ -485,6 +537,8 @@ async function openDeck(id) {
document.getElementById('cards-deck-title').textContent = _curDeck.title;
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
_cards = data.cards || [];
_cardFilter = '';
const si = document.getElementById('card-search'); if (si) si.value = '';
renderCardList();
document.getElementById('view-decks').style.display = 'none';
document.getElementById('view-cards').style.display = 'block';
@@ -511,18 +565,48 @@ function showCards() {
}
/* ════ Card list ════ */
function onCardSearch(v) {
_cardFilter = (v || '').trim().toLowerCase();
renderCardList();
}
function renderCardList() {
const list = document.getElementById('card-list');
const bar = document.getElementById('card-search-bar');
// строка поиска появляется, когда карточек достаточно для фильтрации
if (bar) bar.style.display = _cards.length > 4 ? 'flex' : 'none';
if (!_cards.length) {
if (bar) bar.style.display = 'none';
list.innerHTML = `<div class="fc-empty" style="padding:30px 0">
<div class="fc-empty-icon"><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></div>
<div class="fc-empty-icon"><svg class="ic" style="width:40px;height:40px" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></div>
<h3>Нет карточек</h3>
<p>Добавьте первую карточку ниже</p>
</div>`;
return;
}
list.innerHTML = _cards.map((c, i) => `
<div class="card-item" id="ci-${c.id}">
const q = _cardFilter;
const shown = q
? _cards.filter(c => (c.front || '').toLowerCase().includes(q) || (c.back || '').toLowerCase().includes(q))
: _cards;
const cnt = document.getElementById('card-search-count');
if (cnt) cnt.textContent = q ? `${shown.length} из ${_cards.length}` : `${_cards.length} карточек`;
if (!shown.length) {
list.innerHTML = `<div class="fc-empty" style="padding:24px 0">
<h3>Ничего не найдено</h3>
<p>По запросу «${esc(q)}» карточек нет</p>
</div>`;
return;
}
list.innerHTML = shown.map((c) => `
<div class="card-item" id="ci-${c.id}" data-id="${c.id}">
${q ? '' : `<div class="card-drag" title="Перетащите для сортировки">
<svg class="ic" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></svg>
</div>`}
<div class="card-side">
<div class="card-side-lbl">Вопрос</div>
<textarea class="card-textarea" rows="2"
@@ -540,6 +624,71 @@ function renderCardList() {
</button>
</div>
</div>`).join('');
if (!q) bindCardDrag();
}
/* ── drag-reorder карточек (только без активного фильтра) ── */
let _dragId = null;
function bindCardDrag() {
const list = document.getElementById('card-list');
if (!list) return;
list.querySelectorAll('.card-item').forEach(el => {
const handle = el.querySelector('.card-drag');
if (!handle) return;
// перетаскивание стартует только с ручки — textarea остаётся редактируемой
handle.addEventListener('mousedown', () => el.setAttribute('draggable', 'true'));
handle.addEventListener('touchstart', () => el.setAttribute('draggable', 'true'), { passive: true });
el.addEventListener('dragstart', (e) => {
_dragId = +el.dataset.id;
el.classList.add('dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(_dragId)); } catch {}
});
el.addEventListener('dragend', () => {
el.classList.remove('dragging'); el.removeAttribute('draggable');
list.querySelectorAll('.drag-over-top,.drag-over-bottom')
.forEach(x => x.classList.remove('drag-over-top', 'drag-over-bottom'));
_dragId = null;
});
el.addEventListener('dragover', (e) => {
e.preventDefault();
const r = el.getBoundingClientRect();
const after = (e.clientY - r.top) > r.height / 2;
el.classList.toggle('drag-over-bottom', after);
el.classList.toggle('drag-over-top', !after);
});
el.addEventListener('dragleave', () => el.classList.remove('drag-over-top', 'drag-over-bottom'));
el.addEventListener('drop', (e) => {
e.preventDefault();
el.classList.remove('drag-over-top', 'drag-over-bottom');
const targetId = +el.dataset.id;
if (_dragId == null || _dragId === targetId) return;
const r = el.getBoundingClientRect();
const after = (e.clientY - r.top) > r.height / 2;
moveCard(_dragId, targetId, after);
});
});
}
function moveCard(dragId, targetId, after) {
const from = _cards.findIndex(c => c.id === dragId);
if (from < 0) return;
const item = _cards.splice(from, 1)[0];
let to = _cards.findIndex(c => c.id === targetId);
if (to < 0) { _cards.splice(from, 0, item); return; }
if (after) to += 1;
_cards.splice(to, 0, item);
renderCardList();
persistCardOrder();
}
async function persistCardOrder() {
if (!_curDeck) return;
const order = _cards.map(c => c.id);
await LS.api(`/api/flashcards/decks/${_curDeck.id}/reorder`, {
method: 'PUT', body: JSON.stringify({ order })
}).catch(() => {});
}
async function addCard() {
@@ -572,7 +721,7 @@ async function saveCard(id, field, value) {
}
async function deleteCard(id) {
if (!confirm('Удалить карточку?')) return;
if (!await LS.confirm('Удалить карточку?', { title: 'Удаление карточки', confirmText: 'Удалить', danger: true })) return;
await LS.api(`/api/flashcards/cards/${id}`, { method: 'DELETE' }).catch(()=>{});
_cards = _cards.filter(c => c.id !== id);
renderCardList();
@@ -726,25 +875,31 @@ function finishStudy() {
</div>`).join('');
}
/* ── estimated next interval preview for sq buttons ── */
function updateSQDays(card) {
/* ── estimated next interval preview for sq buttons ──
ВАЖНО: точная копия серверного sm2() (flashcardController.js), иначе
превью врёт. В чистом SM-2 интервал для q>=3 НЕ зависит от значения q
(q влияет только на ease factor), поэтому «Трудно/Знаю/Легко» при первых
повторениях дают одинаковый интервал — это корректно.
(Дифференциация интервалов по кнопкам — кандидат на Фазу 4.) */
function fcNextInterval(card, q) {
const ef = card.ease_factor || 2.5;
const iv = card.interval_days || 1;
const rep = card.repetitions || 0;
const preview = (q) => {
if (q < 3) return '<1 мин';
let niv;
if (rep === 0) niv = 1;
else if (rep === 1) niv = 6;
else niv = Math.round(iv * ef);
const nef = Math.max(1.3, ef + 0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
let n2 = (q === 3 ? Math.max(1, niv - 2) : q === 4 ? niv : Math.round(niv * nef));
return n2 <= 1 ? '1 день' : n2 + ' дн.';
};
document.getElementById('sq-days-0').textContent = '<1 мин';
document.getElementById('sq-days-3').textContent = preview(3);
document.getElementById('sq-days-4').textContent = preview(4);
document.getElementById('sq-days-5').textContent = preview(5);
if (q < 3) return 1;
if (rep === 0) return 1;
if (rep === 1) return 6;
return Math.round(iv * ef);
}
function fcDaysLabel(n) {
if (n <= 1) return '1 день';
if (n < 5) return n + ' дня';
return n + ' дн.';
}
function updateSQDays(card) {
document.getElementById('sq-days-0').textContent = fcDaysLabel(fcNextInterval(card, 0));
document.getElementById('sq-days-3').textContent = fcDaysLabel(fcNextInterval(card, 3));
document.getElementById('sq-days-4').textContent = fcDaysLabel(fcNextInterval(card, 4));
document.getElementById('sq-days-5').textContent = fcDaysLabel(fcNextInterval(card, 5));
}
/* ── touch/mouse swipe ── */