feat(flashcards): общие колоды — учитель назначает колоду классу/ученику

Учитель делится своей колодой с классом или конкретными учениками; карты общие
(одна копия), а прогресс у каждого свой — flashcard_reviews уже keyed по
user_id+card_id, поэтому ученик копит собственные интервалы на тех же картах.

- миграция 075: flashcard_deck_access (deck_id, type class|user, target_id) —
  зеркало folder_access; индексы по target и deck.
- deckAccess(): владелец/админ (canEdit) либо назначенный напрямую/через класс
  (canRead). listDecks отдаёт свои + назначенные (shared/can_edit/owner_name);
  getCards/getStudySession/submitReview пускают по canRead (ученик учится и
  ставит отзыв), правка карт/колоды — только владелец.
- share API (owner + роль teacher/admin): GET /shares, POST /share, DELETE
  /share?type=&target_id=; цель валидируется (свой класс / свой ученик).
- фронт: общие колоды с бейджем учителя, открываются read-only (CSS .readonly
  прячет ручки/удаление/правку, drag и inline-edit выключены), кнопка
  «Поделиться» с модалкой (вкладки Классы/Ученики, тоггл = add/remove share).
- тест flashcards-share 13/13 (шаринг класс/ученик, видимость, изучение+отзыв,
  правка 404, доступ 404, роль-гейт 403, чужой класс 403, снятие доступа).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:30:53 +03:00
