feat(flashcards): картинки на карточках (загрузка, вставка, рендер)

- Миграция 048: колонки front_image/back_image в flashcard_cards
- Бэкенд: POST /api/flashcards/upload (multer, 5МБ, только изображения),
  валидатор safeImg (только /uploads/flashcards/..., блок XSS/traversal/external),
  картинки в add/update/quick/study/random; статик-маунт /uploads/flashcards
- Редактор: превью+кнопка загрузки+вставка (Ctrl+V) на каждую сторону,
  картинки к ещё не созданной карточке через add-bar
- Режим изучения: рендер изображения над текстом на обеих сторонах
- FAB: вставка картинки в быструю карточку

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-02 12:58:24 +03:00
parent 3015a66fab
commit 3d627ce782
6 changed files with 325 additions and 21 deletions
+37 -13
View File
@@ -1,6 +1,15 @@
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
/* ── валидация URL картинки ────────────────────────────────────────────────
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
защита от 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,
};
@@ -0,0 +1,10 @@
-- 048_flashcard_images.sql
-- Картинки на флэш-карточках: отдельная сторона может нести изображение
-- (диаграмма, формула-скрин, график) в дополнение к тексту.
--
-- Храним ОТНОСИТЕЛЬНЫЙ URL загруженного файла (/uploads/flashcards/<hash>.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 '';
+27
View File
@@ -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);
+1
View File
@@ -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) => {
+183 -4
View File
@@ -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 @@
<div class="card-list" id="card-list"></div>
<!-- Add card row -->
<div class="card-add-bar" style="margin-bottom:14px">
<input class="card-add-input" id="new-card-front" placeholder="Лицевая сторона (вопрос)…" onkeydown="addCardOnEnter(event)" />
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…" onkeydown="addCardOnEnter(event)" />
<input class="card-add-input" id="new-card-front" placeholder="Лицевая сторона (вопрос)…"
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'front')" />
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…"
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'back')" />
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
</div>
<div id="new-card-imgs"></div>
<div style="display:flex;gap:10px;align-items:center">
<button class="fc-btn fc-btn-ghost" onclick="openEditDeckModal()"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>️ Редактировать колоду</button>
<button class="fc-btn fc-btn-danger" onclick="confirmDeleteDeck()">Удалить колоду</button>
@@ -371,10 +395,12 @@
<div class="study-card-inner" id="study-card">
<div class="study-face study-face-front">
<span class="study-face-label">Вопрос</span>
<img class="study-face-img" id="study-front-img" alt="" style="display:none" />
<div class="study-face-text" id="study-front-text"></div>
</div>
<div class="study-face study-face-back">
<span class="study-face-label">Ответ</span>
<img class="study-face-img" id="study-back-img" alt="" style="display:none" />
<div class="study-face-text" id="study-back-text"></div>
</div>
<span class="swipe-indicator swipe-right-ind" id="ind-right">ЗНАЮ <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
@@ -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() {
<div class="card-side">
<div class="card-side-lbl">Вопрос</div>
<textarea class="card-textarea" rows="2"
onpaste="onCardPaste(event,${c.id},'front')"
onchange="saveCard(${c.id},'front',this.value)">${esc(c.front)}</textarea>
${imgRowHtml(c, 'front')}
</div>
<div class="card-divider"></div>
<div class="card-side">
<div class="card-side-lbl">Ответ</div>
<textarea class="card-textarea" rows="2"
onpaste="onCardPaste(event,${c.id},'back')"
onchange="saveCard(${c.id},'back',this.value)">${esc(c.back)}</textarea>
${imgRowHtml(c, 'back')}
</div>
<div class="card-actions">
<button class="card-act-btn del" onclick="deleteCard(${c.id})" title="Удалить">
@@ -774,14 +805,17 @@ async function addCard() {
if (!_curDeck) return;
const front = document.getElementById('new-card-front').value.trim();
const back = document.getElementById('new-card-back').value.trim();
if (!front && !back) return;
if (!front && !back && !_newImg.front && !_newImg.back) return;
const card = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards`, {
method: 'POST', body: JSON.stringify({ front, back })
method: 'POST',
body: JSON.stringify({ front, back, front_image: _newImg.front, back_image: _newImg.back })
}).catch(()=>null);
if (!card) return;
_cards.push(card);
document.getElementById('new-card-front').value = '';
document.getElementById('new-card-back').value = '';
_newImg = { front: '', back: '' };
renderNewImgs();
document.getElementById('new-card-front').focus();
renderCardList();
}
@@ -806,6 +840,142 @@ async function deleteCard(id) {
renderCardList();
}
/* ════ Card images ════
Загрузка идёт ПРЯМЫМ fetch (multipart): LS.api всегда ставит
Content-Type: application/json, что ломает разбор FormData в multer. */
function imgRowHtml(c, side) {
const url = side === 'front' ? c.front_image : c.back_image;
if (url) {
return `<div class="card-img-row"><div class="card-img-wrap">
<img class="card-img-thumb" src="${esc(url)}" alt="" onclick="window.open('${esc(url)}','_blank')" />
<button class="card-img-remove" title="Убрать картинку" onclick="removeCardImage(${c.id},'${side}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div></div>`;
}
return `<div class="card-img-row">
<button class="card-img-add" onclick="pickCardImage(${c.id},'${side}')">
<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
Картинка
</button></div>`;
}
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]
? `<span class="card-img-wrap" style="display:inline-flex;align-items:center">
<span class="new-img-lbl">${label}</span>
<img class="card-img-thumb" style="max-height:64px" src="${esc(_newImg[side])}" alt="" />
<button class="card-img-remove" onclick="clearNewImg('${side}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</span>` : '';
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;
+67 -4
View File
@@ -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 ? '<span class="fc-pop-ctx" title="Текущая страница">' + esc(ctx) + '</span>' : '') +
'<button class="fc-pop-x" type="button" aria-label="Закрыть"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>' +
'</div>' +
'<label class="fc-lbl">Вопрос / лицевая сторона</label>' +
'<label class="fc-lbl">Вопрос / лицевая сторона <span class="fc-lbl-hint">картинку — Ctrl+V</span></label>' +
'<textarea id="fc-front" class="fc-ta" rows="2" placeholder="Что спросить…" maxlength="5000"></textarea>' +
'<label class="fc-lbl">Ответ / обратная сторона</label>' +
'<label class="fc-lbl">Ответ / обратная сторона <span class="fc-lbl-hint">картинку — Ctrl+V</span></label>' +
'<textarea id="fc-back" class="fc-ta" rows="2" placeholder="Что вспомнить…" maxlength="5000"></textarea>' +
'<div id="fc-imgs" class="fc-imgs"></div>' +
'<label class="fc-lbl">Колода</label>' +
'<select id="fc-deck" class="fc-sel"><option value="">Быстрые карточки</option></select>' +
'<div class="fc-pop-foot">' +
@@ -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]
? '<span class="fc-img-chip"><span class="fc-img-lbl">' + label + '</span>' +
'<img src="' + esc(_img[side]) + '" alt="" />' +
'<button type="button" class="fc-img-x" data-side="' + side + '" aria-label="Убрать">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 6l12 12M18 6L6 18"/></svg></button></span>'
: '';
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;