diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index aeb2be4..33a30f6 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -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, }; diff --git a/backend/src/middleware/ownership.js b/backend/src/middleware/ownership.js index 98cef49..e63de06 100644 --- a/backend/src/middleware/ownership.js +++ b/backend/src/middleware/ownership.js @@ -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}"`); diff --git a/backend/src/routes/flashcards.js b/backend/src/routes/flashcards.js index 3966276..be0d81e 100644 --- a/backend/src/routes/flashcards.js +++ b/backend/src/routes/flashcards.js @@ -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); diff --git a/frontend/flashcards.html b/frontend/flashcards.html index 585df6e..4bdc33c 100644 --- a/frontend/flashcards.html +++ b/frontend/flashcards.html @@ -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 @@ +
-Создайте первую колоду карточек
@@ -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 = `Добавьте первую карточку ниже
По запросу «${esc(q)}» карточек нет
+