diff --git a/frontend/classes.html b/frontend/classes.html
index 4e9cbfc..2b16add 100644
--- a/frontend/classes.html
+++ b/frontend/classes.html
@@ -86,6 +86,14 @@
tr:last-child td { border-bottom: none; }
.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); }
+ /* ── флаг «готовится к ЦТ» ── */
+ .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 ── */
.assign-list { display: flex; flex-direction: column; gap: 12px; }
@@ -642,10 +650,13 @@
+
+
+
- | Имя | Email | Тестов | Средний % | Вступил | |
+ | Имя | Email | Тестов | Средний % | Вступил | ЦТ | |
@@ -1089,6 +1100,7 @@
document.getElementById('d-name').textContent = d.name;
document.getElementById('d-sub').innerHTML = esc(d.description || '') + ' · Код: ' + esc(d.invite_code) + ' ';
renderMembers(d.members);
+ loadPrep(d.id); // статус флага «готовится к ЦТ» → тумблеры в колонке ЦТ
renderAssignments(d.assignments, d.members.length);
renderDashboard(d);
} 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 ``;
+ }
+
function renderMembers(members) {
const tbody = document.getElementById('d-members');
if (!members.length) {
- tbody.innerHTML = 'Нет учеников. Поделитесь кодом приглашения. |
';
+ tbody.innerHTML = 'Нет учеников. Поделитесь кодом приглашения. |
';
return;
}
tbody.innerHTML = members.map(m => {
@@ -1110,11 +1132,43 @@
${m.tests_count} |
${m.avg_pct!==null?m.avg_pct+'%':'—'} |
${fmtDate(m.joined_at)} |
+ ${prepToggleHtml(m.id)} |
|
`;
}).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) {
_classAssignments = assignments;
const el = document.getElementById('d-assignments');
diff --git a/frontend/flashcards.html b/frontend/flashcards.html
index 52b573d..5a6a32e 100644
--- a/frontend/flashcards.html
+++ b/frontend/flashcards.html
@@ -116,6 +116,21 @@
.deck-add svg { transition: opacity .2s; opacity: .4; }
.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) ── */
#view-cards { display: none; }
.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() {
- const [decks, stats] = await Promise.all([
+ const [decks, stats, tracks] = await Promise.all([
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
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 || [];
renderStats(stats);
renderDecks();
@@ -769,6 +790,53 @@ function renderStats(s) {
}
/* ════ Deck grid ════ */
+/* HTML одной карточки колоды (общий грид + папки-коллекции). */
+function deckCardHtml(d) {
+ const due = d.due_count;
+ const color = d.color || '#9B5DE5';
+ const letter = (d.title || '?')[0].toUpperCase();
+ const shadow = _hexAlpha(color, .22);
+ const dueHtml = due > 0
+ ? `${due} к повторению`
+ : `Актуально`;
+ // Общая колода (назначена мне учителем/направлением): бейдж владельца, без карандаша правки.
+ const sharedHtml = d.shared
+ ? `${esc(d.owner_name || 'учитель')}`
+ : '';
+ return `
+
+
${letter}
+
${d.card_count} карт.
+
+
+
${esc(d.title)}
+ ${d.description ? `
${esc(d.description)}
` : ''}
+
${dueHtml}${sharedHtml}
+
+
+
+
+
+
`;
+}
+
+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) {
@@ -780,47 +848,32 @@ function renderDecks() {
`;
return;
}
- grid.innerHTML = _decks.map(d => {
- const due = d.due_count;
- const color = d.color || '#9B5DE5';
- const letter = (d.title || '?')[0].toUpperCase();
- const shadow = _hexAlpha(color, .22);
- const dueHtml = due > 0
- ? `${due} к повторению`
- : `Актуально`;
- // Общая колода (назначена мне учителем): бейдж владельца, без карандаша правки.
- const sharedHtml = d.shared
- ? `${esc(d.owner_name || 'учитель')}`
- : '';
- return `
-
-
${letter}
-
${d.card_count} карт.
-
-
-
${esc(d.title)}
- ${d.description ? `
${esc(d.description)}
` : ''}
-
${dueHtml}${sharedHtml}
-
-
-
-
-
-
`;
- }).join('') + `
+ // Колоды без коллекции — обычный грид; колоды коллекций — отдельными папками снизу.
+ 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('') + `
`;
+
+ 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 += `
+
+
+ ${esc(label)}
+ ${sub}
+
+
+ ${decks.map(deckCardHtml).join('')}
+ `;
+ }
+ grid.innerHTML = html;
}
/* ════ Open deck (card editor) ════ */