diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index 604fa5b..aa59253 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -94,32 +94,72 @@ function schedule(prev, quality, nowMs) { lapses, dueAt, dueInSec: dueSec, graduated: state === 'review' }; } -/* ── GET /api/flashcards/decks ─────────────────────────────────────────── */ +/* ── доступ к колоде: владелец / админ / расшарена (user|class) ────────────── + { deck, owner, canRead, canEdit }. canEdit — владелец или админ (правка карт/ + колоды). canRead — ещё и тот, кому колода назначена напрямую или через класс + (учится с личным прогрессом: flashcard_reviews keyed по user_id+card_id). */ +function deckAccess(deckId, user) { + const deck = db.prepare(`SELECT * FROM flashcard_decks WHERE id = ?`).get(deckId); + if (!deck) return { deck: null, owner: false, canRead: false, canEdit: false }; + if (deck.user_id === user.id || user.role === 'admin') + return { deck, owner: deck.user_id === user.id, canRead: true, canEdit: true }; + const shared = db.prepare(` + SELECT 1 FROM flashcard_deck_access a + WHERE a.deck_id = ? AND ( + (a.type = 'user' AND a.target_id = ?) OR + (a.type = 'class' AND a.target_id IN (SELECT class_id FROM class_members WHERE user_id = ?)) + ) LIMIT 1 + `).get(deckId, user.id, user.id); + return { deck, owner: false, canRead: !!shared, canEdit: false }; +} + +/* due_count карты колоды для пользователя: learning/review к повтору (due_at<=now) + + новые в пределах дневного лимита (как в getStudySession). 7 binds: + uid, deck, uid, deck, deck, deck, uid. */ +function deckDueCount(deckId, uid) { + return db.prepare(` + SELECT ( + (SELECT COUNT(*) FROM flashcard_cards c + JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? + WHERE c.deck_id = ? AND r.due_at <= datetime('now')) + + MIN( + (SELECT COUNT(*) FROM flashcard_cards c + LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? + WHERE c.deck_id = ? AND r.id IS NULL), + MAX(0, (SELECT new_per_day FROM flashcard_decks WHERE id = ?) + - (SELECT COUNT(*) FROM flashcard_reviews r + JOIN flashcard_cards c ON c.id = r.card_id + WHERE c.deck_id = ? AND r.user_id = ? AND date(r.created_at) = date('now'))) + ) + ) AS n + `).get(uid, deckId, uid, deckId, deckId, deckId, uid).n; +} + +/* ── GET /api/flashcards/decks ─────────────────────────────────────────────── + Свои колоды + назначенные мне (через class/user). shared/can_edit/owner_name — + для UI: общие открываются только на чтение и изучение. */ function listDecks(req, res) { const uid = req.user.id; - // due_count = зрелые/learning к повтору (due_at<=now) + новые, но не больше - // дневного лимита new_per_day за вычетом уже введённых сегодня — чтобы бейдж - // отражал реальный размер сессии, а не весь бэклог новых карт. const decks = db.prepare(` - SELECT d.*, - (SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count, - ( - (SELECT COUNT(*) FROM flashcard_cards c - JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? - WHERE c.deck_id = d.id AND r.due_at <= datetime('now')) - + MIN( - (SELECT COUNT(*) FROM flashcard_cards c - LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? - WHERE c.deck_id = d.id AND r.id IS NULL), - MAX(0, d.new_per_day - (SELECT COUNT(*) FROM flashcard_reviews r - JOIN flashcard_cards c ON c.id = r.card_id - WHERE c.deck_id = d.id AND r.user_id = ? AND date(r.created_at) = date('now'))) - ) - ) AS due_count + SELECT d.*, u.name AS owner_name, + CASE WHEN d.user_id = ? THEN 1 ELSE 0 END AS can_edit, + CASE WHEN d.user_id = ? THEN 0 ELSE 1 END AS shared FROM flashcard_decks d + JOIN users u ON u.id = d.user_id WHERE d.user_id = ? - ORDER BY d.created_at DESC - `).all(uid, uid, uid, uid); + OR EXISTS (SELECT 1 FROM flashcard_deck_access a + WHERE a.deck_id = d.id AND a.type = 'user' AND a.target_id = ?) + OR EXISTS (SELECT 1 FROM flashcard_deck_access a + JOIN class_members cm ON cm.class_id = a.target_id AND cm.user_id = ? + WHERE a.deck_id = d.id AND a.type = 'class') + ORDER BY shared ASC, d.created_at DESC + `).all(uid, uid, uid, uid, uid); + + const cardStmt = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_cards WHERE deck_id = ?`); + for (const d of decks) { + d.card_count = cardStmt.get(d.id).n; + d.due_count = deckDueCount(d.id, uid); + } res.json({ decks }); } @@ -156,20 +196,21 @@ function deleteDeck(req, res) { res.json({ ok: true }); } -/* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────── */ +/* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────────── + Доступно владельцу и тем, кому колода назначена (общая открывается read-only — + can_edit подсказывает фронту прятать редактирование). */ function getCards(req, res) { const uid = req.user.id; - const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) - .get(req.params.id, uid); - if (!deck) return res.status(404).json({ error: 'Not found' }); + const acc = deckAccess(req.params.id, req.user); + if (!acc.canRead) return res.status(404).json({ error: 'Not found' }); const cards = db.prepare(` SELECT c.*, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? ORDER BY c.order_idx, c.id - `).all(uid, deck.id); - res.json({ cards }); + `).all(uid, acc.deck.id); + res.json({ cards, can_edit: acc.canEdit }); } /* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */ @@ -273,9 +314,9 @@ function deleteCard(req, res) { /* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */ function getStudySession(req, res) { const uid = req.user.id; - const deck = db.prepare(`SELECT id, new_per_day FROM flashcard_decks WHERE id = ? AND user_id = ?`) - .get(req.params.id, uid); - if (!deck) return res.status(404).json({ error: 'Not found' }); + const acc = deckAccess(req.params.id, req.user); // владелец ИЛИ кому назначена + if (!acc.canRead) return res.status(404).json({ error: 'Not found' }); + const deck = acc.deck; // 1) Карты к повторению (есть строка отзыва, due_at<=now): learning — по минутам, // review — по дням. Отдаём по возрастанию due_at (срочные learning впереди). @@ -321,12 +362,10 @@ function submitReview(req, res) { if (quality === undefined || quality < 0 || quality > 5) return res.status(400).json({ error: 'quality 0-5 required' }); - const card = db.prepare(` - SELECT c.id FROM flashcard_cards c - JOIN flashcard_decks d ON d.id = c.deck_id - WHERE c.id = ? AND d.user_id = ? - `).get(req.params.id, uid); + // Отзыв может ставить владелец И ученик, которому колода назначена (свой прогресс). + const card = db.prepare(`SELECT id, deck_id FROM flashcard_cards WHERE id = ?`).get(req.params.id); if (!card) return res.status(404).json({ error: 'Not found' }); + if (!deckAccess(card.deck_id, req.user).canRead) return res.status(404).json({ error: 'Not found' }); const existing = db.prepare( `SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?` @@ -453,9 +492,76 @@ function uploadImage(req, res) { res.json({ url: `/uploads/flashcards/${req.file.filename}` }); } +/* ══ Шаринг колоды учителем (назначение классу/ученику) ══════════════════════ + Карты общие (одна копия), прогресс у каждого свой. Управление — только + владелец/админ (canEdit). Цель валидируется: класс/ученик должен принадлежать + учителю (или роль admin) — нельзя расшарить «в чужой класс». */ + +/* ── GET /api/flashcards/decks/:id/shares ── */ +function listShares(req, res) { + const acc = deckAccess(req.params.id, req.user); + if (!acc.deck) return res.status(404).json({ error: 'Not found' }); + if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' }); + const shares = db.prepare(` + SELECT a.id, a.type, a.target_id, a.created_at, + CASE WHEN a.type = 'class' THEN (SELECT name FROM classes WHERE id = a.target_id) + ELSE (SELECT name FROM users WHERE id = a.target_id) END AS target_name + FROM flashcard_deck_access a + WHERE a.deck_id = ? + ORDER BY a.type, target_name + `).all(acc.deck.id); + res.json({ shares }); +} + +/* проверка: класс/ученик принадлежит учителю (admin — без ограничений). */ +function ownsTarget(user, type, targetId) { + if (user.role === 'admin') return true; + if (type === 'class') + return !!db.prepare(`SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?`).get(targetId, user.id); + // type === 'user' — ученик в одном из классов учителя ИЛИ его персональный ученик + return !!db.prepare(` + SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id + WHERE cm.user_id = ? AND c.teacher_id = ? + UNION + SELECT 1 FROM teacher_students WHERE student_id = ? AND teacher_id = ? + LIMIT 1 + `).get(targetId, user.id, targetId, user.id); +} + +/* ── POST /api/flashcards/decks/:id/share { type, target_id } ── */ +function addShare(req, res) { + const acc = deckAccess(req.params.id, req.user); + if (!acc.deck) return res.status(404).json({ error: 'Not found' }); + if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' }); + const type = req.body.type; + const targetId = Number(req.body.target_id) || 0; + if (!['class', 'user'].includes(type) || !targetId) + return res.status(400).json({ error: 'type (class|user) и target_id обязательны' }); + if (!ownsTarget(req.user, type, targetId)) + return res.status(403).json({ error: type === 'class' ? 'Это не ваш класс' : 'Этот ученик не в ваших классах' }); + db.prepare(`INSERT OR IGNORE INTO flashcard_deck_access (deck_id, type, target_id) VALUES (?,?,?)`) + .run(acc.deck.id, type, targetId); + res.json({ ok: true }); +} + +/* ── DELETE /api/flashcards/decks/:id/share?type=&target_id= ── */ +function removeShare(req, res) { + const acc = deckAccess(req.params.id, req.user); + if (!acc.deck) return res.status(404).json({ error: 'Not found' }); + if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' }); + const type = req.query.type; + const targetId = Number(req.query.target_id) || 0; + if (!['class', 'user'].includes(type) || !targetId) + return res.status(400).json({ error: 'type и target_id обязательны' }); + db.prepare(`DELETE FROM flashcard_deck_access WHERE deck_id = ? AND type = ? AND target_id = ?`) + .run(acc.deck.id, type, targetId); + res.json({ ok: true }); +} + module.exports = { listDecks, createDeck, updateDeck, deleteDeck, getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards, getStudySession, submitReview, getStats, quickAdd, getRandom, uploadImage, + listShares, addShare, removeShare, }; diff --git a/backend/src/db/migrations/075_flashcard_deck_access.sql b/backend/src/db/migrations/075_flashcard_deck_access.sql new file mode 100644 index 0000000..36334b0 --- /dev/null +++ b/backend/src/db/migrations/075_flashcard_deck_access.sql @@ -0,0 +1,20 @@ +-- 075_flashcard_deck_access.sql +-- Общие колоды: учитель назначает свою колоду классу или конкретному ученику. +-- Карты остаются общими (одна копия), а прогресс у каждого свой — flashcard_reviews +-- уже keyed по UNIQUE(user_id, card_id), поэтому ученик копит собственные интервалы +-- на тех же картах. Колода остаётся во владении учителя (правка — только владелец). +-- +-- Структура — зеркало folder_access (000_baseline): type='class' → target_id=class_id, +-- type='user' → target_id=user_id. Резолв класса ученика — через class_members. + +CREATE TABLE flashcard_deck_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('class', 'user')), + target_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (deck_id, type, target_id) +); + +CREATE INDEX IF NOT EXISTS idx_fc_deck_access_target ON flashcard_deck_access(type, target_id); +CREATE INDEX IF NOT EXISTS idx_fc_deck_access_deck ON flashcard_deck_access(deck_id); diff --git a/backend/src/routes/flashcards.js b/backend/src/routes/flashcards.js index e488181..3db6945 100644 --- a/backend/src/routes/flashcards.js +++ b/backend/src/routes/flashcards.js @@ -5,7 +5,7 @@ const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const fc = require('../controllers/flashcardController'); -const { authMiddleware } = require('../middleware/auth'); +const { authMiddleware, requireRole } = require('../middleware/auth'); const { requireOwnership } = require('../middleware/ownership'); /* ── multer для картинок карточек ─────────────────────────────────────── @@ -43,6 +43,10 @@ router.get ('/decks/:id/cards', fc.getCards); router.post ('/decks/:id/cards', fc.addCard); router.post ('/decks/:id/cards/bulk', fc.addCardsBulk); router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards); +// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере). +router.get ('/decks/:id/shares', fc.listShares); +router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare); +router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare); router.get ('/decks/:id/study', fc.getStudySession); router.put ('/cards/:id', fc.updateCard); router.delete('/cards/:id', fc.deleteCard); diff --git a/backend/tests/flashcards-share.test.js b/backend/tests/flashcards-share.test.js new file mode 100644 index 0000000..30029e2 --- /dev/null +++ b/backend/tests/flashcards-share.test.js @@ -0,0 +1,140 @@ +'use strict'; +/** + * Integration tests: общие колоды флешкарт (учитель → класс/ученик). + * Covers: шаринг классу/ученику, видимость у назначенного (own+published), + * изучение и отзыв с личным прогрессом на общих картах, запрет правки чужой + * колоды (404), запрет доступа неназначенному (404), ролевой гейт share (403), + * запрет шарить в чужой класс (403), снятие доступа. + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, db, inject, getToken, cleanup } = require('./setup'); + +app.use('/api/flashcards', require('../src/routes/flashcards')); + +after(() => cleanup()); + +let _cc = 0; +function mkClass(teacherId) { + const code = 'T' + (++_cc) + Math.floor(performance.now() % 100000); + const r = db.prepare(`INSERT INTO classes (name, teacher_id, invite_code) VALUES (?,?,?)`) + .run('Класс ' + _cc, teacherId, code); + return r.lastInsertRowid; +} +function enroll(classId, userId) { + db.prepare(`INSERT OR IGNORE INTO class_members (class_id, user_id) VALUES (?,?)`).run(classId, userId); +} + +describe('flashcards — общие колоды (sharing)', () => { + let teacher, otherTeacher, stuIn, stuOut; + let classId, deckId, cardId; + + before(async () => { + teacher = await getToken('teacher'); + otherTeacher = await getToken('teacher'); + stuIn = await getToken('student'); + stuOut = await getToken('student'); + classId = mkClass(teacher.userId); + enroll(classId, stuIn.userId); + + // учитель создаёт колоду с карточкой + const d = await inject('POST', '/api/flashcards/decks', { title: 'Биология' }, teacher.token); + deckId = d.body.id; + const c = await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front: 'Клетка?', back: 'Единица жизни' }, teacher.token); + cardId = c.body.id; + }); + + it('student cannot share (роль-гейт 403)', async () => { + const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: classId }, stuIn.token); + assert.equal(r.status, 403, `got ${r.status}`); + }); + + it('teacher cannot share to a class he does not own (403)', async () => { + const foreignClass = mkClass(otherTeacher.userId); + const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: foreignClass }, teacher.token); + assert.equal(r.status, 403, `got ${r.status}: ${JSON.stringify(r.body)}`); + }); + + it('not-shared: ученик не в классе не видит колоду и не имеет доступа', async () => { + const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token); + assert.ok(!list.body.decks.some(d => d.id === deckId), 'пока не расшарена — не видна'); + assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/cards`, null, stuIn.token)).status, 404); + assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token)).status, 404); + }); + + it('teacher shares deck to class', async () => { + const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: classId }, teacher.token); + assert.equal(r.status, 200, `got ${r.status}: ${JSON.stringify(r.body)}`); + }); + + it('назначенный ученик видит колоду как shared (read-only)', async () => { + const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token); + const d = list.body.decks.find(x => x.id === deckId); + assert.ok(d, 'колода видна ученику'); + assert.equal(d.shared, 1); + assert.equal(d.can_edit, 0); + assert.equal(d.owner_name, teacher.name); + }); + + it('ученик НЕ в классе по-прежнему не видит', async () => { + const list = await inject('GET', '/api/flashcards/decks', null, stuOut.token); + assert.ok(!list.body.decks.some(d => d.id === deckId)); + assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuOut.token)).status, 404); + }); + + it('ученик может изучать и ставить отзыв (свой прогресс)', async () => { + const s = await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token); + assert.equal(s.status, 200); + assert.ok(s.body.cards.some(c => c.id === cardId), 'карта в сессии ученика'); + const rev = await inject('POST', `/api/flashcards/cards/${cardId}/review`, { quality: 4 }, stuIn.token); + assert.equal(rev.status, 200, `review: ${rev.status}`); + // прогресс ученика — отдельная строка от учителя + const row = db.prepare('SELECT COUNT(*) AS n FROM flashcard_reviews WHERE card_id=? AND user_id=?').get(cardId, stuIn.userId); + assert.equal(row.n, 1, 'у ученика свой отзыв'); + }); + + it('ученик НЕ может править общую колоду (404)', async () => { + assert.equal((await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front: 'x', back: 'y' }, stuIn.token)).status, 404); + assert.equal((await inject('PUT', `/api/flashcards/decks/${deckId}`, { title: 'Взлом' }, stuIn.token)).status, 404); + assert.equal((await inject('PUT', `/api/flashcards/cards/${cardId}`, { front: 'Взлом' }, stuIn.token)).status, 404); + assert.equal((await inject('DELETE', `/api/flashcards/cards/${cardId}`, null, stuIn.token)).status, 404); + }); + + it('ученик не может расшаривать чужую колоду (роль/доступ)', async () => { + const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'user', target_id: stuOut.userId }, stuIn.token); + assert.equal(r.status, 403); + }); + + it('teacher shares to a specific user (вне класса)', async () => { + // делаем stuOut персональным учеником, чтобы прошла валидация цели + db.prepare(`INSERT OR IGNORE INTO teacher_students (teacher_id, student_id) VALUES (?,?)`) + .run(teacher.userId, stuOut.userId); + const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'user', target_id: stuOut.userId }, teacher.token); + assert.equal(r.status, 200, `got ${r.status}: ${JSON.stringify(r.body)}`); + const list = await inject('GET', '/api/flashcards/decks', null, stuOut.token); + assert.ok(list.body.decks.some(d => d.id === deckId), 'stuOut теперь видит'); + }); + + it('teacher lists shares (2: class + user)', async () => { + const r = await inject('GET', `/api/flashcards/decks/${deckId}/shares`, null, teacher.token); + assert.equal(r.status, 200); + assert.equal(r.body.shares.length, 2); + assert.ok(r.body.shares.some(s => s.type === 'class' && s.target_id === classId)); + assert.ok(r.body.shares.some(s => s.type === 'user' && s.target_id === stuOut.userId)); + }); + + it('ученик не может смотреть список share (403)', async () => { + assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/shares`, null, stuIn.token)).status, 403); + }); + + it('teacher unshares class → ученик в классе теряет доступ', async () => { + const r = await inject('DELETE', `/api/flashcards/decks/${deckId}/share?type=class&target_id=${classId}`, null, teacher.token); + assert.equal(r.status, 200); + const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token); + assert.ok(!list.body.decks.some(d => d.id === deckId), 'после снятия — не видит'); + assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token)).status, 404); + // прямой share для stuOut остаётся + const listOut = await inject('GET', '/api/flashcards/decks', null, stuOut.token); + assert.ok(listOut.body.decks.some(d => d.id === deckId), 'stuOut доступ сохранён'); + }); +}); diff --git a/frontend/flashcards.html b/frontend/flashcards.html index b41d1c1..52b573d 100644 --- a/frontend/flashcards.html +++ b/frontend/flashcards.html @@ -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; } @@ -405,7 +445,9 @@

Название колоды

- + +
-
+
+ Добавить
-
+
@@ -588,6 +630,23 @@
+ + + @@ -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:' / 'user:' текущих назначений (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 ? `${due} к повторению` : `Актуально`; - return `
+ // Общая колода (назначена мне учителем): бейдж владельца, без карандаша правки. + const sharedHtml = d.shared + ? `${esc(d.owner_name || 'учитель')}` + : ''; + return `
${letter}
${d.card_count} карт. @@ -728,7 +800,7 @@ function renderDecks() {
${esc(d.title)}
${d.description ? `
${esc(d.description)}
` : ''} -
${dueHtml}
+
${dueHtml}${sharedHtml}
-
`; @@ -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() {
`).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 = + ''; + 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 = ``; + 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 ``; + }).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'); + } +} +