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);