feat(prep): фронтенд мастер-флага ЦТ — папка-коллекция карточек + тумблер у учителя

- flashcards.html: колоды коллекции рендерятся сворачиваемой папкой «Подготовка к ЦТ»
  (deckCardHtml вынесен, секции <details> по collection; метки из LS.prepListTracks)
- classes.html: в таблице учеников колонка «ЦТ» с тумблером флага + кнопки «Весь класс → ЦТ»/
  «Снять ЦТ» (LS.prepClassStatus/prepSetStudent/prepUnsetStudent/prepSetClass)
Иконки — inline SVG, без эмодзи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-19 15:37:36 +03:00
parent 9509a67e25
commit 4aacb2d369
2 changed files with 148 additions and 41 deletions
+56 -2
View File
@@ -86,6 +86,14 @@
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
.pct-cell { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700; } .pct-cell { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700; }
.pct-hi { color: var(--green); } .pct-mid { color: var(--amber); } .pct-lo { color: var(--pink); } .pct-hi { color: var(--green); } .pct-mid { color: var(--amber); } .pct-lo { color: var(--pink); }
/* ── флаг «готовится к ЦТ» ── */
.prep-toggle { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 999px;
border: 1px solid var(--border); background: var(--surface); cursor: pointer; font-size: 0.74rem;
font-weight: 600; color: var(--text-3); transition: all .15s; }
.prep-toggle .ic { width: 13px; height: 13px; }
.prep-toggle:hover { border-color: var(--violet); color: var(--violet); }
.prep-toggle.on { background: rgba(123,97,255,.12); border-color: var(--violet); color: var(--violet); }
.prep-bulk { font-size: 0.78rem; }
/* ── Assignments ── */ /* ── Assignments ── */
.assign-list { display: flex; flex-direction: column; gap: 12px; } .assign-list { display: flex; flex-direction: column; gap: 12px; }
@@ -642,10 +650,13 @@
</div> </div>
<button class="btn-ghost" onclick="doAddMember()" id="btn-add-member" disabled>+ Добавить</button> <button class="btn-ghost" onclick="doAddMember()" id="btn-add-member" disabled>+ Добавить</button>
<span id="add-member-err" style="font-size:0.82rem;color:var(--pink)"></span> <span id="add-member-err" style="font-size:0.82rem;color:var(--pink)"></span>
<span style="flex:1"></span>
<button class="btn-ghost prep-bulk" onclick="bulkPrep(true)" title="Включить флаг «готовится к ЦТ» всем ученикам класса">Весь класс → ЦТ</button>
<button class="btn-ghost prep-bulk" onclick="bulkPrep(false)" title="Снять флаг «готовится к ЦТ» со всех учеников класса">Снять ЦТ</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th>Имя</th><th>Email</th><th>Тестов</th><th>Средний %</th><th>Вступил</th><th></th></tr></thead> <thead><tr><th>Имя</th><th>Email</th><th>Тестов</th><th>Средний %</th><th>Вступил</th><th title="Готовится к ЦТ — открывает карточки, курс и пробники ЦТ">ЦТ</th><th></th></tr></thead>
<tbody id="d-members"></tbody> <tbody id="d-members"></tbody>
</table> </table>
</div> </div>
@@ -1089,6 +1100,7 @@
document.getElementById('d-name').textContent = d.name; document.getElementById('d-name').textContent = d.name;
document.getElementById('d-sub').innerHTML = esc(d.description || '') + ' · Код: <strong style="color:var(--violet);letter-spacing:0.05em;user-select:all">' + esc(d.invite_code) + '</strong> <button onclick="navigator.clipboard.writeText(\'' + esc(d.invite_code) + '\');LS.toast(\'Код скопирован\',\'success\')" style="background:none;border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:0.72rem;cursor:pointer;color:var(--text-2);margin-left:4px" title="Копировать код"><svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>'; document.getElementById('d-sub').innerHTML = esc(d.description || '') + ' · Код: <strong style="color:var(--violet);letter-spacing:0.05em;user-select:all">' + esc(d.invite_code) + '</strong> <button onclick="navigator.clipboard.writeText(\'' + esc(d.invite_code) + '\');LS.toast(\'Код скопирован\',\'success\')" style="background:none;border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:0.72rem;cursor:pointer;color:var(--text-2);margin-left:4px" title="Копировать код"><svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>';
renderMembers(d.members); renderMembers(d.members);
loadPrep(d.id); // статус флага «готовится к ЦТ» → тумблеры в колонке ЦТ
renderAssignments(d.assignments, d.members.length); renderAssignments(d.assignments, d.members.length);
renderDashboard(d); renderDashboard(d);
} catch (e) { } catch (e) {
@@ -1096,10 +1108,20 @@
} }
} }
/* Флаг «готовится к ЦТ»: трек ct-math открывает ученику карточки + курс + пробники. */
const PREP_TRACK = 'ct-math';
let _prepStatus = {}; // { studentId: 0|1 } для текущего класса
function prepToggleHtml(id) {
const on = _prepStatus[id] === 1;
return `<button class="prep-toggle${on ? ' on' : ''}" onclick="togglePrep(${id})" title="${on ? 'Готовится к ЦТ — нажмите, чтобы снять' : 'Отметить «готовится к ЦТ»'}">
${on ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>ЦТ' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>нет'}</button>`;
}
function renderMembers(members) { function renderMembers(members) {
const tbody = document.getElementById('d-members'); const tbody = document.getElementById('d-members');
if (!members.length) { if (!members.length) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty">Нет учеников. Поделитесь кодом приглашения.</div></td></tr>'; tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Нет учеников. Поделитесь кодом приглашения.</div></td></tr>';
return; return;
} }
tbody.innerHTML = members.map(m => { tbody.innerHTML = members.map(m => {
@@ -1110,11 +1132,43 @@
<td>${m.tests_count}</td> <td>${m.tests_count}</td>
<td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td> <td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td>
<td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td> <td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td>
<td>${prepToggleHtml(m.id)}</td>
<td><button class="btn-danger" onclick="kickMember(${m.id},'${esc(m.name)}')">Удалить</button></td> <td><button class="btn-danger" onclick="kickMember(${m.id},'${esc(m.name)}')">Удалить</button></td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
/* Подгрузить статус флага для членов класса и перерисовать тумблеры. */
async function loadPrep(classId) {
try {
const r = await LS.prepClassStatus(classId, PREP_TRACK);
_prepStatus = {};
(r.students || []).forEach(s => { _prepStatus[s.id] = s.prep ? 1 : 0; });
if (currentClass && currentClass.members) renderMembers(currentClass.members);
} catch (_) { /* нет прав/трека — тумблеры останутся в «нет» */ }
}
async function togglePrep(studentId) {
const on = _prepStatus[studentId] === 1;
try {
if (on) await LS.prepUnsetStudent(studentId, PREP_TRACK);
else await LS.prepSetStudent(studentId, PREP_TRACK);
_prepStatus[studentId] = on ? 0 : 1;
if (currentClass && currentClass.members) renderMembers(currentClass.members);
LS.toast(on ? 'Снято' : 'Готовится к ЦТ', 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function bulkPrep(on) {
if (!currentClass) return;
if (!confirm(on ? 'Отметить ВСЕХ учеников класса как готовящихся к ЦТ?' : 'Снять флаг ЦТ со ВСЕХ учеников класса?')) return;
try {
await LS.prepSetClass(currentClass.id, PREP_TRACK, on);
await loadPrep(currentClass.id);
LS.toast('Готово', 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function renderAssignments(assignments, totalMembers) { function renderAssignments(assignments, totalMembers) {
_classAssignments = assignments; _classAssignments = assignments;
const el = document.getElementById('d-assignments'); const el = document.getElementById('d-assignments');
+68 -15
View File
@@ -116,6 +116,21 @@
.deck-add svg { transition: opacity .2s; opacity: .4; } .deck-add svg { transition: opacity .2s; opacity: .4; }
.deck-add:hover svg { opacity: .9; } .deck-add:hover svg { opacity: .9; }
/* ── коллекция-папка колод (напр. «Подготовка к ЦТ») ── */
.deck-collection { border: 1px solid var(--border); border-radius: 18px; padding: 2px 14px 16px;
background: rgba(123,97,255,.045); margin-top: 6px; }
.deck-coll-head { display: flex; align-items: center; gap: 10px; cursor: pointer; list-style: none;
padding: 13px 4px; font-weight: 700; color: var(--text); user-select: none; }
.deck-coll-head::-webkit-details-marker { display: none; }
.deck-coll-head > .ic { width: 19px; height: 19px; color: var(--violet); flex: none; }
.deck-coll-title { font-size: 1.03rem; }
.deck-coll-count { font-size: .82rem; font-weight: 600; color: #94a3b8; }
.deck-coll-chev { margin-left: auto; transition: transform .2s; opacity: .55; width: 18px; height: 18px; }
.deck-collection[open] .deck-coll-chev { transform: rotate(180deg); }
.deck-coll-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
@media (max-width: 760px) { .deck-coll-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } }
@media (max-width: 480px) { .deck-coll-grid { grid-template-columns: 1fr; } }
/* ── card list (deck detail) ── */ /* ── card list (deck detail) ── */
#view-cards { display: none; } #view-cards { display: none; }
.card-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 24px; } .card-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 24px; }
@@ -732,11 +747,17 @@ function bindStudyKeys() {
}); });
} }
let _collLabels = null; // { collectionKey: label } для заголовков папок-коллекций
async function loadDecks() { async function loadDecks() {
const [decks, stats] = await Promise.all([ const [decks, stats, tracks] = await Promise.all([
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})), LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
LS.api('/api/flashcards/stats').catch(()=>null), LS.api('/api/flashcards/stats').catch(()=>null),
_collLabels ? Promise.resolve(null) : LS.prepListTracks().catch(()=>null),
]); ]);
if (tracks && tracks.tracks) { // collection-ключ == ключ трека (1:1)
_collLabels = {};
tracks.tracks.forEach(t => { _collLabels[t.key] = t.label || t.title || t.key; });
}
_decks = decks.decks || []; _decks = decks.decks || [];
renderStats(stats); renderStats(stats);
renderDecks(); renderDecks();
@@ -769,18 +790,8 @@ function renderStats(s) {
} }
/* ════ Deck grid ════ */ /* ════ Deck grid ════ */
function renderDecks() { /* HTML одной карточки колоды (общий грид + папки-коллекции). */
const grid = document.getElementById('deck-grid'); function deckCardHtml(d) {
if (!_decks.length) {
grid.innerHTML = `<div class="fc-empty" style="grid-column:1/-1">
<div class="fc-empty-icon"><svg class="ic" style="width:46px;height:46px" viewBox="0 0 24 24"><rect x="3" y="5" width="13" height="15" rx="2"/><path d="M8 5V3.5A1.5 1.5 0 0 1 9.5 2h9A1.5 1.5 0 0 1 20 3.5V16a1.5 1.5 0 0 1-1.5 1.5H16"/></svg></div>
<h3>Нет колод</h3>
<p>Создайте первую колоду карточек</p>
<button class="fc-btn fc-btn-primary" onclick="openNewDeckModal()">+ Создать колоду</button>
</div>`;
return;
}
grid.innerHTML = _decks.map(d => {
const due = d.due_count; const due = d.due_count;
const color = d.color || '#9B5DE5'; const color = d.color || '#9B5DE5';
const letter = (d.title || '?')[0].toUpperCase(); const letter = (d.title || '?')[0].toUpperCase();
@@ -788,7 +799,7 @@ function renderDecks() {
const dueHtml = due > 0 const dueHtml = due > 0
? `<span class="deck-badge due"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>${due} к повторению</span>` ? `<span class="deck-badge due"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>${due} к повторению</span>`
: `<span class="deck-badge zero"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>Актуально</span>`; : `<span class="deck-badge zero"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>Актуально</span>`;
// Общая колода (назначена мне учителем): бейдж владельца, без карандаша правки. // Общая колода (назначена мне учителем/направлением): бейдж владельца, без карандаша правки.
const sharedHtml = d.shared const sharedHtml = d.shared
? `<span class="deck-badge shared" title="Колода от учителя"><svg class="ic" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${esc(d.owner_name || 'учитель')}</span>` ? `<span class="deck-badge shared" title="Колода от учителя"><svg class="ic" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${esc(d.owner_name || 'учитель')}</span>`
: ''; : '';
@@ -817,10 +828,52 @@ function renderDecks() {
</button> </button>
</div> </div>
</div>`; </div>`;
}).join('') + `<div class="deck-add" onclick="openNewDeckModal()"> }
function _plDecks(n) {
const a = n % 10, b = n % 100;
const w = (a === 1 && b !== 11) ? 'колода'
: (a >= 2 && a <= 4 && (b < 10 || b >= 20)) ? 'колоды' : 'колод';
return n + ' ' + w;
}
function renderDecks() {
const grid = document.getElementById('deck-grid');
if (!_decks.length) {
grid.innerHTML = `<div class="fc-empty" style="grid-column:1/-1">
<div class="fc-empty-icon"><svg class="ic" style="width:46px;height:46px" viewBox="0 0 24 24"><rect x="3" y="5" width="13" height="15" rx="2"/><path d="M8 5V3.5A1.5 1.5 0 0 1 9.5 2h9A1.5 1.5 0 0 1 20 3.5V16a1.5 1.5 0 0 1-1.5 1.5H16"/></svg></div>
<h3>Нет колод</h3>
<p>Создайте первую колоду карточек</p>
<button class="fc-btn fc-btn-primary" onclick="openNewDeckModal()">+ Создать колоду</button>
</div>`;
return;
}
// Колоды без коллекции — обычный грид; колоды коллекций — отдельными папками снизу.
const ungrouped = _decks.filter(d => !d.collection);
const byColl = {};
for (const d of _decks) if (d.collection) (byColl[d.collection] = byColl[d.collection] || []).push(d);
let html = ungrouped.map(deckCardHtml).join('') + `<div class="deck-add" onclick="openNewDeckModal()">
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg> <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
Новая колода Новая колода
</div>`; </div>`;
for (const coll of Object.keys(byColl)) {
const decks = byColl[coll];
const label = (_collLabels && _collLabels[coll]) || coll;
const totalDue = decks.reduce((s, d) => s + (d.due_count || 0), 0);
const sub = `${_plDecks(decks.length)}${totalDue ? ` · ${totalDue} к повторению` : ''}`;
html += `<details class="deck-collection" open style="grid-column:1/-1">
<summary class="deck-coll-head">
<svg class="ic" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span class="deck-coll-title">${esc(label)}</span>
<span class="deck-coll-count">${sub}</span>
<svg class="ic deck-coll-chev" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</summary>
<div class="deck-coll-grid">${decks.map(deckCardHtml).join('')}</div>
</details>`;
}
grid.innerHTML = html;
} }
/* ════ Open deck (card editor) ════ */ /* ════ Open deck (card editor) ════ */