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) ════ */