be9fdfa703
Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание); видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам (новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор получает уведомление при смене статуса (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>
255 lines
13 KiB
JavaScript
255 lines
13 KiB
JavaScript
// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') : '';
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
})();
|