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