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' });
|
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 = ?`)
|
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
|
||||||
.get(deck.id)?.m ?? -1;
|
.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 inserted = [];
|
||||||
const ins = db.transaction(() => {
|
const ins = db.transaction(() => {
|
||||||
cards.forEach((c, i) => {
|
cards.forEach((c, i) => {
|
||||||
const r = stmt.run(deck.id, c.front || '', c.back || '', maxIdx + 1 + i);
|
const front = stripTags((c.front || '').slice(0, 5000));
|
||||||
inserted.push({ id: r.lastInsertRowid, front: c.front, back: c.back });
|
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();
|
ins();
|
||||||
|
|||||||
+125
-14
@@ -164,6 +164,19 @@
|
|||||||
#new-card-imgs { display: none; gap: 14px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
|
#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; }
|
.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-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;
|
.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;
|
font-family: 'Manrope', sans-serif; font-size: .88rem; background: #fff;
|
||||||
@@ -463,8 +476,11 @@
|
|||||||
<!-- ── Bulk add Modal ── -->
|
<!-- ── Bulk add Modal ── -->
|
||||||
<div class="fc-modal" id="modal-bulk">
|
<div class="fc-modal" id="modal-bulk">
|
||||||
<div class="fc-modal-bg" onclick="closeModal('modal-bulk')"></div>
|
<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-title">Добавить список карточек</div>
|
||||||
|
|
||||||
|
<!-- шаг 1: текст -->
|
||||||
|
<div id="bulk-step-text">
|
||||||
<div class="fc-modal-field">
|
<div class="fc-modal-field">
|
||||||
<div class="fc-modal-label">Формат: одна карточка = одна строка, вопрос и ответ разделены «—» или «|»</div>
|
<div class="fc-modal-label">Формат: одна карточка = одна строка, вопрос и ответ разделены «—» или «|»</div>
|
||||||
<textarea class="fc-modal-input" id="bulk-text" rows="10" style="resize:vertical"
|
<textarea class="fc-modal-input" id="bulk-text" rows="10" style="resize:vertical"
|
||||||
@@ -472,7 +488,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="fc-modal-actions">
|
<div class="fc-modal-actions">
|
||||||
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-bulk')">Отмена</button>
|
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-bulk')">Отмена</button>
|
||||||
<button class="fc-btn fc-btn-primary" onclick="saveBulk()">Добавить</button>
|
<button class="fc-btn fc-btn-primary" onclick="bulkToPreview()">Дальше →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- шаг 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -887,7 +914,7 @@ async function uploadFcImage(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _imgPickInput = null;
|
let _imgPickInput = null;
|
||||||
function pickCardImage(cardId, side) {
|
function ensureImgPicker() {
|
||||||
if (!_imgPickInput) {
|
if (!_imgPickInput) {
|
||||||
_imgPickInput = document.createElement('input');
|
_imgPickInput = document.createElement('input');
|
||||||
_imgPickInput.type = 'file';
|
_imgPickInput.type = 'file';
|
||||||
@@ -896,11 +923,15 @@ function pickCardImage(cardId, side) {
|
|||||||
document.body.appendChild(_imgPickInput);
|
document.body.appendChild(_imgPickInput);
|
||||||
}
|
}
|
||||||
_imgPickInput.value = '';
|
_imgPickInput.value = '';
|
||||||
_imgPickInput.onchange = () => {
|
return _imgPickInput;
|
||||||
const file = _imgPickInput.files && _imgPickInput.files[0];
|
}
|
||||||
|
function pickCardImage(cardId, side) {
|
||||||
|
const inp = ensureImgPicker();
|
||||||
|
inp.onchange = () => {
|
||||||
|
const file = inp.files && inp.files[0];
|
||||||
if (file) attachCardImage(cardId, side, file);
|
if (file) attachCardImage(cardId, side, file);
|
||||||
};
|
};
|
||||||
_imgPickInput.click();
|
inp.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attachCardImage(cardId, side, file) {
|
async function attachCardImage(cardId, side, file) {
|
||||||
@@ -987,27 +1018,107 @@ function renderNewImgs() {
|
|||||||
function clearNewImg(side) { _newImg[side] = ''; renderNewImgs(); }
|
function clearNewImg(side) { _newImg[side] = ''; renderNewImgs(); }
|
||||||
|
|
||||||
/* ════ Bulk add ════ */
|
/* ════ Bulk add ════ */
|
||||||
|
let _bulkCards = []; // [{ front, back, front_image, back_image }] на шаге предпросмотра
|
||||||
|
|
||||||
function openBulkModal() {
|
function openBulkModal() {
|
||||||
document.getElementById('bulk-text').value = '';
|
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');
|
document.getElementById('modal-bulk').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveBulk() {
|
/* шаг 1 → 2: разобрать строки в карточки */
|
||||||
|
function bulkToPreview() {
|
||||||
const text = document.getElementById('bulk-text').value.trim();
|
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 lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
const cards = lines.map(l => {
|
_bulkCards = lines.map(l => {
|
||||||
const sep = l.includes('—') ? '—' : '|';
|
const sep = l.includes('—') ? '—' : '|';
|
||||||
const [front, ...rest] = l.split(sep);
|
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);
|
}).filter(c => c.front);
|
||||||
if (!cards.length) return;
|
if (!_bulkCards.length) { LS.toast('Не удалось разобрать строки'); return; }
|
||||||
const result = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards/bulk`, {
|
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 })
|
method: 'POST', body: JSON.stringify({ cards })
|
||||||
}).catch(()=>null);
|
});
|
||||||
if (result?.inserted) {
|
} 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);
|
_cards.push(...result.inserted);
|
||||||
renderCardList();
|
renderCardList();
|
||||||
|
LS.toast(`Добавлено карточек: ${result.inserted.length}`, 'success');
|
||||||
}
|
}
|
||||||
closeModal('modal-bulk');
|
closeModal('modal-bulk');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user