Files

584 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Мои материалы — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.mm-main { padding: 28px 24px; max-width: 1100px; margin: 0 auto; width: 100%; }
.mm-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.mm-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.5rem; color: var(--text); }
.mm-sub { color: var(--text-3); font-size: 0.9rem; margin-bottom: 16px; }
.mm-toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
.mm-search { flex: 1; min-width: 180px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 9px; font: inherit; background: var(--surface); color: var(--text); }
.mm-kind { padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; font: inherit; background: var(--surface); color: var(--text); }
/* Двухколоночная раскладка: рейл папок слева + контент справа */
.mm-body { display: flex; gap: 20px; align-items: flex-start; }
.mm-rail { width: 212px; flex-shrink: 0; position: sticky; top: 14px; display: flex; flex-direction: column; gap: 8px; }
.mm-rail-title { font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-3); padding: 0 10px; }
.mm-rail-list { display: flex; flex-direction: column; gap: 3px; }
.mm-rail-item { display: flex; align-items: center; gap: 9px; padding: 8px 10px; border-radius: 9px; cursor: pointer; font-size: .84rem; font-weight: 600; color: var(--text-2); border: 1px solid transparent; transition: background .12s, color .12s, border-color .12s; }
.mm-rail-item:hover { background: rgba(155,93,229,0.07); color: var(--violet); }
.mm-rail-item.active { background: var(--violet); color: #fff; }
.mm-rail-item.drop-hover { border-color: var(--violet); border-style: dashed; background: rgba(155,93,229,0.14); color: var(--violet); }
.mm-rail-item svg { width: 15px; height: 15px; flex-shrink: 0; }
.mm-rail-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mm-rail-count { font-size: .72rem; opacity: .65; }
.mm-rail-item.active .mm-rail-count { opacity: .85; }
.mm-rail-edit { display: inline-flex; opacity: 0; transition: opacity .12s; }
.mm-rail-item:hover .mm-rail-edit { opacity: .55; }
.mm-rail-edit:hover { opacity: 1 !important; }
.mm-rail-edit svg { width: 13px; height: 13px; }
.mm-rail-add { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 9px; background: transparent; cursor: pointer; font-size: .8rem; font-weight: 600; color: var(--text-2); transition: border-color .12s, color .12s; }
.mm-rail-add:hover { border-color: var(--violet); color: var(--violet); }
.mm-rail-add svg { width: 14px; height: 14px; }
.mm-content { flex: 1; min-width: 0; }
.mm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.mm-card.mm-dragging { opacity: .45; }
.mm-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; display: flex; flex-direction: column; position: relative; transition: border-color .14s, box-shadow .14s, transform .14s; }
.mm-card:hover { border-color: rgba(155,93,229,0.3); box-shadow: 0 8px 24px rgba(15,23,42,0.08); transform: translateY(-2px); }
.mm-card-media { background: #f1f5f9; aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.mm-card-media img { width: 100%; height: 100%; object-fit: contain; background: #fff; }
.mm-card-note { padding: 14px 16px; font-size: 0.84rem; color: var(--text-2); white-space: pre-wrap; word-break: break-word; max-height: 180px; overflow: auto; line-height: 1.55; flex: 1; }
.mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); }
.mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; }
.mm-card-meta { font-size: 0.74rem; color: var(--text-3); }
.mm-card-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
.mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; }
.mm-btn:hover { border-color: var(--violet); color: var(--violet); }
.mm-btn.danger:hover { border-color: #ef4444; color: #ef4444; }
.mm-btn svg { width: 13px; height: 13px; }
.mm-move { padding: 4px 6px; border: 1px solid var(--border); border-radius: 8px; font-size: .74rem; background: var(--surface); color: var(--text-2); max-width: 130px; margin-right: auto; }
.mm-kind-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 0.66rem; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: rgba(155,93,229,0.12); color: var(--violet); margin-bottom: 8px; }
.mm-kind-chip svg { width: 11px; height: 11px; }
.mm-card-link { display: flex; align-items: center; gap: 12px; padding: 18px 16px; background: linear-gradient(135deg, rgba(155,93,229,0.10), rgba(6,182,212,0.08)); text-decoration: none; }
.mm-card-link:hover { background: linear-gradient(135deg, rgba(155,93,229,0.16), rgba(6,182,212,0.12)); }
.mm-card-link-ic { width: 42px; height: 42px; border-radius: 12px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: #fff; color: var(--violet); box-shadow: 0 2px 6px rgba(155,93,229,0.18); }
.mm-card-link-ic svg { width: 20px; height: 20px; }
.mm-card-link-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mm-card-link-label { font-size: 0.82rem; font-weight: 700; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mm-card-link-url { font-size: 0.7rem; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mm-btn.primary { background: var(--violet); border-color: var(--violet); color: #fff; }
.mm-btn.primary:hover { background: #7c3aed; border-color: #7c3aed; color: #fff; }
.mm-viewer { display: flex; align-items: center; justify-content: center; background: #f1f5f9; border-radius: 10px; padding: 8px; }
.mm-viewer img { max-width: 100%; max-height: 68vh; object-fit: contain; border-radius: 6px; }
.mm-viewer-note { white-space: pre-wrap; word-break: break-word; line-height: 1.6; font-size: 0.9rem; color: var(--text-2); }
.mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); }
.mm-empty svg { width: 38px; height: 38px; opacity: 0.4; margin-bottom: 12px; }
@media (max-width: 768px) {
.mm-body { flex-direction: column; }
.mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; }
.mm-rail-title { display: none; }
.mm-rail-list { flex-direction: row; gap: 6px; }
.mm-rail-item { flex: 0 0 auto; }
.mm-rail-label { flex: 0 1 auto; max-width: 120px; }
.mm-rail-add { flex: 0 0 auto; white-space: nowrap; }
}
@media (max-width: 640px) { .mm-grid { grid-template-columns: 1fr 1fr; } .mm-main { padding: 18px 14px; } }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="mm-main">
<div class="mm-head">
<span class="mm-title">Мои материалы</span>
<button class="mm-btn" style="margin-left:auto" onclick="createNote()"><i data-lucide="plus"></i> Заметка</button>
<button class="mm-btn" onclick="openDrawModal({})"><i data-lucide="pen-tool"></i> Рисунок</button>
</div>
<div class="mm-sub">Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.</div>
<div class="mm-body">
<aside class="mm-rail">
<div class="mm-rail-title">Папки</div>
<div class="mm-rail-list" id="mm-cols"></div>
<button class="mm-rail-add" onclick="createCollection()"><i data-lucide="folder-plus"></i> Папка</button>
</aside>
<div class="mm-content">
<div class="mm-toolbar">
<input class="mm-search" id="mm-search" placeholder="Поиск по материалам…" oninput="onSearch(this.value)" />
<select class="mm-kind" id="mm-kind" onchange="onKind(this.value)">
<option value="all">Все типы</option>
<option value="board">Доска</option>
<option value="image">Изображения</option>
<option value="note">Заметки</option>
<option value="link">Ссылки</option>
</select>
</div>
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/svg-sanitize.js"></script>
<script src="/js/svg-draw.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
(function () {
const _ip = LS.initPage() || {};
const _canShare = !!(_ip.isTeacher || _ip.isAdmin);
function esc(s) { return LS.escapeHtml(String(s || '')); }
/* Рендер текста заметки с формулами KaTeX ($$…$$, $…$, \(…\), \[…\]).
textContent гарантирует экранирование не-математического текста (без XSS). */
const _MM_DELIMS = [
{ left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '\\(', right: '\\)', display: false },
{ left: '$', right: '$', display: false },
];
function mathHtml(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = String(text);
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: _MM_DELIMS, throwOnError: false }); } catch (e) {}
}
return tmp.innerHTML;
}
function fmtDate(s) {
if (!s) return '';
try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z'));
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' }); } catch (e) { return ''; }
}
const KIND_LABEL = { board: 'Доска', note: 'Заметка', link: 'Ссылка', image: 'Изображение' };
const KIND_ICON = { board: 'layout-template', note: 'sticky-note', link: 'link', image: 'image' };
const PENCIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>';
/* Human-readable label for a saved link (host for external, type for internal) */
function linkLabel(u) {
u = String(u || '');
if (/^https?:\/\//i.test(u)) { try { return new URL(u).host; } catch (e) { return u; } }
if (u.startsWith('/textbook')) return 'Раздел учебника';
if (u.startsWith('/course')) return 'Курс';
if (u.startsWith('/lesson') || u.startsWith('/online-lesson')) return 'Урок';
if (u.startsWith('/exam')) return 'Экзамен';
if (u.startsWith('/lab')) return 'Лаборатория';
return 'Ссылка';
}
let _mats = [];
let _cols = [];
const _filter = { col: 'all', kind: 'all', q: '' };
/* ── Move-to-collection select ── */
function moveSelect(m) {
const opts = ['<option value=""' + (!m.collection_id ? ' selected' : '') + '>Без папки</option>']
.concat(_cols.map(c => `<option value="${c.id}"${m.collection_id === c.id ? ' selected' : ''}>${esc(c.name)}</option>`));
return `<select class="mm-move" title="Папка" onchange="moveMaterial(${m.id}, this.value)">${opts.join('')}</select>`;
}
function card(m) {
const kind = KIND_LABEL[m.kind] || m.kind;
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
const chip = `<span class="mm-kind-chip"><i data-lucide="${KIND_ICON[m.kind] || 'tag'}"></i>${kind}</span>`;
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`;
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
const ann = (m.kind === 'board' || m.kind === 'image')
? `<button class="mm-btn" onclick="annotate(${m.id})" title="Аннотировать (рисовать поверх)"><i data-lucide="pencil-ruler"></i></button>` : '';
const fc = (m.kind === 'note')
? `<button class="mm-btn" onclick="toFlashcard(${m.id})" title="В флешкарты"><i data-lucide="copy"></i></button>` : '';
const sh = _canShare
? `<button class="mm-btn" onclick="openShareModal(${m.id})" title="Раздать ученикам"><i data-lucide="send"></i></button>` : '';
const mv = moveSelect(m);
if (m.kind === 'board' || m.kind === 'image') {
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.url)}" alt="" loading="lazy" draggable="false"/></a>
<div class="mm-card-body">
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
<div class="mm-card-actions">
${mv}
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
<a class="mm-btn" href="${esc(m.url)}" download title="Скачать"><i data-lucide="download"></i></a>
${ann}${sh}${edit}${del}
</div>
</div>
</div>`;
}
if (m.kind === 'link') {
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
<a class="mm-card-link" href="${esc(m.url)}" target="_blank" rel="noopener" title="${esc(m.url)}">
<span class="mm-card-link-ic"><i data-lucide="link"></i></span>
<span class="mm-card-link-meta">
<span class="mm-card-link-label">${esc(linkLabel(m.url))}</span>
<span class="mm-card-link-url">${esc(m.url)}</span>
</span>
</a>
<div class="mm-card-body">
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
<div class="mm-card-actions">
${mv}
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
${sh}${edit}${del}
</div>
</div>
</div>`;
}
// note
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
<div class="mm-card-note">${mathHtml(m.body || '')}</div>
<div class="mm-card-body">
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
</div>
</div>`;
}
/* ── Folder rail (вертикальный список папок слева) ── */
function railItem(key, label, count, editId, droppable) {
const active = _filter.col === key ? ' active' : '';
const ed = editId
? `<span class="mm-rail-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>`
: '';
const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder');
const drop = droppable
? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"`
: '';
return `<div class="mm-rail-item${active}" onclick="setCol('${key}')"${drop}>
<i data-lucide="${ic}"></i>
<span class="mm-rail-label">${esc(label)}</span>
<span class="mm-rail-count">${count}</span>${ed}
</div>`;
}
function renderCols() {
const bar = document.getElementById('mm-cols');
const noneCount = _mats.filter(m => !m.collection_id).length;
let html = railItem('all', 'Все', _mats.length, null, false);
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true); });
html += railItem('none', 'Без папки', noneCount, null, true);
bar.innerHTML = html;
}
/* ── Drag-and-drop: перетащить карточку на папку, чтобы переместить ── */
let _dragId = null;
function mmDragStart(e, id) {
_dragId = id;
try { e.dataTransfer.setData('text/plain', String(id)); e.dataTransfer.effectAllowed = 'move'; } catch (_) {}
if (e.currentTarget) e.currentTarget.classList.add('mm-dragging');
}
function mmDragEnd(e) {
_dragId = null;
if (e.currentTarget) e.currentTarget.classList.remove('mm-dragging');
document.querySelectorAll('.mm-rail-item.drop-hover').forEach(el => el.classList.remove('drop-hover'));
}
function mmDragOver(e, el) { e.preventDefault(); try { e.dataTransfer.dropEffect = 'move'; } catch (_) {} el.classList.add('drop-hover'); }
function mmDragLeave(el) { el.classList.remove('drop-hover'); }
function mmDrop(e, key) {
e.preventDefault();
if (e.currentTarget) e.currentTarget.classList.remove('drop-hover');
let id = _dragId;
if (id == null && e.dataTransfer) { const d = e.dataTransfer.getData('text/plain'); id = d ? Number(d) : null; }
if (id == null) return;
const cid = (key === 'none' || key === 'all') ? null : key;
const mt = _mats.find(x => x.id === id);
if (mt && String(mt.collection_id || '') === String(cid || '')) return; // уже в этой папке
moveMaterial(id, cid);
}
window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd;
window.mmDragOver = mmDragOver; window.mmDragLeave = mmDragLeave; window.mmDrop = mmDrop;
function filtered() {
return _mats.filter(m => {
if (_filter.col === 'none' && m.collection_id) return false;
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
if (_filter.q) {
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
if (!hay.includes(_filter.q)) return false;
}
return true;
});
}
function renderGrid() {
const grid = document.getElementById('mm-grid');
if (!_mats.length) {
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1">
<i data-lucide="folder-open"></i>
<p>Пока пусто. На уроке или в «Мои уроки» нажмите «К себе»/«Область», или создайте заметку.</p>
</div>`;
lucide.createIcons();
return;
}
const rows = filtered();
grid.innerHTML = rows.length
? rows.map(card).join('')
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
lucide.createIcons();
}
async function load() {
try {
const data = await LS.listMaterials();
_mats = data.materials || [];
_cols = data.collections || [];
renderCols();
renderGrid();
} catch (e) {
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
}
}
/* ── Filters ── */
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
function onKind(v) { _filter.kind = v; renderGrid(); }
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
/* ── Material actions ── */
async function moveMaterial(id, cid) {
try { await LS.updateMaterial(id, { collection_id: cid || null }); await load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
window.moveMaterial = moveMaterial;
async function delMaterial(id) {
if (!await LS.confirm('Этот материал будет удалён безвозвратно.', { title: 'Удалить материал?', confirmText: 'Удалить' })) return;
try { await LS.deleteMaterial(id); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
window.delMaterial = delMaterial;
const FLD = 'padding:9px 12px;border:1px solid var(--border);border-radius:9px;font:inherit;width:100%;box-sizing:border-box';
function createNote() {
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
</div>`;
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Создать', primary: true, onClick: async () => {
const title = m.body.querySelector('#mm-nt-title').value.trim();
const text = m.body.querySelector('#mm-nt-body').value.trim();
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.createNote = createNote;
function editMaterial(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
const isNote = mt.kind === 'note';
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
</div>`;
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Сохранить', primary: true, onClick: async () => {
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
try { await LS.updateMaterial(id, data); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.editMaterial = editMaterial;
/* ── Просмотр материала в модалке (лайтбокс) ── */
function openViewer(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return false;
const kind = KIND_LABEL[mt.kind] || mt.kind;
let body;
if (mt.kind === 'image' || mt.kind === 'board') {
body = `<div class="mm-viewer"><img src="${esc(mt.url)}" alt="${esc(mt.title || '')}" /></div>`;
} else if (mt.kind === 'note') {
body = `<div class="mm-viewer-note">${mathHtml(mt.body || '')}</div>`;
} else {
body = `<div class="mm-viewer-note"><a href="${esc(mt.url)}" target="_blank" rel="noopener" style="color:var(--violet)">${esc(mt.url)}</a></div>`;
}
const actions = [];
if (mt.url && (mt.kind === 'image' || mt.kind === 'board')) {
actions.push({ label: 'Скачать', onClick: () => {
const ext = (String(mt.url).match(/\.(png|jpe?g|gif|webp)(?:$|\?)/i) || [])[1] || 'png';
const name = (mt.title || 'material').slice(0, 60).replace(/[\\/:*?"<>|]/g, '_') + '.' + ext;
const a = document.createElement('a'); a.href = mt.url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
} });
}
if (mt.url) actions.push({ label: 'В новой вкладке', onClick: () => window.open(mt.url, '_blank', 'noopener') });
actions.push({ label: 'Закрыть', primary: true, onClick: () => m.close() });
const m = LS.modal({ title: mt.title || kind, content: body, size: 'lg', actions });
return false;
}
window.openViewer = openViewer;
/* ── Collection CRUD ── */
function createCollection() {
const content = `<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />`;
const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Создать', primary: true, onClick: async () => {
const name = m.body.querySelector('#mm-col-name').value.trim();
if (!name) { LS.toast('Введите название', 'warn'); return; }
try { await LS.createMaterialCollection({ name }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.createCollection = createCollection;
function editCollection(id) {
const col = _cols.find(c => c.id === id);
if (!col) return;
const content = `<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />`;
const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [
{ label: 'Удалить', onClick: async () => {
if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return;
try { await LS.deleteMaterialCollection(id); m.close(); if (_filter.col === String(id)) _filter.col = 'all'; load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Сохранить', primary: true, onClick: async () => {
const name = m.body.querySelector('#mm-col-name').value.trim();
if (!name) { LS.toast('Введите название', 'warn'); return; }
try { await LS.updateMaterialCollection(id, { name }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.editCollection = editCollection;
/* ── Рисование / аннотации (SVG-рисовалка) ── */
function ensureDrawStyles() {
if (document.getElementById('mmdraw-style')) return;
const s = document.createElement('style');
s.id = 'mmdraw-style';
s.textContent = `
.mmdraw-ov { position:fixed; inset:0; z-index:99999; background:rgba(15,12,30,.72); display:flex; align-items:center; justify-content:center; padding:16px; }
.mmdraw-box { background:#fff; border-radius:14px; max-width:94vw; max-height:94vh; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 20px 60px rgba(0,0,0,.4); }
.mmdraw-host { padding:10px; overflow:auto; }
.mmdraw-actions { display:flex; justify-content:flex-end; gap:8px; padding:12px 16px; border-top:1px solid #eee; }
.mmdraw-actions button { padding:8px 16px; border-radius:9px; border:1px solid #e2e8f0; background:#fff; font-weight:700; font-size:.85rem; cursor:pointer; color:#475569; }
.mmdraw-actions button.primary { background:#8b5cf6; border-color:#8b5cf6; color:#fff; }
`;
document.head.appendChild(s);
}
function openDrawModal(o) {
o = o || {};
if (!window.SvgDraw) { LS.toast('Редактор не загружен', 'error'); return; }
ensureDrawStyles();
const ov = document.createElement('div');
ov.className = 'mmdraw-ov';
ov.innerHTML = '<div class="mmdraw-box"><div class="mmdraw-host"></div><div class="mmdraw-actions"><button data-a="cancel">Отмена</button><button data-a="save" class="primary">Сохранить</button></div></div>';
document.body.appendChild(ov);
const host = ov.querySelector('.mmdraw-host');
const ed = SvgDraw.mount(host, { bgImage: o.bgImage || null, width: 800, height: 500, onChange: function () {} });
function close() { try { ed.destroy(); } catch (e) {} ov.remove(); }
ov.querySelector('[data-a="cancel"]').onclick = close;
ov.querySelector('[data-a="save"]').onclick = function () {
const btn = this; btn.disabled = true;
ed.exportFlatBlob(async function (blob) {
try {
if (!blob) throw new Error('Не удалось сохранить рисунок');
const fd = new FormData(); fd.append('file', blob, 'drawing.png');
const up = await LS.uploadMaterialFile(fd);
if (o.materialId) {
// Аннотация существующего материала — перезаписываем его, а не плодим копии
await LS.updateMaterial(o.materialId, { url: up.url });
close(); load(); LS.toast('Изменения сохранены', 'success');
} else {
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, sourceTitle: o.sourceTitle || null });
close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success');
}
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
});
};
}
window.openDrawModal = openDrawModal;
function annotate(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
openDrawModal({ materialId: mt.id, bgImage: mt.url, title: mt.title || 'Рисунок', sourceTitle: mt.source_title });
}
window.annotate = annotate;
/* ── Заметка → флешкарта ── */
async function toFlashcard(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
let decks = [];
try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {}
const opts = ['<option value="__new">+ Новая колода «Из материалов»</option>']
.concat(decks.map(d => `<option value="${d.id}">${esc(d.title)}</option>`)).join('');
const front = (mt.title || '').trim() || (mt.body || '').slice(0, 80);
const back = (mt.body || '').trim() || (mt.title || '');
const content = `<div style="display:flex;flex-direction:column;gap:8px">
<label style="font-size:.8rem;color:var(--text-3)">Колода</label>
<select id="fc-deck" style="${FLD}">${opts}</select>
<label style="font-size:.8rem;color:var(--text-3)">Вопрос (лицевая сторона)</label>
<input id="fc-front" value="${esc(front)}" style="${FLD}" />
<label style="font-size:.8rem;color:var(--text-3)">Ответ (оборот)</label>
<textarea id="fc-back" rows="4" style="${FLD};resize:vertical">${esc(back)}</textarea>
</div>`;
const m = LS.modal({ title: 'В флешкарты', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Создать карточку', primary: true, onClick: async () => {
try {
let deckId = m.body.querySelector('#fc-deck').value;
if (deckId === '__new') { const nd = await LS.fcCreateDeck({ title: 'Из материалов' }); deckId = nd.id; }
await LS.fcAddCard(deckId, { front: m.body.querySelector('#fc-front').value, back: m.body.querySelector('#fc-back').value });
m.close(); LS.toast('Карточка добавлена в флешкарты', 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.toFlashcard = toFlashcard;
/* ── Раздатка: учитель → класс (копия ученикам) ── */
async function openShareModal(id) {
let classes = [];
try { classes = await LS.getClasses(); } catch (e) {}
if (!Array.isArray(classes) || !classes.length) { LS.toast('Нет классов для раздачи', 'warn'); return; }
const opts = classes.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
const content = `<div style="display:flex;flex-direction:column;gap:8px">
<label style="font-size:.8rem;color:var(--text-3)">Класс</label>
<select id="sh-class" style="${FLD}">${opts}</select>
<div style="font-size:.78rem;color:var(--text-3)">Копия материала появится у всех учеников класса (с уведомлением).</div>
</div>`;
const m = LS.modal({ title: 'Раздать материал', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Раздать', primary: true, onClick: async () => {
try {
const r = await LS.shareMaterial(id, { classId: Number(m.body.querySelector('#sh-class').value) });
m.close(); LS.toast('Отправлено ученикам: ' + (r.sent || 0), 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.openShareModal = openShareModal;
load();
})();
</script>
</body>
</html>