'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 = '' + ''; 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 = '
' + 'Новая карточка' + (ctx ? '' + esc(ctx) + '' : '') + '' + '
' + '' + '' + '' + '' + '
' + '' + '' + '
' + 'Ctrl+Enter — сохранить' + '' + '
'; 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 = '' + (_decks || []).map(d => ``).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] ? '' + label + '' + '' + '' : ''; 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); } })();