parent f26b522207
commit 9bd40c5d1c
5 changed files with 484 additions and 44 deletions
+178 -8
View File
@@ -375,6 +375,46 @@
.study-face { padding: 20px 14px; }
.sq-btn { padding: 8px 14px; font-size: .78rem; flex: 1; justify-content: center; }
}
/* ── shared decks (назначенные учителем) ── */
.deck-badge.shared { background: rgba(6,214,224,.14); color: #0891b2; max-width: 100%; }
.deck-badge.shared .ic { width: 11px; height: 11px; }
.deck-card.shared { border-color: rgba(6,214,224,.4); }
/* ── read-only режим списка карточек (общая колода) ── */
#card-list.readonly .card-drag,
#card-list.readonly .card-actions,
#card-list.readonly .fx-mini,
#card-list.readonly .card-img-add,
#card-list.readonly .card-img-remove { display: none !important; }
#card-list.readonly .card-display { cursor: default; }
#card-list.readonly .card-display:hover { background: transparent; }
/* ── share modal ── */
.share-sub { font-size: .82rem; color: var(--text-3); margin: -8px 0 16px; line-height: 1.5; }
.share-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.share-tab { flex: 1; padding: 9px; border: 1.5px solid var(--border); border-radius: 10px; background: #fff;
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
color: var(--text-2); transition: .15s; }
.share-tab.active { border-color: var(--violet); background: rgba(155,93,229,.08); color: var(--violet); }
.share-list { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;
margin-bottom: 6px; padding-right: 4px; }
.share-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
border: 1.5px solid var(--border); border-radius: 12px; background: var(--surface-2);
cursor: pointer; transition: .15s; }
.share-row:hover { border-color: var(--violet); }
.share-row.on { border-color: var(--violet); background: rgba(155,93,229,.07); }
.share-row-name { flex: 1; font-size: .88rem; font-weight: 600; color: var(--text); min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share-row-sub { font-size: .72rem; color: var(--text-3); font-weight: 500; }
.share-toggle { width: 40px; height: 22px; border-radius: 99px; background: var(--border); position: relative;
flex-shrink: 0; transition: background .18s; }
.share-row.on .share-toggle { background: var(--violet); }
.share-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
border-radius: 50%; background: #fff; transition: transform .18s; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
.share-row.on .share-toggle::after { transform: translateX(18px); }
.share-empty { text-align: center; padding: 28px 12px; color: var(--text-3); font-size: .84rem; }
.app-layout.dark .share-tab { background: #1A1D27; }
</style>
</head>
<body>
@@ -405,7 +445,9 @@
</button>
<h1 class="fc-title" id="cards-deck-title">Название колоды</h1>
<button class="fc-btn fc-btn-ghost" id="cards-ai-btn" onclick="openAiGenModal()">Сгенерировать ИИ</button>
<button class="fc-btn fc-btn-ghost" onclick="openBulkModal()">Добавить список</button>
<button class="fc-btn fc-btn-ghost" id="cards-bulk-btn" onclick="openBulkModal()">Добавить список</button>
<button class="fc-btn fc-btn-ghost" id="cards-share-btn" style="display:none" onclick="openShareModal()">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;vertical-align:-2px;margin-right:4px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/></svg>Поделиться</button>
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
</div>
<div class="card-search-bar" id="card-search-bar" style="display:none">
@@ -415,7 +457,7 @@
</div>
<div class="card-list" id="card-list"></div>
<!-- Add card row -->
<div class="card-add-bar" style="margin-bottom:14px">
<div class="card-add-bar" id="card-add-row" style="margin-bottom:14px">
<input class="card-add-input" id="new-card-front" placeholder="Лицевая сторона (вопрос)…"
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'front')" />
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…"
@@ -424,7 +466,7 @@
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
</div>
<div id="new-card-imgs"></div>
<div style="display:flex;gap:10px;align-items:center">
<div id="deck-manage-row" style="display:flex;gap:10px;align-items:center">
<button class="fc-btn fc-btn-ghost" onclick="openEditDeckModal()"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>️ Редактировать колоду</button>
<button class="fc-btn fc-btn-danger" onclick="confirmDeleteDeck()">Удалить колоду</button>
</div>
@@ -588,6 +630,23 @@
</div>
</div>
<!-- ── Share Deck Modal ── -->
<div class="fc-modal" id="modal-share">
<div class="fc-modal-bg" onclick="closeModal('modal-share')"></div>
<div class="fc-modal-box" style="max-width:520px">
<div class="fc-modal-title">Поделиться колодой</div>
<p class="share-sub">Назначьте колоду классу или отдельным ученикам. Карточки общие, а прогресс у каждого ученика — свой.</p>
<div class="share-tabs">
<button class="share-tab active" id="share-tab-class" onclick="shareSetTab('class')">Классы</button>
<button class="share-tab" id="share-tab-user" onclick="shareSetTab('user')">Ученики</button>
</div>
<div class="share-list" id="share-list"></div>
<div class="fc-modal-actions">
<button class="fc-btn fc-btn-primary" onclick="closeModal('modal-share')">Готово</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/imggen.js"></script>
@@ -613,11 +672,20 @@ let _editingDeckId = null;
let _deckColor = '#9B5DE5';
let _cardFilter = '';
let _newImg = { front: '', back: '' }; // картинки, прикреплённые к ещё не созданной карточке
let _user = null;
let _isTeacher = false;
let _curDeckReadonly = false; // общая колода (не владелец) — редактирование скрыто
// модалка шаринга
let _shareData = { shares: [], classes: [], students: [] };
let _shareTab = 'class';
let _shareSet = new Set(); // ключи 'class:<id>' / 'user:<id>' текущих назначений
(async () => {
/* ── auth ── */
const { user } = LS.initPage();
if (!user) return;
_user = user;
_isTeacher = (user.role === 'teacher' || user.role === 'admin');
const avatarEl = document.getElementById('nav-avatar');
const nameEl = document.getElementById('nav-user');
LS.renderNavAvatar(avatarEl, user);
@@ -720,7 +788,11 @@ function renderDecks() {
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 zero"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>Актуально</span>`;
return `<div class="deck-card" style="--dc-shadow:${shadow}">
// Общая колода (назначена мне учителем): бейдж владельца, без карандаша правки.
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>`
: '';
return `<div class="deck-card${d.shared ? ' shared' : ''}" style="--dc-shadow:${shadow}">
<div class="deck-head" style="background:${color}" onclick="openDeck(${d.id})">
<div class="deck-head-letter">${letter}</div>
<span class="deck-head-count">${d.card_count} карт.</span>
@@ -728,7 +800,7 @@ function renderDecks() {
<div class="deck-body" onclick="openDeck(${d.id})">
<div class="deck-name">${esc(d.title)}</div>
${d.description ? `<div class="deck-desc">${esc(d.description)}</div>` : ''}
<div class="deck-meta">${dueHtml}</div>
<div class="deck-meta">${dueHtml}${sharedHtml}</div>
</div>
<div class="deck-actions">
<button class="deck-btn-study" onclick="openDeckStudy(${d.id})" ${d.card_count===0?'disabled':''}>
@@ -738,8 +810,10 @@ function renderDecks() {
? `<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Изучать`
: 'Нет карточек'}
</button>
<button class="deck-btn-edit" onclick="openDeck(${d.id})">
<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<button class="deck-btn-edit" onclick="openDeck(${d.id})" title="${d.shared ? 'Открыть' : 'Редактировать'}">
${d.shared
? `<svg class="ic" viewBox="0 0 24 24"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>`
: `<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`}
</button>
</div>
</div>`;
@@ -753,11 +827,16 @@ function renderDecks() {
async function openDeck(id) {
_curDeck = _decks.find(d => d.id === id);
if (!_curDeck) return;
// Общая колода (назначена мне) — только просмотр и изучение, без правки.
_curDeckReadonly = (_curDeck.shared === 1) || (_curDeck.can_edit === 0);
document.getElementById('cards-deck-title').textContent = _curDeck.title;
applyCardsPermissions();
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
if (data && data.can_edit === false) _curDeckReadonly = true; // страховка по серверу
_cards = data.cards || [];
_cardFilter = '';
const si = document.getElementById('card-search'); if (si) si.value = '';
applyCardsPermissions();
renderCardList();
document.getElementById('view-decks').style.display = 'none';
document.getElementById('view-cards').style.display = 'block';
@@ -770,6 +849,19 @@ function openDeckStudy(id) {
startStudyForDeck(id);
}
/* Показ/скрытие редактирующих элементов в зависимости от прав на колоду.
readonly (общая, не владелец) → прячем добавление/ИИ/список/правку колоды;
кнопка «Поделиться» — только владельцу-учителю/админу. */
function applyCardsPermissions() {
const ed = !_curDeckReadonly;
['cards-ai-btn', 'cards-bulk-btn', 'card-add-row', 'deck-manage-row'].forEach(id => {
const el = document.getElementById(id); if (el) el.style.display = ed ? '' : 'none';
});
const imgs = document.getElementById('new-card-imgs'); if (imgs) imgs.style.display = ed ? '' : 'none';
const shareBtn = document.getElementById('cards-share-btn');
if (shareBtn) shareBtn.style.display = (ed && _isTeacher) ? '' : 'none';
}
function showDecks() {
document.getElementById('view-decks').style.display = 'block';
document.getElementById('view-cards').style.display = 'none';
@@ -856,9 +948,12 @@ function renderCardList() {
</div>
</div>`).join('');
// read-only (общая колода) → CSS прячет ручки/удаление/правку, drag не вешаем
list.classList.toggle('readonly', _curDeckReadonly);
// Отрисовать карточки (KaTeX). Правка — по клику (textarea), как в Anki.
list.querySelectorAll('.card-display').forEach(fcRenderDisplay);
if (!q) bindCardDrag();
if (!q && !_curDeckReadonly) bindCardDrag();
}
/* Показать отрисованный текст карточки (или плейсхолдер, если пусто). */
@@ -871,6 +966,7 @@ function fcRenderDisplay(disp) {
}
/* Клик по отрисованной карточке → редактирование (textarea с сырым LaTeX). */
function fcStartEdit(disp) {
if (_curDeckReadonly) return; // общая колода — только чтение
const side = disp.closest('.card-side');
const ta = side && side.querySelector('.card-textarea');
if (!ta) return;
@@ -1748,6 +1844,80 @@ async function confirmDeleteDeck() {
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
/* ════ Поделиться колодой (учитель → класс/ученик) ════
Карты общие, прогресс у каждого ученика свой. Тоггл сразу шлёт add/remove
share (оптимистично, с откатом при ошибке). */
async function openShareModal() {
if (!_curDeck || !_isTeacher || _curDeckReadonly) return;
document.getElementById('modal-share').classList.add('open');
document.getElementById('share-list').innerHTML =
'<div class="share-empty">Загрузка…</div>';
try {
const [sh, cls, st] = await Promise.all([
LS.api(`/api/flashcards/decks/${_curDeck.id}/shares`).catch(() => ({ shares: [] })),
LS.getClasses().catch(() => []),
LS.getStudents().catch(() => []),
]);
_shareData.shares = (sh && sh.shares) || [];
_shareData.classes = Array.isArray(cls) ? cls : (cls && cls.classes) || [];
_shareData.students = Array.isArray(st) ? st : (st && st.students) || [];
_shareSet = new Set(_shareData.shares.map(s => `${s.type}:${s.target_id}`));
} catch (e) {
_shareData = { shares: [], classes: [], students: [] };
_shareSet = new Set();
}
shareSetTab(_shareTab);
}
function shareSetTab(tab) {
_shareTab = tab;
document.getElementById('share-tab-class').classList.toggle('active', tab === 'class');
document.getElementById('share-tab-user').classList.toggle('active', tab === 'user');
renderShareList();
}
function renderShareList() {
const box = document.getElementById('share-list');
const items = _shareTab === 'class' ? _shareData.classes : _shareData.students;
if (!items.length) {
box.innerHTML = `<div class="share-empty">${_shareTab === 'class'
? 'У вас пока нет классов' : 'У вас пока нет учеников'}</div>`;
return;
}
box.innerHTML = items.map(it => {
const on = _shareSet.has(`${_shareTab}:${it.id}`);
const sub = _shareTab === 'class'
? (it.member_count != null ? `${it.member_count} учеников` : '')
: (it.email || '');
return `<div class="share-row${on ? ' on' : ''}" data-id="${it.id}" onclick="toggleShare(${it.id}, this)">
<div class="share-row-name">${esc(it.name)}${sub ? `<span class="share-row-sub" style="display:block">${esc(sub)}</span>` : ''}</div>
<div class="share-toggle"></div>
</div>`;
}).join('');
}
async function toggleShare(id, row) {
const key = `${_shareTab}:${id}`;
const wasOn = _shareSet.has(key);
// оптимистично
if (wasOn) { _shareSet.delete(key); row.classList.remove('on'); }
else { _shareSet.add(key); row.classList.add('on'); }
try {
if (wasOn) {
await LS.api(`/api/flashcards/decks/${_curDeck.id}/share?type=${_shareTab}&target_id=${id}`, { method: 'DELETE' });
} else {
await LS.api(`/api/flashcards/decks/${_curDeck.id}/share`, {
method: 'POST', body: JSON.stringify({ type: _shareTab, target_id: id })
});
}
} catch (e) {
// откат
if (wasOn) { _shareSet.add(key); row.classList.add('on'); }
else { _shareSet.delete(key); row.classList.remove('on'); }
LS.toast('Не удалось изменить доступ: ' + (e && e.message || 'ошибка'), 'error');
}
}
</script>
<script src="/js/mobile.js"></script>