3d627ce782
- Миграция 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>
276 lines
15 KiB
JavaScript
276 lines
15 KiB
JavaScript
'use strict';
|
|
/*
|
|
* flashcard-fab.js — глобальная плавающая кнопка «создать карточку».
|
|
* Доступна на любой странице (учебник, лаборатория, симуляция…): ученик
|
|
* мгновенно фиксирует то, что хочет запомнить, не уходя со страницы.
|
|
*
|
|
* Подключается лениво из sidebar.js (на всех страницах с шапкой).
|
|
* Карточка уходит в POST /api/flashcards/quick (колода по выбору или
|
|
* автоколода «Быстрые карточки»). Уважает фиче-флаг flashcards.
|
|
*/
|
|
(function () {
|
|
if (typeof LS === 'undefined' || !LS.isLoggedIn || !LS.isLoggedIn()) return;
|
|
if (document.getElementById('fc-fab')) return;
|
|
|
|
// Страницы, где FAB не нужен (полноэкранные / системные / сама страница карточек)
|
|
const EXCLUDE = ['/login', '/403', '/404', '/500', '/classroom', '/guest-board', '/flashcards'];
|
|
const path = location.pathname.replace(/\.html$/, '');
|
|
if (EXCLUDE.some(p => path === p || path.startsWith(p + '/'))) return;
|
|
|
|
let _decks = null;
|
|
let _open = false;
|
|
let _img = { front: '', back: '' }; // прикреплённые картинки (URL после загрузки)
|
|
|
|
const esc = (s) => (LS.esc ? LS.esc(s) : String(s == null ? '' : s));
|
|
|
|
// Гейт по фиче-флагу
|
|
(LS.loadFeatures ? LS.loadFeatures() : Promise.resolve({}))
|
|
.then(feats => { if (!feats || feats.flashcards !== false) inject(); })
|
|
.catch(() => inject());
|
|
|
|
function pageLabel() {
|
|
let t = (document.title || '').split(/[—·|]/)[0].trim();
|
|
return t && t.length < 48 ? t : '';
|
|
}
|
|
|
|
function inject() {
|
|
ensureStyles();
|
|
|
|
const fab = document.createElement('button');
|
|
fab.id = 'fc-fab'; fab.type = 'button';
|
|
fab.setAttribute('aria-label', 'Создать карточку');
|
|
fab.title = 'Создать карточку (запомнить)';
|
|
fab.innerHTML =
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
|
'<rect x="3" y="5" width="14" height="15" rx="2.5"/><path d="M20 8v9"/><path d="M10 9.5v6M7 12.5h6"/></svg>';
|
|
fab.addEventListener('click', (e) => { e.stopPropagation(); toggle(); });
|
|
document.body.appendChild(fab);
|
|
|
|
const ctx = pageLabel();
|
|
const pop = document.createElement('div');
|
|
pop.id = 'fc-pop';
|
|
pop.innerHTML =
|
|
'<div class="fc-pop-head">' +
|
|
'<span class="fc-pop-title">Новая карточка</span>' +
|
|
(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">Вопрос / лицевая сторона <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">Ответ / обратная сторона <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">' +
|
|
'<span class="fc-hint">Ctrl+Enter — сохранить</span>' +
|
|
'<button id="fc-save" class="fc-save" type="button">Сохранить</button>' +
|
|
'</div>';
|
|
document.body.appendChild(pop);
|
|
|
|
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(); }
|
|
});
|
|
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _open) close(); });
|
|
document.addEventListener('click', () => { if (_open) close(); });
|
|
}
|
|
|
|
function toggle() { _open ? close() : openPop(); }
|
|
|
|
async function openPop() {
|
|
_open = true;
|
|
document.getElementById('fc-pop').classList.add('fc-show');
|
|
document.getElementById('fc-fab').classList.add('fc-fab-on');
|
|
if (_decks === null) {
|
|
try { const r = await LS.api('/api/flashcards/decks'); _decks = r.decks || []; }
|
|
catch { _decks = []; }
|
|
fillDecks();
|
|
}
|
|
setTimeout(() => document.getElementById('fc-front')?.focus(), 70);
|
|
}
|
|
|
|
function close() {
|
|
_open = false;
|
|
document.getElementById('fc-pop')?.classList.remove('fc-show');
|
|
document.getElementById('fc-fab')?.classList.remove('fc-fab-on');
|
|
}
|
|
|
|
function fillDecks() {
|
|
const sel = document.getElementById('fc-deck');
|
|
if (!sel) return;
|
|
const cur = sel.value;
|
|
sel.innerHTML = '<option value="">Быстрые карточки</option>' +
|
|
(_decks || []).map(d => `<option value="${d.id}">${esc(d.title)}</option>`).join('');
|
|
if (cur) sel.value = cur;
|
|
}
|
|
|
|
async function save() {
|
|
const front = (document.getElementById('fc-front').value || '').trim();
|
|
const back = (document.getElementById('fc-back').value || '').trim();
|
|
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, 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 }));
|
|
document.getElementById('fc-front').focus();
|
|
} catch (e) {
|
|
LS.toast('Ошибка: ' + (e && e.message || 'не удалось сохранить'), 'error');
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = 'Сохранить';
|
|
}
|
|
}
|
|
|
|
/* ── картинки: загрузка прямым 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');
|
|
s.id = 'fc-fab-style';
|
|
s.textContent = `
|
|
#fc-fab {
|
|
position: fixed; right: 20px; bottom: 20px; z-index: 90;
|
|
width: 54px; height: 54px; border-radius: 50%; border: none; cursor: pointer;
|
|
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
|
|
color: #fff; display: grid; place-items: center;
|
|
box-shadow: 0 6px 22px rgba(155,93,229,0.45);
|
|
transition: transform .18s cubic-bezier(.34,1.4,.64,1), box-shadow .18s;
|
|
}
|
|
#fc-fab svg { width: 24px; height: 24px; }
|
|
#fc-fab:hover { transform: translateY(-2px) scale(1.05); box-shadow: 0 10px 30px rgba(155,93,229,0.55); }
|
|
#fc-fab:active { transform: scale(0.96); }
|
|
#fc-fab.fc-fab-on { transform: rotate(45deg); }
|
|
#fc-fab::after {
|
|
content: 'Запомнить'; position: absolute; right: 64px; top: 50%; transform: translateY(-50%);
|
|
background: rgba(15,23,42,0.9); color: #fff; padding: 5px 11px; border-radius: 8px;
|
|
font: 600 0.76rem 'Manrope', sans-serif; white-space: nowrap;
|
|
opacity: 0; pointer-events: none; transition: opacity .15s;
|
|
}
|
|
#fc-fab:hover::after { opacity: 1; }
|
|
#fc-fab.fc-fab-on::after { display: none; }
|
|
|
|
#fc-pop {
|
|
position: fixed; right: 20px; bottom: 86px; z-index: 91;
|
|
width: 320px; max-width: calc(100vw - 32px);
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.08); border-radius: 18px;
|
|
box-shadow: 0 18px 50px rgba(15,23,42,0.22);
|
|
padding: 16px; opacity: 0; transform: translateY(10px) scale(0.97);
|
|
transform-origin: bottom right; pointer-events: none;
|
|
transition: opacity .18s, transform .18s cubic-bezier(.34,1.3,.64,1);
|
|
font-family: 'Manrope', sans-serif;
|
|
}
|
|
#fc-pop.fc-show { opacity: 1; transform: none; pointer-events: auto; }
|
|
.fc-pop-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
|
.fc-pop-title { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800; color: #0F172A; }
|
|
.fc-pop-ctx {
|
|
font-size: 0.66rem; font-weight: 700; color: #7c3aed;
|
|
background: rgba(155,93,229,0.1); padding: 3px 8px; border-radius: 99px;
|
|
max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.fc-pop-x { margin-left: auto; width: 28px; height: 28px; border: none; background: rgba(15,23,42,0.05);
|
|
border-radius: 8px; cursor: pointer; color: #56687A; display: grid; place-items: center; }
|
|
.fc-pop-x svg { width: 15px; height: 15px; }
|
|
.fc-pop-x:hover { background: rgba(241,91,181,0.12); color: #db2777; }
|
|
.fc-lbl { display: block; font-size: 0.68rem; font-weight: 700; color: #56687A;
|
|
text-transform: uppercase; letter-spacing: 0.04em; margin: 8px 0 4px; }
|
|
.fc-ta, .fc-sel {
|
|
width: 100%; box-sizing: border-box; padding: 9px 12px;
|
|
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.86rem; color: #0F172A;
|
|
background: #fafbfc; resize: vertical; transition: border-color .15s;
|
|
}
|
|
.fc-ta:focus, .fc-sel:focus { outline: none; border-color: #9B5DE5; background: #fff; }
|
|
.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;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 700;
|
|
transition: filter .15s, transform .12s;
|
|
}
|
|
.fc-save:hover { filter: brightness(1.06); }
|
|
.fc-save:active { transform: translateY(1px); }
|
|
.fc-save:disabled { opacity: 0.6; cursor: default; }
|
|
@media (max-width: 560px) {
|
|
#fc-fab { width: 50px; height: 50px; right: 16px; bottom: 16px; }
|
|
#fc-pop { right: 12px; left: 12px; width: auto; bottom: 78px; }
|
|
#fc-fab::after { display: none; }
|
|
}
|
|
.app-layout.dark #fc-pop { background: #1A1D27; border-color: rgba(255,255,255,0.08); }
|
|
.app-layout.dark .fc-pop-title { color: #E8ECF2; }
|
|
.app-layout.dark .fc-ta, .app-layout.dark .fc-sel { background: #11141c; color: #E8ECF2; border-color: rgba(255,255,255,0.1); }
|
|
`;
|
|
document.head.appendChild(s);
|
|
}
|
|
})();
|