diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index 33a30f6..50f50a2 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -1,6 +1,15 @@ const db = require('../db/db'); const { stripTags } = require('../utils/sanitize'); +/* ── валидация URL картинки ──────────────────────────────────────────────── + Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/) — + защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */ +function safeImg(url) { + if (typeof url !== 'string') return ''; + const u = url.trim(); + return /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(u) ? u : ''; +} + /* ── SM-2 algorithm ─────────────────────────────────────────────────────── quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar, 3 = correct with difficulty, 4 = correct, 5 = perfect @@ -97,11 +106,13 @@ function addCard(req, res) { if (!deck) return res.status(404).json({ error: 'Not found' }); const front = stripTags((req.body.front || '').slice(0, 5000)); const back = stripTags((req.body.back || '').slice(0, 5000)); + const front_image = safeImg(req.body.front_image); + const back_image = safeImg(req.body.back_image); const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) .get(deck.id)?.m ?? -1; - const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`) - .run(deck.id, front, back, maxIdx + 1); - res.json({ id: r.lastInsertRowid, deck_id: deck.id, front, back, order_idx: maxIdx + 1 }); + const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`) + .run(deck.id, front, back, front_image, back_image, maxIdx + 1); + res.json({ id: r.lastInsertRowid, deck_id: deck.id, front, back, front_image, back_image, order_idx: maxIdx + 1 }); } /* ── POST /api/flashcards/decks/:id/cards/bulk ──────────────────────────── */ @@ -160,9 +171,12 @@ function updateCard(req, res) { `).get(req.params.id, uid); if (!card) return res.status(404).json({ error: 'Not found' }); const { front, back } = req.body; - db.prepare(`UPDATE flashcard_cards SET front=?, back=? WHERE id=?`) - .run(front ?? card.front, back ?? card.back, card.id); - res.json({ ok: true }); + // Картинки трогаем только если поле реально пришло (пустая строка = очистить). + const frontImg = ('front_image' in req.body) ? safeImg(req.body.front_image) : card.front_image; + const backImg = ('back_image' in req.body) ? safeImg(req.body.back_image) : card.back_image; + db.prepare(`UPDATE flashcard_cards SET front=?, back=?, front_image=?, back_image=? WHERE id=?`) + .run(front ?? card.front, back ?? card.back, frontImg, backImg, card.id); + res.json({ ok: true, front_image: frontImg, back_image: backImg }); } /* ── DELETE /api/flashcards/cards/:id ──────────────────────────────────── */ @@ -186,7 +200,7 @@ function getStudySession(req, res) { if (!deck) return res.status(404).json({ error: 'Not found' }); // due cards first, then new cards (no review yet), limit 20 const cards = db.prepare(` - SELECT c.id, c.front, c.back, + SELECT c.id, c.front, c.back, c.front_image, c.back_image, COALESCE(r.ease_factor, 2.5) AS ease_factor, COALESCE(r.interval_days, 1) AS interval_days, COALESCE(r.repetitions, 0) AS repetitions, @@ -273,7 +287,9 @@ function quickAdd(req, res) { const uid = req.user.id; const front = stripTags((req.body.front || '').slice(0, 5000)).trim(); const back = stripTags((req.body.back || '').slice(0, 5000)).trim(); - if (!front) return res.status(400).json({ error: 'Лицевая сторона обязательна' }); + const front_image = safeImg(req.body.front_image); + const back_image = safeImg(req.body.back_image); + if (!front && !front_image) return res.status(400).json({ error: 'Заполни лицевую сторону (текст или картинку)' }); let deck = null; const deckId = Number(req.body.deckId) || 0; @@ -294,9 +310,9 @@ function quickAdd(req, res) { const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) .get(deck.id)?.m ?? -1; - const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`) - .run(deck.id, front, back, maxIdx + 1); - res.json({ id: r.lastInsertRowid, deck_id: deck.id, deck_title: deck.title, deck_color: deck.color, front, back }); + const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`) + .run(deck.id, front, back, front_image, back_image, maxIdx + 1); + res.json({ id: r.lastInsertRowid, deck_id: deck.id, deck_title: deck.title, deck_color: deck.color, front, back, front_image, back_image }); } /* ── GET /api/flashcards/random — случайная карточка из всего пула ─────── @@ -310,7 +326,7 @@ function getRandom(req, res) { if (!total) return res.json({ card: null, total: 0 }); const card = db.prepare(` - SELECT c.id, c.front, c.back, c.deck_id, + SELECT c.id, c.front, c.back, c.front_image, c.back_image, c.deck_id, d.title AS deck_title, d.color AS deck_color FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id @@ -320,9 +336,17 @@ function getRandom(req, res) { res.json({ card, total }); } +/* ── POST /api/flashcards/upload — загрузка картинки для карточки ────────── + multipart (поле file). Возвращает { url } для сохранения в front_image / + back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */ +function uploadImage(req, res) { + if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' }); + res.json({ url: `/uploads/flashcards/${req.file.filename}` }); +} + module.exports = { listDecks, createDeck, updateDeck, deleteDeck, getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards, getStudySession, submitReview, getStats, - quickAdd, getRandom, + quickAdd, getRandom, uploadImage, }; diff --git a/backend/src/db/migrations/048_flashcard_images.sql b/backend/src/db/migrations/048_flashcard_images.sql new file mode 100644 index 0000000..dbe922d --- /dev/null +++ b/backend/src/db/migrations/048_flashcard_images.sql @@ -0,0 +1,10 @@ +-- 048_flashcard_images.sql +-- Картинки на флэш-карточках: отдельная сторона может нести изображение +-- (диаграмма, формула-скрин, график) в дополнение к тексту. +-- +-- Храним ОТНОСИТЕЛЬНЫЙ URL загруженного файла (/uploads/flashcards/.png), +-- а не сам бинарь — файлы лежат на диске в backend/uploads/flashcards и +-- отдаются статикой. Пустая строка = картинки нет (как front/back). + +ALTER TABLE flashcard_cards ADD COLUMN front_image TEXT NOT NULL DEFAULT ''; +ALTER TABLE flashcard_cards ADD COLUMN back_image TEXT NOT NULL DEFAULT ''; diff --git a/backend/src/routes/flashcards.js b/backend/src/routes/flashcards.js index be0d81e..e488181 100644 --- a/backend/src/routes/flashcards.js +++ b/backend/src/routes/flashcards.js @@ -1,11 +1,38 @@ const express = require('express'); const router = express.Router(); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); const fc = require('../controllers/flashcardController'); const { authMiddleware } = require('../middleware/auth'); const { requireOwnership } = require('../middleware/ownership'); +/* ── multer для картинок карточек ─────────────────────────────────────── + Файлы складываем в backend/uploads/flashcards, отдаём статикой через + /uploads/flashcards (см. server.js). Имя — случайный hex, расширение из + оригинала (нормализованное). Только изображения, до 5 МБ. */ +const _fcUploadsDir = path.join(__dirname, '../../uploads/flashcards'); +if (!fs.existsSync(_fcUploadsDir)) fs.mkdirSync(_fcUploadsDir, { recursive: true }); + +const _fcStorage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, _fcUploadsDir), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, ''); + cb(null, crypto.randomBytes(14).toString('hex') + (ext || '.png')); + }, +}); +const fcUpload = multer({ + storage: _fcStorage, + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (req, file, cb) => + cb(null, ['image/jpeg','image/png','image/gif','image/webp'].includes(file.mimetype)), +}); + router.use(authMiddleware); +router.post ('/upload', fcUpload.single('file'), fc.uploadImage); + router.post ('/quick', fc.quickAdd); router.get ('/random', fc.getRandom); router.get ('/decks', fc.listDecks); diff --git a/backend/src/server.js b/backend/src/server.js index b3f6b55..3cbb1bd 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -331,6 +331,7 @@ app.use('/js', express.static(jsDir, staticCache)); app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache)); app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache)); app.use('/avatars', express.static(path.join(__dirname, '../uploads/avatars'), { maxAge: '1d' })); +app.use('/uploads/flashcards', express.static(path.join(__dirname, '../uploads/flashcards'), { maxAge: '7d' })); // Redirect legacy .html URLs → clean URLs (301) app.use((req, res, next) => { diff --git a/frontend/flashcards.html b/frontend/flashcards.html index dd80ffe..8377a4e 100644 --- a/frontend/flashcards.html +++ b/frontend/flashcards.html @@ -146,6 +146,24 @@ .card-act-btn:hover { background: var(--surface-2); color: var(--text); } .card-act-btn.del:hover { background: #FEE2E2; color: #DC2626; } + /* card image (editor) */ + .card-img-row { margin-top: 8px; } + .card-img-wrap { position: relative; display: inline-block; } + .card-img-thumb { max-width: 140px; max-height: 92px; border-radius: 8px; display: block; + border: 1.5px solid var(--border); object-fit: cover; cursor: zoom-in; } + .card-img-remove { position: absolute; top: -7px; right: -7px; width: 22px; height: 22px; + border-radius: 50%; border: none; background: #DC2626; color: #fff; cursor: pointer; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 6px rgba(0,0,0,.25); } + .card-img-remove svg { width: 12px; height: 12px; } + .card-img-add { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 8px; + border: 1.5px dashed var(--border); background: none; cursor: pointer; color: var(--text-3); + font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 600; transition: .15s; } + .card-img-add:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.04); } + .card-img-add .ic { width: 13px; height: 13px; } + #new-card-imgs { display: none; gap: 14px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; } + .new-img-lbl { font-size: .7rem; font-weight: 700; color: var(--text-3); margin-right: 4px; } + .card-add-bar { display: flex; gap: 10px; align-items: center; } .card-add-input { flex: 1; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: .88rem; background: #fff; @@ -185,8 +203,11 @@ .study-face { position: absolute; inset: 0; border-radius: 22px; padding: 36px 40px; display: flex; align-items: center; justify-content: center; + flex-direction: column; gap: 12px; backface-visibility: hidden; -webkit-backface-visibility: hidden; border: 1.5px solid var(--border); overflow: auto; } + .study-face-img { max-width: 100%; max-height: 190px; border-radius: 12px; object-fit: contain; + box-shadow: 0 2px 10px rgba(0,0,0,.1); } .study-face-front { background: linear-gradient(170deg, var(--deck-color-a, rgba(155,93,229,.07)) 0%, #fff 38%); box-shadow: 0 10px 40px rgba(0,0,0,.1), 0 2px 8px rgba(0,0,0,.06); } .study-face-back { background: linear-gradient(170deg, rgba(246,243,255,.9) 0%, #fff 45%); @@ -344,10 +365,13 @@
- - + +
+
@@ -371,10 +395,12 @@
Вопрос +
Ответ +
ЗНАЮ @@ -474,6 +500,7 @@ let _cards = []; let _editingDeckId = null; let _deckColor = '#9B5DE5'; let _cardFilter = ''; +let _newImg = { front: '', back: '' }; // картинки, прикреплённые к ещё не созданной карточке (async () => { /* ── auth ── */ @@ -689,13 +716,17 @@ function renderCardList() {
Вопрос
+ ${imgRowHtml(c, 'front')}
Ответ
+ ${imgRowHtml(c, 'back')}
+
`; + } + return `
+
`; +} + +async function uploadFcImage(file) { + if (!file || !file.type || !file.type.startsWith('image/')) throw new Error('Только изображения'); + if (file.size > 5 * 1024 * 1024) throw new Error('Файл больше 5 МБ'); + const fd = new FormData(); + fd.append('file', file); + const token = localStorage.getItem('ls_token'); + const res = await fetch('/api/flashcards/upload', { + method: 'POST', + headers: token ? { Authorization: 'Bearer ' + token } : {}, + body: fd, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Не удалось загрузить'); + return data.url; +} + +let _imgPickInput = null; +function pickCardImage(cardId, side) { + if (!_imgPickInput) { + _imgPickInput = document.createElement('input'); + _imgPickInput.type = 'file'; + _imgPickInput.accept = 'image/*'; + _imgPickInput.style.display = 'none'; + document.body.appendChild(_imgPickInput); + } + _imgPickInput.value = ''; + _imgPickInput.onchange = () => { + const file = _imgPickInput.files && _imgPickInput.files[0]; + if (file) attachCardImage(cardId, side, file); + }; + _imgPickInput.click(); +} + +async function attachCardImage(cardId, side, file) { + const card = _cards.find(c => c.id === cardId); + if (!card) return; + const field = side === 'front' ? 'front_image' : 'back_image'; + let url; + try { LS.toast('Загрузка картинки…'); url = await uploadFcImage(file); } + catch (e) { LS.toast(e.message || 'Ошибка загрузки', 'error'); return; } + await LS.api(`/api/flashcards/cards/${cardId}`, { + method: 'PUT', body: JSON.stringify({ [field]: url }) + }).catch(()=>{}); + card[field] = url; + updateCardImgRow(cardId, side); + LS.toast('Картинка добавлена', 'success'); +} + +async function removeCardImage(cardId, side) { + const card = _cards.find(c => c.id === cardId); + if (!card) return; + const field = side === 'front' ? 'front_image' : 'back_image'; + await LS.api(`/api/flashcards/cards/${cardId}`, { + method: 'PUT', body: JSON.stringify({ [field]: '' }) + }).catch(()=>{}); + card[field] = ''; + updateCardImgRow(cardId, side); +} + +/* точечно перерисовываем только блок картинки — textarea'ы не трогаем */ +function updateCardImgRow(cardId, side) { + const card = _cards.find(c => c.id === cardId); + const item = document.getElementById('ci-' + cardId); + if (!card || !item) { renderCardList(); return; } + const sides = item.querySelectorAll('.card-side'); + const sideEl = side === 'front' ? sides[0] : sides[1]; + if (!sideEl) { renderCardList(); return; } + const row = sideEl.querySelector('.card-img-row'); + if (row) row.outerHTML = imgRowHtml(card, side); + else sideEl.insertAdjacentHTML('beforeend', imgRowHtml(card, side)); +} + +function onCardPaste(e, cardId, side) { + const items = e.clipboardData && e.clipboardData.items; + if (!items) return; + for (const it of items) { + if (it.type && it.type.startsWith('image/')) { + const file = it.getAsFile(); + if (file) { e.preventDefault(); attachCardImage(cardId, side, file); } + return; + } + } +} + +/* ── картинки для ещё не созданной карточки (add-bar) ── */ +async function onNewCardPaste(e, side) { + const items = e.clipboardData && e.clipboardData.items; + if (!items) return; + for (const it of items) { + if (it.type && it.type.startsWith('image/')) { + const file = it.getAsFile(); + if (!file) return; + e.preventDefault(); + try { const url = await uploadFcImage(file); _newImg[side] = url; renderNewImgs(); LS.toast('Картинка прикреплена к новой карточке', 'success'); } + catch (err) { LS.toast(err.message || 'Ошибка загрузки', 'error'); } + return; + } + } +} + +function renderNewImgs() { + const box = document.getElementById('new-card-imgs'); + if (!box) return; + const chip = (side, label) => _newImg[side] + ? ` + ${label} + + + ` : ''; + const f = chip('front', 'Вопрос'), b = chip('back', 'Ответ'); + box.innerHTML = f + b; + box.style.display = (f || b) ? 'flex' : 'none'; +} + +function clearNewImg(side) { _newImg[side] = ''; renderNewImgs(); } + /* ════ Bulk add ════ */ function openBulkModal() { document.getElementById('bulk-text').value = ''; @@ -891,6 +1061,8 @@ function showStudyCard() { el.className = 'study-card-inner'; document.getElementById('study-front-text').innerHTML = mathHtmlFC(card.front); document.getElementById('study-back-text').innerHTML = mathHtmlFC(card.back); + setStudyImg('study-front-img', card.front_image); + setStudyImg('study-back-img', card.back_image); _studyFlipped = false; document.getElementById('study-btns').classList.remove('visible'); document.getElementById('study-flip-hint').style.display = 'block'; @@ -900,6 +1072,13 @@ function showStudyCard() { updateSQDays(card); } +function setStudyImg(id, url) { + const img = document.getElementById(id); + if (!img) return; + if (url) { img.src = url; img.style.display = 'block'; } + else { img.removeAttribute('src'); img.style.display = 'none'; } +} + function updateStudyProgress() { const total = _studyCards.length; const done = _studyIdx; diff --git a/frontend/js/flashcard-fab.js b/frontend/js/flashcard-fab.js index 4173a51..219753b 100644 --- a/frontend/js/flashcard-fab.js +++ b/frontend/js/flashcard-fab.js @@ -19,6 +19,7 @@ let _decks = null; let _open = false; + let _img = { front: '', back: '' }; // прикреплённые картинки (URL после загрузки) const esc = (s) => (LS.esc ? LS.esc(s) : String(s == null ? '' : s)); @@ -54,10 +55,11 @@ (ctx ? '' + esc(ctx) + '' : '') + '' + '
' + - '' + + '' + '' + - '' + + '' + '' + + '
' + '' + '' + '
' + @@ -68,6 +70,8 @@ pop.querySelector('.fc-pop-x').addEventListener('click', close); pop.querySelector('#fc-save').addEventListener('click', save); + pop.querySelector('#fc-front').addEventListener('paste', (e) => onPasteImg(e, 'front')); + pop.querySelector('#fc-back').addEventListener('paste', (e) => onPasteImg(e, 'back')); pop.addEventListener('click', (e) => e.stopPropagation()); pop.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } @@ -109,15 +113,17 @@ async function save() { const front = (document.getElementById('fc-front').value || '').trim(); const back = (document.getElementById('fc-back').value || '').trim(); - if (!front) { LS.toast('Заполни лицевую сторону (вопрос)', 'error'); document.getElementById('fc-front').focus(); return; } + if (!front && !_img.front) { LS.toast('Заполни лицевую сторону (текст или картинку)', 'error'); document.getElementById('fc-front').focus(); return; } const deckId = document.getElementById('fc-deck').value; const btn = document.getElementById('fc-save'); btn.disabled = true; btn.textContent = 'Сохраняю…'; try { - const r = await LS.api('/api/flashcards/quick', { method: 'POST', body: { front, back, deckId: deckId || undefined } }); + const r = await LS.api('/api/flashcards/quick', { method: 'POST', body: { front, back, deckId: deckId || undefined, front_image: _img.front, back_image: _img.back } }); LS.toast('Карточка добавлена → «' + (r.deck_title || 'колода') + '»', 'success'); document.getElementById('fc-front').value = ''; document.getElementById('fc-back').value = ''; + _img = { front: '', back: '' }; + renderImgs(); // если создалась новая (быстрая) колода — перечитаем список при следующем открытии if (r.deck_id && _decks && !_decks.some(d => d.id === r.deck_id)) _decks = null; window.dispatchEvent(new CustomEvent('flashcard:added', { detail: r })); @@ -129,6 +135,52 @@ } } + /* ── картинки: загрузка прямым fetch (LS.api навязывает JSON-тип) ── */ + async function uploadImg(file) { + if (!file || !file.type || !file.type.startsWith('image/')) throw new Error('Только изображения'); + if (file.size > 5 * 1024 * 1024) throw new Error('Файл больше 5 МБ'); + const fd = new FormData(); + fd.append('file', file); + const token = localStorage.getItem('ls_token'); + const res = await fetch('/api/flashcards/upload', { + method: 'POST', headers: token ? { Authorization: 'Bearer ' + token } : {}, body: fd, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Не удалось загрузить'); + return data.url; + } + + async function onPasteImg(e, side) { + const items = e.clipboardData && e.clipboardData.items; + if (!items) return; + for (const it of items) { + if (it.type && it.type.startsWith('image/')) { + const file = it.getAsFile(); + if (!file) return; + e.preventDefault(); + try { _img[side] = await uploadImg(file); renderImgs(); LS.toast('Картинка прикреплена', 'success'); } + catch (err) { LS.toast(err && err.message || 'Ошибка загрузки', 'error'); } + return; + } + } + } + + function renderImgs() { + const box = document.getElementById('fc-imgs'); + if (!box) return; + const chip = (side, label) => _img[side] + ? '' + label + '' + + '' + + '' + : ''; + const html = chip('front', 'Вопрос') + chip('back', 'Ответ'); + box.innerHTML = html; + box.style.display = html ? 'flex' : 'none'; + box.querySelectorAll('.fc-img-x').forEach(b => + b.addEventListener('click', () => { _img[b.dataset.side] = ''; renderImgs(); })); + } + function ensureStyles() { if (document.getElementById('fc-fab-style')) return; const s = document.createElement('style'); @@ -189,6 +241,17 @@ .fc-sel { resize: none; cursor: pointer; } .fc-pop-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 14px; } .fc-hint { font-size: 0.68rem; color: #94a3b8; } + .fc-lbl-hint { font-weight: 600; color: #b6a7e0; text-transform: none; letter-spacing: 0; font-size: 0.62rem; } + .fc-imgs { display: none; gap: 8px; flex-wrap: wrap; margin-top: 10px; } + .fc-img-chip { position: relative; display: inline-flex; align-items: center; gap: 6px; + background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.18); + border-radius: 10px; padding: 4px 8px 4px 6px; } + .fc-img-lbl { font-size: 0.62rem; font-weight: 700; color: #7c3aed; } + .fc-img-chip img { max-height: 48px; max-width: 92px; border-radius: 6px; display: block; } + .fc-img-x { width: 18px; height: 18px; border: none; border-radius: 50%; background: #DC2626; color: #fff; + cursor: pointer; display: grid; place-items: center; flex-shrink: 0; } + .fc-img-x svg { width: 10px; height: 10px; } + .app-layout.dark .fc-img-chip { background: rgba(155,93,229,0.12); } .fc-save { padding: 9px 20px; border: none; border-radius: 99px; cursor: pointer; background: linear-gradient(135deg, #06D6E0, #9B5DE5); color: #fff;