feat: avatar moderation — ученик загружает фото, учитель/админ подтверждает или отклоняет
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ async function login(req, res, next) {
|
|||||||
|
|
||||||
function me(req, res) {
|
function me(req, res) {
|
||||||
const user = db.prepare(
|
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);
|
).get(req.user.id);
|
||||||
res.json(user);
|
res.json(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -2899,6 +2899,25 @@ db.exec(`
|
|||||||
`);
|
`);
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_task ON geometry_submissions(task_id)');
|
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.)
|
// User preferences (server-synced: whiteboard defaults, dashboard visibility, etc.)
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -144,6 +144,7 @@ app.use('/api/search', searchRoutes);
|
|||||||
app.use('/api/flashcards', flashcardRoutes);
|
app.use('/api/flashcards', flashcardRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/preferences', require('./routes/preferences'));
|
app.use('/api/preferences', require('./routes/preferences'));
|
||||||
|
app.use('/api/avatar', require('./routes/avatar'));
|
||||||
app.use('/api/analytics', analyticsRoutes);
|
app.use('/api/analytics', analyticsRoutes);
|
||||||
app.use('/api/live', liveRoutes);
|
app.use('/api/live', liveRoutes);
|
||||||
app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom
|
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 frontendDir = path.join(__dirname, '../../frontend');
|
||||||
const jsDir = path.join(__dirname, '../../js');
|
const jsDir = path.join(__dirname, '../../js');
|
||||||
const staticCache = isProd ? { maxAge: '7d' } : { setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') };
|
const staticCache = isProd ? { maxAge: '7d' } : { setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') };
|
||||||
app.use('/js', express.static(jsDir, staticCache));
|
app.use('/js', express.static(jsDir, staticCache));
|
||||||
app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache));
|
app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache));
|
||||||
app.use('/img', express.static(path.join(frontendDir, 'img'), 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)
|
// Redirect legacy .html URLs → clean URLs (301)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|||||||
@@ -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 { 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-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; }
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -878,6 +911,10 @@
|
|||||||
<button class="admin-nav-item" data-tab="permissions" onclick="switchTab(this)" id="btn-tab-permissions" style="display:none">
|
<button class="admin-nav-item" data-tab="permissions" onclick="switchTab(this)" id="btn-tab-permissions" style="display:none">
|
||||||
<i data-lucide="shield" style="width:15px;height:15px"></i> Права доступа
|
<i data-lucide="shield" style="width:15px;height:15px"></i> Права доступа
|
||||||
</button>
|
</button>
|
||||||
|
<button class="admin-nav-item" data-tab="avatars" onclick="switchTab(this);loadAvatarRequests()">
|
||||||
|
<i data-lucide="image" style="width:15px;height:15px"></i> Аватары
|
||||||
|
<span class="admin-badge" id="av-badge" style="display:none"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="admin-nav-sep" id="admin-nav-system-sep" style="display:none"></div>
|
<div class="admin-nav-sep" id="admin-nav-system-sep" style="display:none"></div>
|
||||||
<div class="admin-nav-label" id="admin-nav-system-label" style="display:none">Система</div>
|
<div class="admin-nav-label" id="admin-nav-system-label" style="display:none">Система</div>
|
||||||
@@ -1102,6 +1139,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Права доступа ── -->
|
<!-- ── Права доступа ── -->
|
||||||
|
<!-- ── Avatars moderation tab ── -->
|
||||||
|
<div class="tab-pane" id="tab-avatars">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px">
|
||||||
|
<div class="section-title" style="margin:0">Аватары на модерации</div>
|
||||||
|
<button class="btn-outline" onclick="loadAvatarRequests()" style="padding:6px 14px;font-size:0.8rem">
|
||||||
|
<i data-lucide="refresh-cw" style="width:13px;height:13px;vertical-align:-2px"></i> Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="av-list">
|
||||||
|
<div style="color:var(--muted);text-align:center;padding:40px 0;font-size:0.85rem">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane" id="tab-permissions">
|
<div class="tab-pane" id="tab-permissions">
|
||||||
<div class="perm-header">
|
<div class="perm-header">
|
||||||
<div class="section-title" style="margin:0">Права доступа по ролям</div>
|
<div class="section-title" style="margin:0">Права доступа по ролям</div>
|
||||||
@@ -5220,10 +5270,93 @@
|
|||||||
else if (tab === 'errors') loadErrorLog();
|
else if (tab === 'errors') loadErrorLog();
|
||||||
else if (tab === 'health') loadHealth();
|
else if (tab === 'health') loadHealth();
|
||||||
else if (tab === 'classroom') { loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
|
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 = '<div style="color:var(--muted);text-align:center;padding:40px 0;font-size:0.85rem">Загрузка...</div>';
|
||||||
|
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 = '<div class="av-empty"><i data-lucide="check-circle" style="width:36px;height:36px;opacity:.3;display:block;margin:0 auto 10px"></i>Нет заявок на модерацию</div>';
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = `<div class="av-grid">${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
|
||||||
|
? `<img src="/avatars/${esc(r.current_avatar)}" alt="">`
|
||||||
|
: initials;
|
||||||
|
const newAvatar = `<img src="/avatars/${esc(r.filename)}" alt="" onerror="this.parentElement.textContent='?'">`;
|
||||||
|
const d = new Date(r.created_at).toLocaleString('ru', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' });
|
||||||
|
return `<div class="av-card" id="av-card-${r.id}">
|
||||||
|
<div class="av-card-top">
|
||||||
|
<div class="av-imgs">
|
||||||
|
<div class="av-img-wrap">
|
||||||
|
<span>Сейчас</span>
|
||||||
|
<div class="av-img">${curAvatar}</div>
|
||||||
|
</div>
|
||||||
|
<svg class="av-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||||
|
<div class="av-img-wrap">
|
||||||
|
<span>Новый</span>
|
||||||
|
<div class="av-img">${newAvatar}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="av-user-name">${esc(r.user_name||r.user_email)}</div>
|
||||||
|
<div class="av-date">${esc(r.user_email)} · ${d}</div>
|
||||||
|
</div>
|
||||||
|
<div class="av-actions">
|
||||||
|
<button class="av-approve" onclick="avatarApprove(${r.id})">Одобрить</button>
|
||||||
|
<button class="av-reject" onclick="avatarRejectPrompt(${r.id})">Отклонить</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}</div>`;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
} catch {
|
||||||
|
list.innerHTML = '<div class="av-empty">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ─── */
|
/* ─── init ─── */
|
||||||
loadStats();
|
loadStats();
|
||||||
|
loadAvatarRequests(); // load badge count on page open
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+97
-2
@@ -75,7 +75,26 @@
|
|||||||
background: linear-gradient(135deg, rgba(155,93,229,0.6), rgba(6,214,224,0.4));
|
background: linear-gradient(135deg, rgba(155,93,229,0.6), rgba(6,214,224,0.4));
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: #fff;
|
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 {
|
.p-name {
|
||||||
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
|
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
|
||||||
@@ -592,10 +611,21 @@
|
|||||||
|
|
||||||
<div class="p-left-inner">
|
<div class="p-left-inner">
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div class="p-avatar-wrap">
|
<div class="p-avatar-wrap" id="p-avatar-wrap">
|
||||||
<div class="p-avatar-ring"><div class="p-avatar-ring-inner"></div></div>
|
<div class="p-avatar-ring"><div class="p-avatar-ring-inner"></div></div>
|
||||||
<div class="p-avatar" id="big-avatar">LS</div>
|
<div class="p-avatar" id="big-avatar">LS</div>
|
||||||
|
<!-- Edit button — shown only for students via JS -->
|
||||||
|
<button class="p-avatar-edit-btn" id="p-avatar-edit-btn" title="Изменить аватар" style="display:none"
|
||||||
|
onclick="document.getElementById('p-avatar-input').click()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<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.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="p-avatar-input" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style="display:none" onchange="avatarFileSelected(this)">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-avatar-status" id="p-avatar-status"></div>
|
||||||
|
|
||||||
<div class="p-name" id="profile-name">—</div>
|
<div class="p-name" id="profile-name">—</div>
|
||||||
<div class="p-email" id="profile-email">—</div>
|
<div class="p-email" id="profile-email">—</div>
|
||||||
@@ -1063,7 +1093,21 @@
|
|||||||
try {
|
try {
|
||||||
const u = await LS.fetchMe();
|
const u = await LS.fetchMe();
|
||||||
const initials = (u.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
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 = `<img src="/avatars/${LS.escapeHtml(u.avatar_url)}" alt="Аватар">`;
|
||||||
|
} 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-name').textContent = u.name||'—';
|
||||||
document.getElementById('profile-email').textContent= u.email||'—';
|
document.getElementById('profile-email').textContent= u.email||'—';
|
||||||
document.getElementById('profile-role').textContent = ROLE_LABELS[u.role]||u.role;
|
document.getElementById('profile-role').textContent = ROLE_LABELS[u.role]||u.role;
|
||||||
@@ -1085,6 +1129,57 @@
|
|||||||
} catch {}
|
} 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 = `<img src="${url}" alt="Аватар" style="opacity:.6">`;
|
||||||
|
document.getElementById('p-avatar-status').className = 'p-avatar-status pending';
|
||||||
|
document.getElementById('p-avatar-status').textContent = 'На проверке';
|
||||||
|
} catch {
|
||||||
|
LS.toast('Ошибка загрузки', 'error');
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Student ── */
|
/* ── Student ── */
|
||||||
async function loadStudentStats() {
|
async function loadStudentStats() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -649,23 +649,27 @@ const _prefsCache = {};
|
|||||||
let _prefsDirty = false;
|
let _prefsDirty = false;
|
||||||
let _prefsTimer = null;
|
let _prefsTimer = null;
|
||||||
|
|
||||||
async function _prefsLoad() {
|
// SYNC DISABLED (debug mode) — раскомментировать для включения синхронизации
|
||||||
if (!isLoggedIn()) return;
|
async function _prefsLoad() { /* disabled */ }
|
||||||
try {
|
function _prefsFlush() { /* disabled */ }
|
||||||
const data = await apiFetch('/api/preferences', { method: 'GET' });
|
|
||||||
Object.assign(_prefsCache, data);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _prefsFlush() {
|
// async function _prefsLoad() {
|
||||||
if (!_prefsDirty) return;
|
// if (!isLoggedIn()) return;
|
||||||
_prefsDirty = false;
|
// try {
|
||||||
if (!isLoggedIn()) return;
|
// const data = await apiFetch('/api/preferences', { method: 'GET' });
|
||||||
apiFetch('/api/preferences', {
|
// Object.assign(_prefsCache, data);
|
||||||
method: 'PATCH',
|
// } catch (e) {}
|
||||||
body: JSON.stringify(_prefsCache),
|
// }
|
||||||
}).catch(() => {});
|
//
|
||||||
}
|
// function _prefsFlush() {
|
||||||
|
// if (!_prefsDirty) return;
|
||||||
|
// _prefsDirty = false;
|
||||||
|
// if (!isLoggedIn()) return;
|
||||||
|
// apiFetch('/api/preferences', {
|
||||||
|
// method: 'PATCH',
|
||||||
|
// body: JSON.stringify(_prefsCache),
|
||||||
|
// }).catch(() => {});
|
||||||
|
// }
|
||||||
|
|
||||||
const lsPrefs = {
|
const lsPrefs = {
|
||||||
get(key, def) {
|
get(key, def) {
|
||||||
|
|||||||
Reference in New Issue
Block a user