Files
Learn_System/frontend/my-materials.html
T
Maxim Dolgolyov 785f85e1ef fix(materials): не падать из-за глобального esc (api.js) — обернул inline-скрипт в IIFE
js/api.js объявляет глобальный `const esc`, а инлайн-скрипт my-materials объявлял `function esc`
→ «Identifier esc has already been declared», из-за чего весь скрипт страницы не выполнялся.
Обернул инлайн-скрипт в IIFE (esc и прочее локальны; обработчики экспортируются через window.*).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:33:19 +03:00

420 lines
24 KiB
HTML
Raw 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"/>
<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-cols { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; }
.mm-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid var(--border); border-radius: 99px; background: var(--surface); cursor: pointer; font-size: .8rem; font-weight: 600; color: var(--text-2); }
.mm-chip:hover { border-color: var(--violet); color: var(--violet); }
.mm-chip.active { background: var(--violet); border-color: var(--violet); color: #fff; }
.mm-chip-count { font-size: .7rem; opacity: .7; }
.mm-chip-edit { display: inline-flex; opacity: .65; margin-left: 2px; }
.mm-chip-edit svg { width: 12px; height: 12px; }
.mm-chip-edit:hover { opacity: 1; }
.mm-chip-add { border-style: dashed; }
.mm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.mm-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; display: flex; flex-direction: column; position: relative; }
.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-badge { position: absolute; top: 8px; left: 8px; font-size: 0.68rem; font-weight: 700; padding: 3px 8px; border-radius: 99px; background: rgba(155,93,229,0.12); color: var(--violet); }
.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: 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-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-cols" id="mm-cols"></div>
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</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>
(function () {
const _ip = LS.initPage() || {};
const _canShare = !!(_ip.isTeacher || _ip.isAdmin);
function esc(s) { return LS.escapeHtml(String(s || '')); }
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 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>';
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 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">
<span class="mm-kind-badge">${kind}</span>
<a class="mm-card-media" href="${esc(m.url)}" target="_blank" rel="noopener"><img src="${esc(m.url)}" alt="" loading="lazy"/></a>
<div class="mm-card-body">
<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" href="${esc(m.url)}" target="_blank" rel="noopener" title="Открыть"><i data-lucide="external-link"></i></a>
<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">
<span class="mm-kind-badge">${kind}</span>
<div class="mm-card-note"><a href="${esc(m.url)}" target="_blank" rel="noopener" style="color:var(--violet)">${esc(m.url)}</a></div>
<div class="mm-card-body">
<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>`;
}
// note
return `<div class="mm-card">
<span class="mm-kind-badge">${kind}</span>
<div class="mm-card-note">${esc(m.body || '')}</div>
<div class="mm-card-body">
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
<div class="mm-card-actions">${mv}${edit}${del}</div>
</div>
</div>`;
}
/* ── Collections bar ── */
function chip(key, label, count, editId) {
const active = _filter.col === key ? ' active' : '';
const ed = editId ? `<span class="mm-chip-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>` : '';
return `<button class="mm-chip${active}" onclick="setCol('${key}')">${esc(label)} <span class="mm-chip-count">${count}</span>${ed}</button>`;
}
function renderCols() {
const bar = document.getElementById('mm-cols');
const noneCount = _mats.filter(m => !m.collection_id).length;
let html = chip('all', 'Все', _mats.length);
_cols.forEach(c => { html += chip(String(c.id), c.name, c.count, c.id); });
if (noneCount) html += chip('none', 'Без папки', noneCount);
html += `<button class="mm-chip mm-chip-add" onclick="createCollection()">+ папка</button>`;
bar.innerHTML = html;
}
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 (!confirm('Удалить этот материал?')) 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;
/* ── 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 (!confirm('Удалить папку? Материалы останутся (станут «Без папки»).')) 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.uploadFile(fd);
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: LS.downloadFileUrl(up.id), 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({ 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>