diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 47815e6..3df2ea0 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -60,7 +60,7 @@ async function login(req, res, next) { function me(req, res) { const user = db.prepare( - 'SELECT id, email, name, role, created_at, last_login FROM users WHERE id = ?' + 'SELECT id, email, name, role, created_at, last_login, avatar_url FROM users WHERE id = ?' ).get(req.user.id); res.json(user); } diff --git a/backend/src/controllers/avatarController.js b/backend/src/controllers/avatarController.js new file mode 100644 index 0000000..8e7d7c1 --- /dev/null +++ b/backend/src/controllers/avatarController.js @@ -0,0 +1,107 @@ +const path = require('path'); +const fs = require('fs'); +const db = require('../db/db'); + +const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars'); + +/* ── POST /api/avatar/request ── student submits a new avatar ───────────── */ +function requestAvatar(req, res) { + if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); + + // Cancel any previous pending request from this user (replace it) + const prev = db.prepare( + "SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'" + ).get(req.user.id); + if (prev) { + // Delete old file + try { fs.unlinkSync(path.join(AVATARS_DIR, prev.filename)); } catch {} + db.prepare("DELETE FROM avatar_requests WHERE user_id=? AND status='pending'").run(req.user.id); + } + + db.prepare(` + INSERT INTO avatar_requests (user_id, filename, status, created_at) + VALUES (?, ?, 'pending', datetime('now')) + `).run(req.user.id, req.file.filename); + + res.json({ ok: true, filename: req.file.filename }); +} + +/* ── GET /api/avatar/my-status ── student polls their request status ────── */ +function myStatus(req, res) { + const row = db.prepare(` + SELECT ar.id, ar.filename, ar.status, ar.reject_msg, ar.created_at, ar.reviewed_at + FROM avatar_requests ar + WHERE ar.user_id = ? + ORDER BY ar.created_at DESC LIMIT 1 + `).get(req.user.id); + + const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id); + res.json({ request: row || null, current_avatar: user?.avatar_url || null }); +} + +/* ── GET /api/avatar/pending ── moderator sees all pending requests ──────── */ +function getPending(req, res) { + const rows = db.prepare(` + SELECT ar.id, ar.filename, ar.status, ar.created_at, + u.id AS user_id, u.name AS user_name, u.email AS user_email, + u.avatar_url AS current_avatar + FROM avatar_requests ar + JOIN users u ON u.id = ar.user_id + WHERE ar.status = 'pending' + ORDER BY ar.created_at ASC + `).all(); + res.json(rows); +} + +/* ── POST /api/avatar/:id/approve ── moderator approves ─────────────────── */ +function approveAvatar(req, res) { + const row = db.prepare('SELECT * FROM avatar_requests WHERE id=?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'Заявка не найдена' }); + if (row.status !== 'pending') return res.status(400).json({ error: 'Заявка уже обработана' }); + + // Remove old avatar file if exists + const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(row.user_id); + if (user?.avatar_url) { + try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {} + } + + db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(row.filename, row.user_id); + db.prepare(` + UPDATE avatar_requests SET status='approved', reviewer_id=?, reviewed_at=datetime('now') + WHERE id=? + `).run(req.user.id, row.id); + + res.json({ ok: true }); +} + +/* ── POST /api/avatar/:id/reject ── moderator rejects ───────────────────── */ +function rejectAvatar(req, res) { + const row = db.prepare('SELECT * FROM avatar_requests WHERE id=?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'Заявка не найдена' }); + if (row.status !== 'pending') return res.status(400).json({ error: 'Заявка уже обработана' }); + + const msg = (req.body.reason || '').toString().slice(0, 200).trim(); + + // Delete uploaded file + try { fs.unlinkSync(path.join(AVATARS_DIR, row.filename)); } catch {} + + db.prepare(` + UPDATE avatar_requests + SET status='rejected', reviewer_id=?, reject_msg=?, reviewed_at=datetime('now') + WHERE id=? + `).run(req.user.id, msg || null, row.id); + + res.json({ ok: true }); +} + +/* ── DELETE /api/avatar/me ── student removes their approved avatar ──────── */ +function removeAvatar(req, res) { + const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id); + if (user?.avatar_url) { + try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {} + db.prepare('UPDATE users SET avatar_url=NULL WHERE id=?').run(req.user.id); + } + res.json({ ok: true }); +} + +module.exports = { requestAvatar, myStatus, getPending, approveAvatar, rejectAvatar, removeAvatar }; diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 9927cc4..f5476bb 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -2899,6 +2899,25 @@ db.exec(` `); db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_task ON geometry_submissions(task_id)'); +// Avatar photo (approved URL, NULL = use initials) +try { db.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT DEFAULT NULL"); } catch {} + +// Avatar moderation queue +db.exec(` + CREATE TABLE IF NOT EXISTS avatar_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + reviewer_id INTEGER REFERENCES users(id), + reject_msg TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + reviewed_at TEXT + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_avatar_req_user ON avatar_requests(user_id)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_avatar_req_status ON avatar_requests(status)'); + // User preferences (server-synced: whiteboard defaults, dashboard visibility, etc.) db.exec(` CREATE TABLE IF NOT EXISTS user_preferences ( diff --git a/backend/src/routes/avatar.js b/backend/src/routes/avatar.js new file mode 100644 index 0000000..380eda2 --- /dev/null +++ b/backend/src/routes/avatar.js @@ -0,0 +1,40 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const path = require('path'); +const crypto = require('crypto'); +const { authMiddleware, requireRole } = require('../middleware/auth'); +const ctrl = require('../controllers/avatarController'); + +/* ── multer: avatars only, 2 MB ────────────────────────────────────────── */ +const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars'); +const AVATAR_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); + +const storage = multer.diskStorage({ + destination: AVATARS_DIR, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const name = crypto.randomBytes(16).toString('hex') + ext; + cb(null, name); + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB + fileFilter: (_req, file, cb) => { + cb(null, AVATAR_TYPES.has(file.mimetype)); + }, +}); + +/* ── student routes ─────────────────────────────────────────────────────── */ +router.post('/request', authMiddleware, upload.single('avatar'), ctrl.requestAvatar); +router.get('/my-status', authMiddleware, ctrl.myStatus); +router.delete('/me', authMiddleware, ctrl.removeAvatar); + +/* ── moderator routes (teacher or admin) ────────────────────────────────── */ +router.get('/pending', authMiddleware, requireRole('teacher', 'admin'), ctrl.getPending); +router.post('/:id/approve', authMiddleware, requireRole('teacher', 'admin'), ctrl.approveAvatar); +router.post('/:id/reject', authMiddleware, requireRole('teacher', 'admin'), ctrl.rejectAvatar); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 1209445..81231ae 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -144,6 +144,7 @@ app.use('/api/search', searchRoutes); app.use('/api/flashcards', flashcardRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/preferences', require('./routes/preferences')); +app.use('/api/avatar', require('./routes/avatar')); app.use('/api/analytics', analyticsRoutes); app.use('/api/live', liveRoutes); app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom @@ -273,9 +274,10 @@ function _fmtUptime(s) { const frontendDir = path.join(__dirname, '../../frontend'); const jsDir = path.join(__dirname, '../../js'); const staticCache = isProd ? { maxAge: '7d' } : { setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') }; -app.use('/js', express.static(jsDir, staticCache)); -app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache)); -app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache)); +app.use('/js', express.static(jsDir, staticCache)); +app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache)); +app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache)); +app.use('/avatars', express.static(path.join(__dirname, '../../uploads/avatars'), { maxAge: '1d' })); // Redirect legacy .html URLs → clean URLs (301) app.use((req, res, next) => { diff --git a/frontend/admin.html b/frontend/admin.html index 6c1108b..c74aa0f 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -827,6 +827,39 @@ .cr-hist-search { flex: 1; min-width: 180px; padding: 9px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family:'Manrope',sans-serif; font-size:0.88rem; background:var(--surface); color:var(--text); } .cr-hist-search:focus { outline:none; border-color:var(--violet); } .cr-hist-count { font-size:0.85rem; color:var(--text-3); font-weight:600; white-space:nowrap; } + /* ── Avatar moderation ── */ + .av-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px,1fr)); gap: 16px; } + .av-card { + background: var(--surface); border: 1.5px solid var(--border-h); + border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 12px; + } + .av-card-top { display: flex; align-items: center; gap: 12px; } + .av-imgs { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; } + .av-img-wrap { text-align: center; } + .av-img-wrap span { font-size: 0.62rem; color: var(--text-3); font-weight: 600; display: block; margin-bottom: 4px; } + .av-img { + width: 64px; height: 64px; border-radius: 50%; object-fit: cover; + border: 2px solid var(--border-h); background: var(--surface-2); + display: flex; align-items: center; justify-content: center; + font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; + color: var(--text-2); overflow: hidden; + } + .av-img img { width: 100%; height: 100%; object-fit: cover; } + .av-arrow { color: var(--text-3); flex-shrink: 0; } + .av-user-name { font-size: 0.82rem; font-weight: 700; color: var(--text); } + .av-date { font-size: 0.7rem; color: var(--text-3); margin-top: 2px; } + .av-actions { display: flex; gap: 8px; } + .av-approve { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 700; background: rgba(6,214,96,0.12); color: #06d660; transition: background .15s; } + .av-approve:hover { background: rgba(6,214,96,0.22); } + .av-reject { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 700; background: rgba(241,91,181,0.12); color: #F15BB5; transition: background .15s; } + .av-reject:hover { background: rgba(241,91,181,0.22); } + .av-empty { text-align: center; padding: 60px 0; color: var(--text-3); font-size: 0.85rem; } + .admin-badge { + display: inline-flex; align-items: center; justify-content: center; + min-width: 18px; height: 18px; border-radius: 99px; padding: 0 5px; + background: #F15BB5; color: #fff; font-size: 0.65rem; font-weight: 800; + margin-left: 4px; vertical-align: middle; + } @@ -878,6 +911,10 @@ + @@ -1102,6 +1139,19 @@ + +
+
+
Аватары на модерации
+ +
+
+
Загрузка...
+
+
+
Права доступа по ролям
@@ -5220,10 +5270,93 @@ else if (tab === 'errors') loadErrorLog(); else if (tab === 'health') loadHealth(); else if (tab === 'classroom') { loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); } + else if (tab === 'avatars') { loadAvatarRequests(); } }; + /* ── Avatar moderation ─────────────────────────────────────────────── */ + async function loadAvatarRequests() { + const list = document.getElementById('av-list'); + list.innerHTML = '
Загрузка...
'; + try { + const rows = await LS.get('/api/avatar/pending'); + const badge = document.getElementById('av-badge'); + if (rows.length) { + badge.textContent = rows.length; + badge.style.display = 'inline-flex'; + } else { + badge.style.display = 'none'; + } + if (!rows.length) { + list.innerHTML = '
Нет заявок на модерацию
'; + if (window.lucide) lucide.createIcons(); + return; + } + list.innerHTML = `
${rows.map(r => { + const initials = (r.user_name||'LS').split(' ').slice(0,2).map(w=>(w[0]||'').toUpperCase()).join('') || 'LS'; + const curAvatar = r.current_avatar + ? `` + : initials; + const newAvatar = ``; + const d = new Date(r.created_at).toLocaleString('ru', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' }); + return `
+
+
+
+ Сейчас +
${curAvatar}
+
+ +
+ Новый +
${newAvatar}
+
+
+
+
+
${esc(r.user_name||r.user_email)}
+
${esc(r.user_email)} · ${d}
+
+
+ + +
+
`; + }).join('')}
`; + if (window.lucide) lucide.createIcons(); + } catch { + list.innerHTML = '
Ошибка загрузки
'; + } + } + + async function avatarApprove(id) { + const card = document.getElementById('av-card-' + id); + if (card) card.style.opacity = '0.5'; + try { + await LS.post('/api/avatar/' + id + '/approve', {}); + LS.toast('Аватар одобрен', 'success'); + loadAvatarRequests(); + } catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; } + } + + function avatarRejectPrompt(id) { + const reason = prompt('Причина отклонения (необязательно):') ?? null; + if (reason === null) return; // cancelled + avatarReject(id, reason); + } + + async function avatarReject(id, reason) { + const card = document.getElementById('av-card-' + id); + if (card) card.style.opacity = '0.5'; + try { + await LS.patch('/api/avatar/' + id + '/reject', { reason }); + LS.toast('Аватар отклонён', 'info'); + loadAvatarRequests(); + } catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; } + } + /* ─── init ─── */ loadStats(); + loadAvatarRequests(); // load badge count on page open if (window.lucide) lucide.createIcons();
diff --git a/frontend/profile.html b/frontend/profile.html index e9ff09d..8427473 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -75,7 +75,26 @@ background: linear-gradient(135deg, rgba(155,93,229,0.6), rgba(6,214,224,0.4)); display: flex; align-items: center; justify-content: center; font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: #fff; + overflow: hidden; } + .p-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } + .p-avatar-edit-btn { + position: absolute; bottom: 0; right: 0; + width: 26px; height: 26px; border-radius: 50%; + background: #9B5DE5; border: 2px solid #1a1248; + display: flex; align-items: center; justify-content: center; + cursor: pointer; z-index: 2; transition: background .15s; + } + .p-avatar-edit-btn:hover { background: #7c3fd4; } + .p-avatar-edit-btn svg { width: 13px; height: 13px; color: #fff; } + .p-avatar-status { + margin-top: 8px; font-size: 0.68rem; font-weight: 600; + padding: 3px 10px; border-radius: 99px; + display: none; + } + .p-avatar-status.pending { display: inline-block; background: rgba(255,200,0,0.18); color: #ffd166; } + .p-avatar-status.rejected { display: inline-block; background: rgba(241,91,181,0.18); color: #F15BB5; } + .p-avatar-status.approved { display: inline-block; background: rgba(6,214,96,0.18); color: #06d660; } .p-name { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800; @@ -592,10 +611,21 @@
-
+
LS
+ + +
+
@@ -1063,7 +1093,21 @@ try { const u = await LS.fetchMe(); const initials = (u.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS'; - document.getElementById('big-avatar').textContent = initials; + + // Avatar: photo or initials + const avatarEl = document.getElementById('big-avatar'); + if (u.avatar_url) { + avatarEl.innerHTML = `Аватар`; + } else { + avatarEl.textContent = initials; + } + + // Show edit button for students only + if (u.role === 'student') { + document.getElementById('p-avatar-edit-btn').style.display = 'flex'; + loadAvatarStatus(); + } + document.getElementById('profile-name').textContent = u.name||'—'; document.getElementById('profile-email').textContent= u.email||'—'; document.getElementById('profile-role').textContent = ROLE_LABELS[u.role]||u.role; @@ -1085,6 +1129,57 @@ } catch {} } + /* ── Avatar upload ────────────────────────────────────────────────────── */ + async function loadAvatarStatus() { + try { + const data = await LS.get('/api/avatar/my-status'); + const el = document.getElementById('p-avatar-status'); + if (!data.request) { el.className = 'p-avatar-status'; return; } + const r = data.request; + if (r.status === 'pending') { + el.className = 'p-avatar-status pending'; + el.textContent = 'На проверке'; + } else if (r.status === 'rejected') { + el.className = 'p-avatar-status rejected'; + el.textContent = r.reject_msg ? 'Отклонено: ' + r.reject_msg : 'Отклонено'; + } else if (r.status === 'approved') { + el.className = 'p-avatar-status approved'; + el.textContent = 'Одобрено'; + // Auto-hide after 3s + setTimeout(() => { el.className = 'p-avatar-status'; }, 3000); + } + } catch {} + } + + async function avatarFileSelected(input) { + const file = input.files[0]; + if (!file) return; + if (file.size > 2 * 1024 * 1024) { LS.toast('Файл слишком большой (макс. 2 МБ)', 'error'); return; } + + const fd = new FormData(); + fd.append('avatar', file); + + try { + const token = LS.getToken(); + const res = await fetch('/api/avatar/request', { + method: 'POST', + headers: token ? { Authorization: 'Bearer ' + token } : {}, + body: fd, + }); + if (!res.ok) throw new Error(); + LS.toast('Аватар отправлен на проверку', 'success'); + // Show pending preview locally + const url = URL.createObjectURL(file); + const wrap = document.getElementById('big-avatar'); + wrap.innerHTML = `Аватар`; + document.getElementById('p-avatar-status').className = 'p-avatar-status pending'; + document.getElementById('p-avatar-status').textContent = 'На проверке'; + } catch { + LS.toast('Ошибка загрузки', 'error'); + } + input.value = ''; + } + /* ── Student ── */ async function loadStudentStats() { try { diff --git a/js/api.js b/js/api.js index 05aa804..74a52ee 100644 --- a/js/api.js +++ b/js/api.js @@ -649,23 +649,27 @@ const _prefsCache = {}; let _prefsDirty = false; let _prefsTimer = null; -async function _prefsLoad() { - if (!isLoggedIn()) return; - try { - const data = await apiFetch('/api/preferences', { method: 'GET' }); - Object.assign(_prefsCache, data); - } catch (e) {} -} +// SYNC DISABLED (debug mode) — раскомментировать для включения синхронизации +async function _prefsLoad() { /* disabled */ } +function _prefsFlush() { /* disabled */ } -function _prefsFlush() { - if (!_prefsDirty) return; - _prefsDirty = false; - if (!isLoggedIn()) return; - apiFetch('/api/preferences', { - method: 'PATCH', - body: JSON.stringify(_prefsCache), - }).catch(() => {}); -} +// async function _prefsLoad() { +// if (!isLoggedIn()) return; +// try { +// const data = await apiFetch('/api/preferences', { method: 'GET' }); +// Object.assign(_prefsCache, data); +// } catch (e) {} +// } +// +// function _prefsFlush() { +// if (!_prefsDirty) return; +// _prefsDirty = false; +// if (!isLoggedIn()) return; +// apiFetch('/api/preferences', { +// method: 'PATCH', +// body: JSON.stringify(_prefsCache), +// }).catch(() => {}); +// } const lsPrefs = { get(key, def) {