Files
Learn_System/frontend/my-materials.html
T
Maxim Dolgolyov 786419ce01 feat(materials): серверные миниатюры (sharp) + пагинация рендера списка
Миниатюры: uploadPersonalFile генерирует webp ≤480px (sharp), возвращает {url, thumbUrl}; колонка thumb_url (мигр.074); грид рисует <img> на миниатюре, просмотр/скачивание/аннотация — полный url. Ссылочная чистка матчит url И thumb_url; share копирует thumb; квота учитывает файл+миниатюру. Сейверы board-clip/material-save/textbook-clip/draw пробрасывают thumbUrl.

Пагинация: клиент рендерит PAGE_SIZE=60 карточек + «Показать ещё» (сброс на смену фильтра), сохраняя клиентский поиск/сортировку над полным списком.

Тесты: materials.test.js 16→19. План V2 выполнен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:40:23 +03:00

779 lines
46 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"/>
<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; }
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.mm-tag { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 99px; background: rgba(6,182,212,0.12); color: #0891b2; cursor: pointer; transition: background .12s; }
.mm-tag:hover { background: rgba(6,182,212,0.24); }
.mm-src { color: var(--text-3); text-decoration: none; border-bottom: 1px dotted var(--text-3); }
.mm-src:hover { color: var(--violet); border-bottom-color: var(--violet); }
.mm-tagpill { display: inline-flex; align-items: center; gap: 4px; font-size: .76rem; font-weight: 600; padding: 6px 10px; border-radius: 9px; background: rgba(155,93,229,0.12); color: var(--violet); }
.mm-tagpill-x { display: inline-flex; cursor: pointer; }
.mm-tagpill-x svg { width: 13px; height: 13px; }
.mm-check { position: absolute; top: 10px; left: 10px; z-index: 3; width: 18px; height: 18px; cursor: pointer; accent-color: var(--violet); opacity: 0; transition: opacity .12s; }
.mm-card:hover .mm-check, .mm-check:checked { opacity: 1; }
.mm-card.mm-selected { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.35); }
.mm-bulk { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; padding: 10px 12px; border: 1px solid var(--violet); border-radius: 10px; background: rgba(155,93,229,0.06); }
.mm-bulk-count { font-weight: 700; font-size: .84rem; color: var(--violet); margin-right: auto; }
.mm-swatches { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.mm-swatch { width: 26px; height: 26px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); }
.mm-swatch.on { border-color: var(--text); }
.mm-swatch-none { background: repeating-linear-gradient(45deg,#fff,#fff 4px,#e2e8f0 4px,#e2e8f0 8px); }
.mm-preview { min-height: 22px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 8px; font-size: .86rem; color: var(--text-2); background: rgba(148,163,184,0.06); white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
.mm-preview:empty::before { content: 'Превью формул появится здесь…'; color: var(--text-3); }
.mm-more { display: flex; justify-content: center; padding: 8px 0 2px; }
@media (max-width: 768px) { .mm-check { opacity: .85; } }
@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>
<select class="mm-kind" id="mm-sort" onchange="onSort(this.value)" title="Сортировка">
<option value="new">Сначала новые</option>
<option value="old">Сначала старые</option>
<option value="title">По названию</option>
<option value="kind">По типу</option>
</select>
<span id="mm-tagfilter"></span>
</div>
<div id="mm-bulk" class="mm-bulk" style="display:none"></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;
}
/* Live formula preview for the note editor (renders $…$ as you type). */
function mmPreview(ta, prevId) { const p = document.getElementById(prevId); if (p) p.innerHTML = mathHtml(ta.value); }
window.mmPreview = mmPreview;
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 'Ссылка';
}
function parseTags(s) { return String(s || '').split(',').map(t => t.trim()).filter(Boolean); }
/* Only trust folder colors that look like a hex value (guards inline-style injection). */
function safeColor(c) { return /^#[0-9a-fA-F]{3,8}$/.test(String(c || '')) ? c : ''; }
/* Meta line: source title links back to the originating lesson when known. */
function metaHtml(m) {
const date = fmtDate(m.created_at);
let src = '';
if (m.source_title) {
src = m.source_session_id
? `<a class="mm-src" href="/my-lessons?session=${Number(m.source_session_id)}" title="Открыть исходный урок">${esc(m.source_title)}</a>`
: esc(m.source_title);
src += ' · ';
}
return src + esc(date);
}
/* Tag chips (click → filter). data-t carries the raw value, dodging JS-string injection. */
function tagsHtml(m) {
const tg = parseTags(m.tags);
if (!tg.length) return '';
return `<div class="mm-tags">${tg.map(t => `<span class="mm-tag" data-t="${esc(t)}" onclick="filterTag(this.dataset.t)">${esc(t)}</span>`).join('')}</div>`;
}
/* Lazy-load the full note body — the list endpoint returns only a 1000-char preview. */
async function ensureFullBody(m) {
if (!m || !m.body_trunc) return m;
try { const full = await LS.getMaterial(m.id); if (full && typeof full.body === 'string') { m.body = full.body; m.body_trunc = 0; } } catch (e) {}
return m;
}
let _mats = [];
let _cols = [];
const _filter = { col: 'all', kind: 'all', q: '', sort: 'new', tag: '' };
const _sel = new Set(); // ids selected for bulk actions
const PAGE_SIZE = 60; // cards rendered to the DOM at once ("Показать ещё" adds more)
let _shown = PAGE_SIZE;
/* ── 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 = metaHtml(m);
const tags = tagsHtml(m);
const selCls = _sel.has(m.id) ? ' mm-selected' : '';
const cb = `<input type="checkbox" class="mm-check" ${_sel.has(m.id) ? 'checked' : ''} onclick="toggleSel(event,${m.id})" title="Выбрать" />`;
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${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.thumb_url || m.url)}" alt="" loading="lazy" decoding="async" 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>${tags}
<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${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
<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>${tags}
<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${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
<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>${tags}
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
</div>
</div>`;
}
/* ── Folder rail (вертикальный список папок слева) ── */
function railItem(key, label, count, editId, droppable, color) {
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 tint = safeColor(color);
const icStyle = (tint && !active) ? ` style="color:${tint}"` : '';
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}"${icStyle}></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, c.color); });
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 sortRows(rows) {
const s = _filter.sort || 'new';
if (s === 'new') return rows; // server already returns newest-first
const a = rows.slice();
if (s === 'old') a.reverse();
else if (s === 'title') a.sort((x, y) => (x.title || x.body || '').localeCompare(y.title || y.body || '', 'ru'));
else if (s === 'kind') a.sort((x, y) => (x.kind || '').localeCompare(y.kind || ''));
return a;
}
function filtered() {
const rows = _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.tag && !parseTags(m.tags).includes(_filter.tag)) 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;
});
return sortRows(rows);
}
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();
if (!rows.length) {
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
lucide.createIcons(); renderBulk(); return;
}
let html = rows.slice(0, _shown).map(card).join('');
if (rows.length > _shown) {
html += `<div class="mm-more" style="grid-column:1/-1"><button class="mm-btn" onclick="showMore()"><i data-lucide="chevron-down"></i> Показать ещё (${rows.length - _shown})</button></div>`;
}
grid.innerHTML = html;
lucide.createIcons();
renderBulk();
}
function showMore() { _shown += PAGE_SIZE; renderGrid(); }
window.showMore = showMore;
async function load() {
try {
const data = await LS.listMaterials();
_mats = data.materials || [];
_cols = data.collections || [];
renderCols();
renderGrid();
renderTagFilter();
} catch (e) {
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
}
}
/* ── Filters ── */
function setCol(key) { _filter.col = key; _shown = PAGE_SIZE; renderCols(); renderGrid(); }
function onKind(v) { _filter.kind = v; _shown = PAGE_SIZE; renderGrid(); }
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); _shown = PAGE_SIZE; renderGrid(); }
function onSort(v) { _filter.sort = v; _shown = PAGE_SIZE; renderGrid(); }
function filterTag(t) { _filter.tag = String(t || ''); _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
function clearTag() { _filter.tag = ''; _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
function renderTagFilter() {
const el = document.getElementById('mm-tagfilter');
if (!el) return;
el.innerHTML = _filter.tag
? `<span class="mm-tagpill">#${esc(_filter.tag)} <span class="mm-tagpill-x" onclick="clearTag()" title="Сбросить фильтр по тегу"><i data-lucide="x"></i></span></span>`
: '';
if (window.lucide) lucide.createIcons();
}
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
window.onSort = onSort; window.filterTag = filterTag; window.clearTag = clearTag;
/* ── Multi-select + bulk actions (reuse per-item endpoints) ── */
function renderBulk() {
const bar = document.getElementById('mm-bulk');
if (!bar) return;
const n = _sel.size;
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
const opts = ['<option value="__none">Без папки</option>']
.concat(_cols.map(c => `<option value="${c.id}">${esc(c.name)}</option>`)).join('');
bar.style.display = 'flex';
bar.innerHTML = `<span class="mm-bulk-count">Выбрано: ${n}</span>
<select class="mm-move" onchange="bulkMove(this.value)" title="Переместить выбранные"><option value="">Переместить в…</option>${opts}</select>
<button class="mm-btn danger" onclick="bulkDelete()"><i data-lucide="trash-2"></i> Удалить</button>
<button class="mm-btn" onclick="clearSel()"><i data-lucide="x"></i> Снять</button>`;
if (window.lucide) lucide.createIcons();
}
function toggleSel(e, id) {
e.stopPropagation();
const cb = e.target;
if (cb.checked) _sel.add(id); else _sel.delete(id);
const cardEl = cb.closest('.mm-card');
if (cardEl) cardEl.classList.toggle('mm-selected', cb.checked);
renderBulk();
}
function clearSel() { _sel.clear(); renderGrid(); }
async function bulkMove(v) {
if (v === '') return;
const cid = v === '__none' ? null : Number(v);
const ids = [..._sel];
try {
for (const id of ids) await LS.updateMaterial(id, { collection_id: cid });
_sel.clear(); load(); LS.toast('Перемещено: ' + ids.length, 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function bulkDelete() {
const ids = [..._sel];
if (!ids.length) return;
if (!await LS.confirm(`Будет удалено материалов: ${ids.length}. Действие необратимо.`, { title: 'Удалить выбранные?', confirmText: 'Удалить' })) return;
try {
for (const id of ids) await LS.deleteMaterial(id);
_sel.clear(); load(); LS.toast('Удалено: ' + ids.length, 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
window.toggleSel = toggleSel; window.clearSel = clearSel; window.bulkMove = bulkMove; window.bulkDelete = bulkDelete;
/* ── 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" oninput="mmPreview(this,'mm-nt-prev')"></textarea>
<div id="mm-nt-prev" class="mm-preview"></div>
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
</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();
const tags = m.body.querySelector('#mm-nt-tags').value.trim() || null;
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, tags }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.createNote = createNote;
async function editMaterial(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
const isNote = mt.kind === 'note';
if (isNote) await ensureFullBody(mt);
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" oninput="mmPreview(this,'mm-ed-prev')">${esc(mt.body || '')}</textarea><div id="mm-ed-prev" class="mm-preview"></div>` : ''}
<input id="mm-ed-tags" value="${esc(mt.tags || '')}" placeholder="Теги через запятую" style="${FLD}" />
</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(),
tags: m.body.querySelector('#mm-ed-tags').value.trim() || null,
};
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'); }
} },
] });
if (isNote) { const ta = m.body.querySelector('#mm-ed-body'); if (ta) mmPreview(ta, 'mm-ed-prev'); }
}
window.editMaterial = editMaterial;
/* ── Просмотр материала в модалке (лайтбокс) ── */
async function openViewer(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return false;
if (mt.kind === 'note') await ensureFullBody(mt);
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 ── */
const COL_PALETTE = ['#9b5de5', '#06b6d4', '#f97316', '#10b981', '#ef4444', '#eab308', '#3b82f6', '#ec4899'];
function colorPalette(sel) {
sel = safeColor(sel);
return `<div style="font-size:.78rem;color:var(--text-3)">Цвет</div>
<div class="mm-swatches">
<span class="mm-swatch mm-swatch-none${!sel ? ' on' : ''}" data-c="" onclick="pickSwatch(this)" title="Без цвета"></span>
${COL_PALETTE.map(c => `<span class="mm-swatch${sel === c ? ' on' : ''}" data-c="${c}" style="background:${c}" onclick="pickSwatch(this)"></span>`).join('')}
</div>`;
}
function pickSwatch(el) { el.parentNode.querySelectorAll('.mm-swatch').forEach(s => s.classList.remove('on')); el.classList.add('on'); }
function pickedColor(body) { const on = body.querySelector('.mm-swatch.on'); return on ? (on.dataset.c || null) : null; }
window.pickSwatch = pickSwatch;
function createCollection() {
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />
${colorPalette(null)}
</div>`;
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, color: pickedColor(m.body) }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.createCollection = createCollection;
/* Reorder a folder up/down by normalizing sort_order to the new index order. */
async function moveCollection(id, dir) {
const arr = _cols.slice();
const i = arr.findIndex(c => c.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= arr.length) return;
[arr[i], arr[j]] = [arr[j], arr[i]];
try {
await Promise.all(arr.map((c, k) => c.sort_order !== k ? LS.updateMaterialCollection(c.id, { sortOrder: k }) : null).filter(Boolean));
load();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
window.moveCollection = moveCollection;
function editCollection(id) {
const col = _cols.find(c => c.id === id);
if (!col) return;
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />
${colorPalette(col.color)}
<div style="display:flex;gap:8px;margin-top:2px">
<button class="mm-btn" onclick="moveCollection(${id},-1)"><i data-lucide="arrow-up"></i> Выше</button>
<button class="mm-btn" onclick="moveCollection(${id},1)"><i data-lucide="arrow-down"></i> Ниже</button>
</div>
</div>`;
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, color: pickedColor(m.body) }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
if (window.lucide) lucide.createIcons();
}
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, thumbUrl: up.thumbUrl || null });
close(); load(); LS.toast('Изменения сохранены', 'success');
} else {
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, thumbUrl: up.thumbUrl || null, 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;
await ensureFullBody(mt);
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>