feat(my-materials): папки в виде рейла слева + drag-and-drop перемещение карточек

This commit is contained in:
Maxim Dolgolyov
2026-06-12 22:53:18 +03:00
parent 107ca2220c
commit 9d622454d6
+99 -32
View File
@@ -17,16 +17,29 @@
.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-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; }
@@ -57,6 +70,15 @@
.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>
@@ -71,18 +93,26 @@
<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 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 class="mm-cols" id="mm-cols"></div>
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
</div>
</main>
</div>
@@ -166,8 +196,8 @@
const mv = moveSelect(m);
if (m.kind === 'board' || m.kind === 'image') {
return `<div class="mm-card">
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.url)}" alt="" loading="lazy"/></a>
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>
@@ -183,7 +213,7 @@
}
if (m.kind === 'link') {
return `<div class="mm-card">
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">
@@ -205,7 +235,7 @@
}
// note
return `<div class="mm-card">
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}
@@ -216,22 +246,59 @@
</div>`;
}
/* ── Collections bar ── */
function chip(key, label, count, editId) {
/* ── Folder rail (вертикальный список папок слева) ── */
function railItem(key, label, count, editId, droppable) {
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>`;
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 = 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>`;
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;