feat(flashcards): картинки в массовом импорте «Добавить список»
- модалка в 2 шага: текст -> предпросмотр карточек, к каждой стороне можно прикрепить картинку перед импортом - addCardsBulk принимает front_image/back_image (через safeImg) и теперь санитизит front/back (stripTags) — раньше bulk пропускал теги - общий ensureImgPicker() переиспользуется редактором и предпросмотром Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -125,12 +125,16 @@ function addCardsBulk(req, res) {
|
||||
if (!Array.isArray(cards) || !cards.length) return res.status(400).json({ error: 'cards[] required' });
|
||||
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
|
||||
.get(deck.id)?.m ?? -1;
|
||||
const stmt = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`);
|
||||
const stmt = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`);
|
||||
const inserted = [];
|
||||
const ins = db.transaction(() => {
|
||||
cards.forEach((c, i) => {
|
||||
const r = stmt.run(deck.id, c.front || '', c.back || '', maxIdx + 1 + i);
|
||||
inserted.push({ id: r.lastInsertRowid, front: c.front, back: c.back });
|
||||
const front = stripTags((c.front || '').slice(0, 5000));
|
||||
const back = stripTags((c.back || '').slice(0, 5000));
|
||||
const fImg = safeImg(c.front_image);
|
||||
const bImg = safeImg(c.back_image);
|
||||
const r = stmt.run(deck.id, front, back, fImg, bImg, maxIdx + 1 + i);
|
||||
inserted.push({ id: r.lastInsertRowid, front, back, front_image: fImg, back_image: bImg });
|
||||
});
|
||||
});
|
||||
ins();
|
||||
|
||||
+132
-21
@@ -164,6 +164,19 @@
|
||||
#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; }
|
||||
|
||||
/* bulk import preview */
|
||||
.bulk-preview-list { max-height: 56vh; overflow-y: auto; display: flex; flex-direction: column;
|
||||
gap: 8px; margin-bottom: 6px; padding-right: 4px; }
|
||||
.bulk-row { display: grid; grid-template-columns: 24px 1fr 1fr; gap: 8px; align-items: start;
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; }
|
||||
.bulk-row-n { font-size: .7rem; font-weight: 800; color: var(--text-3); padding-top: 3px; }
|
||||
.bulk-row-side { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.bulk-row-txt { font-size: .8rem; color: var(--text); white-space: pre-wrap; word-break: break-word; line-height: 1.35; }
|
||||
.bulk-row-empty { color: var(--text-3); font-style: italic; }
|
||||
.bulk-img-wrap { position: relative; display: inline-block; }
|
||||
.bulk-img-thumb { max-width: 110px; max-height: 64px; border-radius: 6px; display: block;
|
||||
border: 1px solid var(--border); object-fit: cover; }
|
||||
|
||||
.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;
|
||||
@@ -463,16 +476,30 @@
|
||||
<!-- ── Bulk add Modal ── -->
|
||||
<div class="fc-modal" id="modal-bulk">
|
||||
<div class="fc-modal-bg" onclick="closeModal('modal-bulk')"></div>
|
||||
<div class="fc-modal-box">
|
||||
<div class="fc-modal-box" style="max-width:580px">
|
||||
<div class="fc-modal-title">Добавить список карточек</div>
|
||||
<div class="fc-modal-field">
|
||||
<div class="fc-modal-label">Формат: одна карточка = одна строка, вопрос и ответ разделены «—» или «|»</div>
|
||||
<textarea class="fc-modal-input" id="bulk-text" rows="10" style="resize:vertical"
|
||||
placeholder="Митохондрия — органелл клетки, производит АТФ Ядро | содержит ДНК Рибосома — синтез белка"></textarea>
|
||||
|
||||
<!-- шаг 1: текст -->
|
||||
<div id="bulk-step-text">
|
||||
<div class="fc-modal-field">
|
||||
<div class="fc-modal-label">Формат: одна карточка = одна строка, вопрос и ответ разделены «—» или «|»</div>
|
||||
<textarea class="fc-modal-input" id="bulk-text" rows="10" style="resize:vertical"
|
||||
placeholder="Митохондрия — органелл клетки, производит АТФ Ядро | содержит ДНК Рибосома — синтез белка"></textarea>
|
||||
</div>
|
||||
<div class="fc-modal-actions">
|
||||
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-bulk')">Отмена</button>
|
||||
<button class="fc-btn fc-btn-primary" onclick="bulkToPreview()">Дальше →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-modal-actions">
|
||||
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-bulk')">Отмена</button>
|
||||
<button class="fc-btn fc-btn-primary" onclick="saveBulk()">Добавить</button>
|
||||
|
||||
<!-- шаг 2: предпросмотр с картинками -->
|
||||
<div id="bulk-step-preview" style="display:none">
|
||||
<div class="fc-modal-label" style="margin-bottom:10px">Проверьте карточки и при желании прикрепите картинку к стороне</div>
|
||||
<div class="bulk-preview-list" id="bulk-preview-list"></div>
|
||||
<div class="fc-modal-actions">
|
||||
<button class="fc-btn fc-btn-ghost" onclick="bulkBackToText()">← Назад</button>
|
||||
<button class="fc-btn fc-btn-primary" id="bulk-import-btn" onclick="saveBulk()">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -887,7 +914,7 @@ async function uploadFcImage(file) {
|
||||
}
|
||||
|
||||
let _imgPickInput = null;
|
||||
function pickCardImage(cardId, side) {
|
||||
function ensureImgPicker() {
|
||||
if (!_imgPickInput) {
|
||||
_imgPickInput = document.createElement('input');
|
||||
_imgPickInput.type = 'file';
|
||||
@@ -896,11 +923,15 @@ function pickCardImage(cardId, side) {
|
||||
document.body.appendChild(_imgPickInput);
|
||||
}
|
||||
_imgPickInput.value = '';
|
||||
_imgPickInput.onchange = () => {
|
||||
const file = _imgPickInput.files && _imgPickInput.files[0];
|
||||
return _imgPickInput;
|
||||
}
|
||||
function pickCardImage(cardId, side) {
|
||||
const inp = ensureImgPicker();
|
||||
inp.onchange = () => {
|
||||
const file = inp.files && inp.files[0];
|
||||
if (file) attachCardImage(cardId, side, file);
|
||||
};
|
||||
_imgPickInput.click();
|
||||
inp.click();
|
||||
}
|
||||
|
||||
async function attachCardImage(cardId, side, file) {
|
||||
@@ -987,27 +1018,107 @@ function renderNewImgs() {
|
||||
function clearNewImg(side) { _newImg[side] = ''; renderNewImgs(); }
|
||||
|
||||
/* ════ Bulk add ════ */
|
||||
let _bulkCards = []; // [{ front, back, front_image, back_image }] на шаге предпросмотра
|
||||
|
||||
function openBulkModal() {
|
||||
document.getElementById('bulk-text').value = '';
|
||||
_bulkCards = [];
|
||||
document.getElementById('bulk-step-text').style.display = '';
|
||||
document.getElementById('bulk-step-preview').style.display = 'none';
|
||||
document.getElementById('modal-bulk').classList.add('open');
|
||||
}
|
||||
|
||||
async function saveBulk() {
|
||||
/* шаг 1 → 2: разобрать строки в карточки */
|
||||
function bulkToPreview() {
|
||||
const text = document.getElementById('bulk-text').value.trim();
|
||||
if (!text || !_curDeck) return;
|
||||
if (!text) { LS.toast('Введите хотя бы одну строку'); return; }
|
||||
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
const cards = lines.map(l => {
|
||||
_bulkCards = lines.map(l => {
|
||||
const sep = l.includes('—') ? '—' : '|';
|
||||
const [front, ...rest] = l.split(sep);
|
||||
return { front: (front||'').trim(), back: rest.join(sep).trim() };
|
||||
return { front: (front || '').trim(), back: rest.join(sep).trim(), front_image: '', back_image: '' };
|
||||
}).filter(c => c.front);
|
||||
if (!cards.length) return;
|
||||
const result = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards/bulk`, {
|
||||
method: 'POST', body: JSON.stringify({ cards })
|
||||
}).catch(()=>null);
|
||||
if (result?.inserted) {
|
||||
if (!_bulkCards.length) { LS.toast('Не удалось разобрать строки'); return; }
|
||||
document.getElementById('bulk-step-text').style.display = 'none';
|
||||
document.getElementById('bulk-step-preview').style.display = '';
|
||||
renderBulkPreview();
|
||||
}
|
||||
|
||||
function bulkBackToText() {
|
||||
document.getElementById('bulk-step-preview').style.display = 'none';
|
||||
document.getElementById('bulk-step-text').style.display = '';
|
||||
}
|
||||
|
||||
function renderBulkPreview() {
|
||||
const list = document.getElementById('bulk-preview-list');
|
||||
document.getElementById('bulk-import-btn').textContent = `Добавить ${_bulkCards.length}`;
|
||||
list.innerHTML = _bulkCards.map((c, i) => `
|
||||
<div class="bulk-row">
|
||||
<div class="bulk-row-n">${i + 1}</div>
|
||||
<div class="bulk-row-side">
|
||||
<div class="bulk-row-txt">${c.front ? esc(c.front) : '<span class="bulk-row-empty">— нет текста —</span>'}</div>
|
||||
${bulkImgCell(i, 'front')}
|
||||
</div>
|
||||
<div class="bulk-row-side">
|
||||
<div class="bulk-row-txt">${c.back ? esc(c.back) : '<span class="bulk-row-empty">— нет ответа —</span>'}</div>
|
||||
${bulkImgCell(i, 'back')}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function bulkImgCell(i, side) {
|
||||
const url = _bulkCards[i][side === 'front' ? 'front_image' : 'back_image'];
|
||||
if (url) {
|
||||
return `<div class="bulk-img-wrap">
|
||||
<img class="bulk-img-thumb" src="${esc(url)}" alt="" />
|
||||
<button class="card-img-remove" title="Убрать" onclick="bulkRemoveImg(${i},'${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>`;
|
||||
}
|
||||
return `<button class="card-img-add" onclick="bulkPickImg(${i},'${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>`;
|
||||
}
|
||||
|
||||
function bulkPickImg(i, side) {
|
||||
const inp = ensureImgPicker();
|
||||
inp.onchange = async () => {
|
||||
const file = inp.files && inp.files[0];
|
||||
if (!file) return;
|
||||
try { _bulkCards[i][side === 'front' ? 'front_image' : 'back_image'] = await uploadFcImage(file); renderBulkPreview(); }
|
||||
catch (e) { LS.toast(e.message || 'Ошибка загрузки', 'error'); }
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
|
||||
function bulkRemoveImg(i, side) {
|
||||
_bulkCards[i][side === 'front' ? 'front_image' : 'back_image'] = '';
|
||||
renderBulkPreview();
|
||||
}
|
||||
|
||||
async function saveBulk() {
|
||||
if (!_curDeck || !_bulkCards.length) { closeModal('modal-bulk'); return; }
|
||||
const cards = _bulkCards.filter(c => c.front || c.back || c.front_image || c.back_image);
|
||||
if (!cards.length) { closeModal('modal-bulk'); return; }
|
||||
const btn = document.getElementById('bulk-import-btn');
|
||||
btn.disabled = true; btn.textContent = 'Добавляю…';
|
||||
let result;
|
||||
try {
|
||||
result = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards/bulk`, {
|
||||
method: 'POST', body: JSON.stringify({ cards })
|
||||
});
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка импорта: ' + (e && e.message || 'не удалось'), 'error');
|
||||
btn.disabled = false; btn.textContent = `Добавить ${_bulkCards.length}`;
|
||||
return;
|
||||
}
|
||||
btn.disabled = false;
|
||||
if (result && result.inserted) {
|
||||
_cards.push(...result.inserted);
|
||||
renderCardList();
|
||||
LS.toast(`Добавлено карточек: ${result.inserted.length}`, 'success');
|
||||
}
|
||||
closeModal('modal-bulk');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user