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:
@@ -126,6 +126,30 @@ function addCardsBulk(req, res) {
|
|||||||
res.json({ inserted });
|
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 ─────────────────────────────────────── */
|
/* ── PUT /api/flashcards/cards/:id ─────────────────────────────────────── */
|
||||||
function updateCard(req, res) {
|
function updateCard(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
@@ -298,7 +322,7 @@ function getRandom(req, res) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
listDecks, createDeck, updateDeck, deleteDeck,
|
listDecks, createDeck, updateDeck, deleteDeck,
|
||||||
getCards, addCard, addCardsBulk, updateCard, deleteCard,
|
getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards,
|
||||||
getStudySession, submitReview, getStats,
|
getStudySession, submitReview, getStats,
|
||||||
quickAdd, getRandom,
|
quickAdd, getRandom,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const db = require('../db/db');
|
|||||||
* paramKey — req.params key for the record ID (default: 'id')
|
* paramKey — req.params key for the record ID (default: 'id')
|
||||||
* adminBypass — admin role always passes (default: true)
|
* 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 }) {
|
function requireOwnership({ table, fetchFn, ownerField, paramKey = 'id', adminBypass = true }) {
|
||||||
if (table && !ALLOWED_TABLES.has(table)) throw new Error(`requireOwnership: unknown table "${table}"`);
|
if (table && !ALLOWED_TABLES.has(table)) throw new Error(`requireOwnership: unknown table "${table}"`);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const fc = require('../controllers/flashcardController');
|
const fc = require('../controllers/flashcardController');
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
const { requireOwnership } = require('../middleware/ownership');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ router.delete('/decks/:id', fc.deleteDeck);
|
|||||||
router.get ('/decks/:id/cards', fc.getCards);
|
router.get ('/decks/:id/cards', fc.getCards);
|
||||||
router.post ('/decks/:id/cards', fc.addCard);
|
router.post ('/decks/:id/cards', fc.addCard);
|
||||||
router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
|
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.get ('/decks/:id/study', fc.getStudySession);
|
||||||
router.put ('/cards/:id', fc.updateCard);
|
router.put ('/cards/:id', fc.updateCard);
|
||||||
router.delete('/cards/:id', fc.deleteCard);
|
router.delete('/cards/:id', fc.deleteCard);
|
||||||
|
|||||||
+181
-26
@@ -80,6 +80,14 @@
|
|||||||
.card-item { background: #fff; border: 1.5px solid var(--border); border-radius: 12px;
|
.card-item { background: #fff; border: 1.5px solid var(--border); border-radius: 12px;
|
||||||
display: flex; gap: 0; overflow: hidden; }
|
display: flex; gap: 0; overflow: hidden; }
|
||||||
.card-item.editing { border-color: var(--violet); }
|
.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-side { flex: 1; padding: 12px 14px; min-width: 0; }
|
||||||
.card-divider { width: 1px; background: var(--border); flex-shrink: 0; }
|
.card-divider { width: 1px; background: var(--border); flex-shrink: 0; }
|
||||||
.card-side-lbl { font-size: .68rem; font-weight: 700; text-transform: uppercase;
|
.card-side-lbl { font-size: .68rem; font-weight: 700; text-transform: uppercase;
|
||||||
@@ -101,6 +109,15 @@
|
|||||||
color: var(--text); outline: none; transition: .15s; }
|
color: var(--text); outline: none; transition: .15s; }
|
||||||
.card-add-input:focus { border-color: var(--violet); }
|
.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 ── */
|
/* ── study mode ── */
|
||||||
#view-study { display: none; }
|
#view-study { display: none; }
|
||||||
.study-wrap { max-width: 600px; margin: 0 auto; }
|
.study-wrap { max-width: 600px; margin: 0 auto; }
|
||||||
@@ -145,7 +162,12 @@
|
|||||||
.sq-btn { padding: 10px 20px; border-radius: 10px; border: 2px solid transparent;
|
.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;
|
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; }
|
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; }
|
.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 { background: #FEE2E2; border-color: #FECACA; color: #DC2626; }
|
||||||
.sq-btn-again:hover { background: #FECACA; }
|
.sq-btn-again:hover { background: #FECACA; }
|
||||||
.sq-btn-hard { background: #FEF3C7; border-color: #FDE68A; color: #D97706; }
|
.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-ghost" onclick="openBulkModal()">Добавить список</button>
|
||||||
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
|
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
|
||||||
</div>
|
</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>
|
<div class="card-list" id="card-list"></div>
|
||||||
<!-- Add card row -->
|
<!-- Add card row -->
|
||||||
<div class="card-add-bar" style="margin-bottom:14px">
|
<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>
|
<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>
|
</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">
|
<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"><1 мин</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-days" id="sq-days-3">—</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-days" id="sq-days-4">—</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-days" id="sq-days-5">—</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>
|
</div>
|
||||||
|
|
||||||
<!-- done screen -->
|
<!-- done screen -->
|
||||||
@@ -379,6 +406,7 @@ let _curDeck = null;
|
|||||||
let _cards = [];
|
let _cards = [];
|
||||||
let _editingDeckId = null;
|
let _editingDeckId = null;
|
||||||
let _deckColor = '#9B5DE5';
|
let _deckColor = '#9B5DE5';
|
||||||
|
let _cardFilter = '';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
/* ── auth ── */
|
/* ── auth ── */
|
||||||
@@ -402,9 +430,33 @@ let _deckColor = '#9B5DE5';
|
|||||||
/* ════ Init ════ */
|
/* ════ Init ════ */
|
||||||
async function init() {
|
async function init() {
|
||||||
buildColorPicker();
|
buildColorPicker();
|
||||||
|
bindStudyKeys();
|
||||||
await loadDecks();
|
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() {
|
async function loadDecks() {
|
||||||
const [decks, stats] = await Promise.all([
|
const [decks, stats] = await Promise.all([
|
||||||
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
|
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
|
||||||
@@ -442,7 +494,7 @@ function renderDecks() {
|
|||||||
const grid = document.getElementById('deck-grid');
|
const grid = document.getElementById('deck-grid');
|
||||||
if (!_decks.length) {
|
if (!_decks.length) {
|
||||||
grid.innerHTML = `<div class="fc-empty" style="grid-column:1/-1">
|
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>
|
<h3>Нет колод</h3>
|
||||||
<p>Создайте первую колоду карточек</p>
|
<p>Создайте первую колоду карточек</p>
|
||||||
<button class="fc-btn fc-btn-primary" onclick="openNewDeckModal()">+ Создать колоду</button>
|
<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;
|
document.getElementById('cards-deck-title').textContent = _curDeck.title;
|
||||||
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
|
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
|
||||||
_cards = data.cards || [];
|
_cards = data.cards || [];
|
||||||
|
_cardFilter = '';
|
||||||
|
const si = document.getElementById('card-search'); if (si) si.value = '';
|
||||||
renderCardList();
|
renderCardList();
|
||||||
document.getElementById('view-decks').style.display = 'none';
|
document.getElementById('view-decks').style.display = 'none';
|
||||||
document.getElementById('view-cards').style.display = 'block';
|
document.getElementById('view-cards').style.display = 'block';
|
||||||
@@ -511,18 +565,48 @@ function showCards() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ════ Card list ════ */
|
/* ════ Card list ════ */
|
||||||
|
function onCardSearch(v) {
|
||||||
|
_cardFilter = (v || '').trim().toLowerCase();
|
||||||
|
renderCardList();
|
||||||
|
}
|
||||||
|
|
||||||
function renderCardList() {
|
function renderCardList() {
|
||||||
const list = document.getElementById('card-list');
|
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 (!_cards.length) {
|
||||||
|
if (bar) bar.style.display = 'none';
|
||||||
list.innerHTML = `<div class="fc-empty" style="padding:30px 0">
|
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>
|
<h3>Нет карточек</h3>
|
||||||
<p>Добавьте первую карточку ниже</p>
|
<p>Добавьте первую карточку ниже</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
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">
|
||||||
<div class="card-side-lbl">Вопрос</div>
|
<div class="card-side-lbl">Вопрос</div>
|
||||||
<textarea class="card-textarea" rows="2"
|
<textarea class="card-textarea" rows="2"
|
||||||
@@ -540,6 +624,71 @@ function renderCardList() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
</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() {
|
async function addCard() {
|
||||||
@@ -572,7 +721,7 @@ async function saveCard(id, field, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCard(id) {
|
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(()=>{});
|
await LS.api(`/api/flashcards/cards/${id}`, { method: 'DELETE' }).catch(()=>{});
|
||||||
_cards = _cards.filter(c => c.id !== id);
|
_cards = _cards.filter(c => c.id !== id);
|
||||||
renderCardList();
|
renderCardList();
|
||||||
@@ -726,25 +875,31 @@ function finishStudy() {
|
|||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── estimated next interval preview for sq buttons ── */
|
/* ── estimated next interval preview for sq buttons ──
|
||||||
function updateSQDays(card) {
|
ВАЖНО: точная копия серверного sm2() (flashcardController.js), иначе
|
||||||
|
превью врёт. В чистом SM-2 интервал для q>=3 НЕ зависит от значения q
|
||||||
|
(q влияет только на ease factor), поэтому «Трудно/Знаю/Легко» при первых
|
||||||
|
повторениях дают одинаковый интервал — это корректно.
|
||||||
|
(Дифференциация интервалов по кнопкам — кандидат на Фазу 4.) */
|
||||||
|
function fcNextInterval(card, q) {
|
||||||
const ef = card.ease_factor || 2.5;
|
const ef = card.ease_factor || 2.5;
|
||||||
const iv = card.interval_days || 1;
|
const iv = card.interval_days || 1;
|
||||||
const rep = card.repetitions || 0;
|
const rep = card.repetitions || 0;
|
||||||
const preview = (q) => {
|
if (q < 3) return 1;
|
||||||
if (q < 3) return '<1 мин';
|
if (rep === 0) return 1;
|
||||||
let niv;
|
if (rep === 1) return 6;
|
||||||
if (rep === 0) niv = 1;
|
return Math.round(iv * ef);
|
||||||
else if (rep === 1) niv = 6;
|
}
|
||||||
else niv = Math.round(iv * ef);
|
function fcDaysLabel(n) {
|
||||||
const nef = Math.max(1.3, ef + 0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
|
if (n <= 1) return '1 день';
|
||||||
let n2 = (q === 3 ? Math.max(1, niv - 2) : q === 4 ? niv : Math.round(niv * nef));
|
if (n < 5) return n + ' дня';
|
||||||
return n2 <= 1 ? '1 день' : n2 + ' дн.';
|
return n + ' дн.';
|
||||||
};
|
}
|
||||||
document.getElementById('sq-days-0').textContent = '<1 мин';
|
function updateSQDays(card) {
|
||||||
document.getElementById('sq-days-3').textContent = preview(3);
|
document.getElementById('sq-days-0').textContent = fcDaysLabel(fcNextInterval(card, 0));
|
||||||
document.getElementById('sq-days-4').textContent = preview(4);
|
document.getElementById('sq-days-3').textContent = fcDaysLabel(fcNextInterval(card, 3));
|
||||||
document.getElementById('sq-days-5').textContent = preview(5);
|
document.getElementById('sq-days-4').textContent = fcDaysLabel(fcNextInterval(card, 4));
|
||||||
|
document.getElementById('sq-days-5').textContent = fcDaysLabel(fcNextInterval(card, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── touch/mouse swipe ── */
|
/* ── touch/mouse swipe ── */
|
||||||
|
|||||||
Reference in New Issue
Block a user