Files
Maxim Dolgolyov be9fdfa703 feat(wishes): трекер пожеланий по улучшению системы
Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание);
видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам
(новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор
получает уведомление при смене статуса (pushNotif).

Бэкенд: миграция 080 (таблица wishes), wishController (list/create/update/remove с
валидацией и whitelist категорий/статусов), routes/wishes (PATCH — только админ, DELETE —
автор«новое»/админ, проверка в хендлере), смонтировано в server.js. Тесты 15/15.

Фронт: страница /wishes (форма + список со статус-бейджами; у админа — фильтры,
смена статуса, ответ, удаление), пункт «Пожелания» в сайдбаре (все роли), фиче-флаг
feature_wishes_enabled (тумблер в админ-модулях + whitelist + FEATURE_HREFS; админ
видит всегда). Клиентские врапперы LS.wish*.

⚠️ Живой БД нужен npm run migrate (080). lint:routes 0; node --check всех файлов + инлайна.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:12:10 +03:00

255 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Universal sidebar — LearnSpace
// Generates full sidebar HTML into <aside id="app-sidebar">
// Load after api.js, before page-specific scripts.
(function () {
'use strict';
const el = document.getElementById('app-sidebar');
if (!el) return;
const user = (typeof LS !== 'undefined') ? LS.getUser?.() : null;
const role = user?.role || 'student';
const isTch = ['teacher', 'admin'].includes(role);
const isAdm = role === 'admin';
const isStu = role === 'student';
// Clean current path (strip .html extension)
const path = location.pathname.replace(/\.html$/, '');
// Active link: exact match OR prefix match (e.g. /biochem active for /biochem-library)
function isActive(href) {
const h = href.replace(/\.html$/, '');
if (path === h) return true;
if (h !== '/' && path.startsWith(h + '-')) return true;
if (h !== '/' && path.startsWith(h + '/')) return true;
return false;
}
function esc(s) {
return s ? String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') : '';
}
// Build an <a> sidebar link
function L(href, icon, label, { id = '', cls = '', hidden = false } = {}) {
const active = isActive(href) ? ' active' : '';
const classes = ['sb-link', active, cls].filter(Boolean).join(' ');
const idA = id ? ` id="${id}"` : '';
const hidA = hidden ? ' style="display:none"' : '';
return `<a href="${esc(href)}" class="${classes}"${idA}${hidA}><i data-lucide="${icon}" class="sb-icon"></i><span class="sb-lbl">${label}</span></a>`;
}
// Collapsible group: section header + body. State persisted in localStorage.
function G(slug, label, body) {
const stored = localStorage.getItem('ls_sb_g_' + slug);
const collapsed = stored === '1';
return `<div class="sb-group${collapsed ? ' collapsed' : ''}" data-sb-group="${slug}">
<button class="sb-group-hdr" onclick="window.__lsSbToggle('${slug}')">
<span class="sb-lbl sb-group-lbl">${label}</span>
<i data-lucide="chevron-down" class="sb-group-chev"></i>
</button>
<div class="sb-group-body">${body}</div>
</div>`;
}
const initials = (user?.name || 'LS').split(' ').slice(0, 2).map(w => (w[0] || '').toUpperCase()).join('') || 'LS';
const displayName = esc(user?.name || user?.email || '—');
el.innerHTML = `
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" title="Свернуть меню" data-sb-wired="1"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="typeof lsSearchOpen!=='undefined'&&lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
${L('/dashboard', 'home', 'Дашборд')}
${L('/sitemap', 'map', 'Путеводитель')}
${L('/wishes', 'lightbulb', 'Пожелания')}
${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })}
${G('learning', 'Учебный процесс', `
${L('/classes', 'graduation-cap', 'Классы', { id: 'btn-classes', hidden: !isTch })}
${L('/homework', 'clipboard-list', 'Домашние задания')}
${L('/my-students', 'user-plus', 'Мои ученики', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/classroom', 'presentation', 'Онлайн-урок', { id: 'btn-classroom', cls: 'sb-link-cr' })}
${L('/lesson-history','archive', 'Архив уроков')}
${L('/my-materials', 'bookmark', 'Мои материалы')}
${L('/live-quiz', 'radio', 'Live-квиз', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/board', 'layout-dashboard', 'Доска', { id: 'btn-board', hidden: true })}
`)}
${G('content', 'Контент', `
${L('/textbooks', 'book-open-text', 'Учебники')}
${L('/library', 'book-open', 'Библиотека')}
${L('/theory', 'brain', 'Теория')}
${L('/knowledge-map', 'share-2', 'Карта знаний')}
${L('/flashcards', 'copy', 'Флэшкарты')}
${L('/question-bank', 'database', 'Банк вопросов', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/exam-prep/math9', 'clipboard-check', 'Подготовка к экзамену 9')}
${L('/exam-prep/ctmath', 'clipboard-check', 'Подготовка к ЦЭ/ЦТ')}
`)}
${G('practice', 'Практика и игры', `
${L('/lab', 'atom', 'Лаборатория')}
${L('/quantik', 'rocket', 'Квантик: Законы Мира')}
${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/biochem', 'flask-conical', 'Биохимия')}
${L('/red-book', 'leaf', 'Красная книга')}
${L('/crossword', 'grid-3x3', 'Кроссворд')}
${L('/hangman', 'gamepad-2', 'Виселица')}
${L('/pet', 'heart', 'Питомец')}
${L('/collection', 'layers', 'Коллекция')}
`)}
${isTch ? G('management', 'Отчёты и управление', `
${L('/analytics', 'bar-chart-2', 'Аналитика', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/gradebook', 'table', 'Журнал', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/admin', 'settings', 'Управление', { id: 'btn-admin', hidden: !isTch })}
`) : ''}
</nav>
<div style="padding:4px 2px;flex-shrink:0">
${isStu ? '<button class="sb-link" id="btn-join" style="display:none" onclick="typeof openJoinModal!==\'undefined\'&&openJoinModal()"><i data-lucide="user-plus" class="sb-icon"></i><span class="sb-lbl">Вступить в класс</span></button>' : ''}
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="LS.notif?.toggle()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">${esc(initials)}</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user">${displayName}</div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>`;
// Inject group styles once
if (!document.getElementById('sb-group-styles')) {
const style = document.createElement('style');
style.id = 'sb-group-styles';
style.textContent = `
.sb-group { margin: 10px 0 2px; }
.sb-group-hdr {
width: 100%; padding: 6px 14px 4px; border: none; background: none;
display: flex; align-items: center; justify-content: space-between;
font-family: 'Manrope', system-ui, sans-serif;
font-size: 0.68rem; font-weight: 800; letter-spacing: 0.08em;
color: var(--text-3, #56687A); text-transform: uppercase;
cursor: pointer; transition: color .12s, opacity .12s;
opacity: 0.72;
}
.sb-group-hdr:hover { color: var(--violet, #9B5DE5); opacity: 1; }
.sb-group-chev {
width: 12px; height: 12px; transition: transform .18s;
}
.sb-group.collapsed .sb-group-chev { transform: rotate(-90deg); }
.sb-group-body {
display: flex; flex-direction: column;
max-height: 800px; overflow: hidden;
transition: max-height .25s ease, opacity .18s;
opacity: 1;
}
.sb-group.collapsed .sb-group-body {
max-height: 0; opacity: 0; pointer-events: none;
}
/* When sidebar collapsed (icon-only mode) — hide group headers */
.app-layout.sb-collapsed .sb-group-hdr { display: none; }
.app-layout.sb-collapsed .sb-group-body { max-height: none !important; opacity: 1 !important; pointer-events: auto !important; }
/* ── Онлайн-урок: всегда заметный пункт + live-индикатор ── */
.sb-link-cr .sb-icon { color: var(--violet, #9B5DE5); }
.sb-link-cr:not(.active) { font-weight: 700; }
.sb-cr-live {
display: none; align-items: center; gap: 5px; margin-left: auto; flex-shrink: 0;
padding: 3px 8px; border-radius: 999px;
font-size: 0.56rem; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase;
color: #fff; background: linear-gradient(135deg, #F15BB5, #9B5DE5);
}
.sb-cr-live::before {
content: ''; width: 6px; height: 6px; border-radius: 50%; background: #fff;
animation: sb-cr-pulse 1.15s ease-in-out infinite;
}
@keyframes sb-cr-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .3; transform: scale(.6); } }
.sb-link-cr.is-live { background: linear-gradient(90deg, rgba(241,91,181,.16), rgba(155,93,229,.05)); color: var(--text, #0F172A); }
.sb-link-cr.is-live .sb-icon { color: #F15BB5; }
.sb-link-cr.is-live .sb-cr-live { display: inline-flex; }
.sb-link-cr.is-live::after {
content: ''; position: absolute; left: 0; top: 5px; bottom: 5px; width: 3px;
border-radius: 0 3px 3px 0; background: linear-gradient(180deg, #F15BB5, #9B5DE5);
}
/* Свёрнутый сайдбар (только иконки): бейдж прячем, показываем точку-пульс */
.app-layout.sb-collapsed .sb-cr-live { display: none !important; }
.app-layout.sb-collapsed .sb-link-cr.is-live::after {
left: auto; right: 9px; top: 8px; bottom: auto; width: 8px; height: 8px;
border-radius: 50%; background: #F15BB5; animation: sb-cr-pulse 1.15s ease-in-out infinite;
}
`;
document.head.appendChild(style);
}
// Онлайн-урок: бейдж «В эфире» (скрыт; включается классом .is-live из api.js по SSE)
const crLink = el.querySelector('#btn-classroom');
if (crLink) crLink.insertAdjacentHTML('beforeend', '<span class="sb-cr-live">В эфире</span>');
// Toggle handler (global so onclick works)
window.__lsSbToggle = function (slug) {
const g = el.querySelector(`[data-sb-group="${slug}"]`);
if (!g) return;
const collapsed = g.classList.toggle('collapsed');
try { localStorage.setItem('ls_sb_g_' + slug, collapsed ? '1' : '0'); } catch {}
};
// Insert notif-drop sibling after <aside> if not already present
if (!document.getElementById('notif-drop')) {
const nd = document.createElement('div');
nd.className = 'notif-drop';
nd.id = 'notif-drop';
el.insertAdjacentElement('afterend', nd);
}
// Apply collapsed state
if (localStorage.getItem('ls_sb_collapsed') === '1') {
document.querySelector('.app-layout')?.classList.add('sb-collapsed');
}
// Wire sidebar toggle
el.querySelector('.sb-toggle')?.addEventListener('click', () => {
const layout = document.querySelector('.app-layout');
if (!layout) return;
const collapsed = layout.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '0');
});
// Re-render Lucide icons
if (window.lucide) lucide.createIcons();
// Async: board visibility, feature flags, notifications
if (typeof LS !== 'undefined') {
LS.showBoardIfAllowed?.();
LS.hideDisabledFeatures?.();
LS.notif?.init?.();
// Синхронно по кэш-состоянию (CSS уже инъектнут до сборки) — прячем пустые
// группы сразу, без мигания; hideDisabledFeatures повторит после свежих данных.
LS.hideEmptySidebarGroups?.();
}
// Глобальная плавающая кнопка «создать карточку» (на всех страницах с шапкой)
if (typeof LS !== 'undefined' && LS.isLoggedIn?.() && !document.getElementById('fc-fab-loader')) {
const s = document.createElement('script');
s.id = 'fc-fab-loader';
s.src = '/js/flashcard-fab.js';
s.defer = true;
document.body.appendChild(s);
}
// Квантик-ассистент — плавающий помощник на всех страницах с шапкой
if (typeof LS !== 'undefined' && LS.isLoggedIn?.() && !document.getElementById('asst-loader')) {
const s = document.createElement('script');
s.id = 'asst-loader';
s.src = '/js/assistant.js';
s.defer = true;
document.body.appendChild(s);
}
})();