Files
Learn_System/frontend/wishes.html
T
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

243 lines
12 KiB
HTML
Raw 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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Пожелания — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; }
.container { max-width: 820px; margin: 0 auto; padding: 28px 32px 100px; }
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; color: #0F172A; margin-bottom: 6px; }
.page-sub { font-size: 0.82rem; color: var(--text-3); margin-bottom: 22px; }
/* submit card */
.wf-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 18px 20px; margin-bottom: 24px; }
.wf-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.wf-inp, .wf-sel, .wf-area {
width: 100%; padding: 9px 12px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.86rem; color: #0F172A; outline: none; transition: border-color .15s;
}
.wf-inp:focus, .wf-sel:focus, .wf-area:focus { border-color: var(--violet); }
.wf-area { min-height: 70px; resize: vertical; }
.wf-sel { cursor: pointer; }
.wf-actions { display: flex; justify-content: flex-end; gap: 10px; }
/* filters */
.w-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
.w-fchip { padding: 6px 13px; border-radius: 999px; border: 1.5px solid rgba(15,23,42,0.1); background: transparent;
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all .15s; }
.w-fchip:hover { border-color: var(--violet); color: var(--violet); }
.w-fchip.active { background: rgba(155,93,229,0.08); border-color: var(--violet); color: var(--violet); }
/* wish list */
.w-list { display: flex; flex-direction: column; gap: 12px; }
.w-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 16px 18px; border-left: 3px solid var(--wc, #9B5DE5); }
.w-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.w-title { font-size: 0.92rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; }
.w-badge { font-size: 0.68rem; font-weight: 700; padding: 3px 10px; border-radius: 999px; white-space: nowrap; }
.wb-new { background: rgba(6,214,224,0.12); color: #06aab3; }
.wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.wb-in_progress{ background: rgba(255,179,71,0.14); color: #d97706; }
.wb-done { background: rgba(5,150,82,0.12); color: #059652; }
.wb-declined { background: rgba(15,23,42,0.07); color: var(--text-3); }
.w-cat { font-size: 0.7rem; font-weight: 700; color: var(--text-3); }
.w-meta { font-size: 0.72rem; color: var(--text-3); }
.w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.5; margin-top: 4px; white-space: pre-wrap; word-break: break-word; }
.w-note { font-size: 0.8rem; color: #0F172A; background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.18);
border-radius: 10px; padding: 8px 12px; margin-top: 10px; line-height: 1.5; }
.w-note b { color: var(--violet); }
.w-author { font-size: 0.72rem; font-weight: 700; color: var(--violet); }
/* admin manage */
.w-manage { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(15,23,42,0.1); }
.w-manage .wf-sel { width: auto; min-width: 150px; }
.w-manage .wf-area { flex: 1; min-width: 200px; min-height: 40px; }
.w-btn { padding: 7px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12); background: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700; color: #3D4F6B; cursor: pointer; transition: all .15s; }
.w-btn:hover { border-color: var(--violet); color: var(--violet); }
.w-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
.w-btn-primary:hover { opacity: .9; color: #fff; }
.w-btn-danger { border-color: rgba(239,71,111,0.25); color: #EF476F; }
.w-btn-danger:hover { background: rgba(239,71,111,0.06); }
.w-empty { text-align: center; padding: 50px 20px; color: var(--text-3); }
@media (max-width: 600px) { .container { padding: 16px 14px 80px; } }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="container">
<div class="page-title">Пожелания по улучшению</div>
<div class="page-sub" id="w-sub">Предложите, что улучшить в системе — мы это увидим и ответим.</div>
<!-- Submit form -->
<div class="wf-card">
<div class="wf-row">
<input class="wf-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" style="flex:2;min-width:200px" />
<select class="wf-sel" id="wf-cat" style="flex:1;min-width:150px">
<option value="feature">Новая функция</option>
<option value="ui">Интерфейс</option>
<option value="content">Контент</option>
<option value="bug">Баг / ошибка</option>
<option value="other">Другое</option>
</select>
</div>
<div class="wf-row">
<textarea class="wf-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
</div>
<div class="wf-actions">
<button class="w-btn w-btn-primary" id="wf-submit" onclick="submitWish()">
<i data-lucide="send" style="width:13px;height:13px;vertical-align:-2px"></i> Отправить
</button>
</div>
</div>
<!-- Admin filters -->
<div class="w-filters" id="w-filters" style="display:none"></div>
<div class="w-list" id="w-list"><div class="w-empty">Загрузка…</div></div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
const { user, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
LS.showBoardIfAllowed();
LS.notif.init();
const CAT_LABEL = { ui: 'Интерфейс', content: 'Контент', feature: 'Новая функция', bug: 'Баг', other: 'Другое' };
const ST_LABEL = { new: 'Новое', planned: 'Запланировано', in_progress: 'В работе', done: 'Готово', declined: 'Отклонено' };
const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined'];
let _statusFilter = null;
let _wishes = [];
function fmtDate(s) {
if (!s) return '';
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z');
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
}
async function load() {
try {
const params = _statusFilter ? { status: _statusFilter } : {};
const data = await LS.wishesList(params);
_wishes = data.wishes || [];
if (data.isAdmin) renderFilters(data.counts || {});
render();
} catch (e) {
document.getElementById('w-list').innerHTML = `<div class="w-empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
}
}
function renderFilters(counts) {
const el = document.getElementById('w-filters');
el.style.display = '';
const total = Object.values(counts).reduce((a, b) => a + b, 0);
let html = `<button class="w-fchip${!_statusFilter ? ' active' : ''}" onclick="setFilter(null)">Все ${total ? '· ' + total : ''}</button>`;
html += ST_ORDER.map(s => counts[s]
? `<button class="w-fchip${_statusFilter === s ? ' active' : ''}" onclick="setFilter('${s}')">${ST_LABEL[s]} · ${counts[s]}</button>`
: '').join('');
el.innerHTML = html;
}
function setFilter(s) { _statusFilter = s; load(); }
function render() {
const el = document.getElementById('w-list');
if (!_wishes.length) {
el.innerHTML = `<div class="w-empty"><div style="opacity:.4;margin-bottom:8px"><i data-lucide="lightbulb" style="width:40px;height:40px"></i></div>${isAdmin ? 'Пожеланий пока нет.' : 'Вы ещё не оставляли пожеланий. Поделитесь идеей выше!'}</div>`;
if (window.lucide) lucide.createIcons();
return;
}
el.innerHTML = _wishes.map(cardHtml).join('');
if (window.lucide) lucide.createIcons();
}
const ST_COLOR = { new: '#06aab3', planned: '#9B5DE5', in_progress: '#d97706', done: '#059652', declined: '#94A3B8' };
function cardHtml(w) {
const author = (isAdmin && w.author_name) ? `<span class="w-author">${esc(w.author_name)}</span> · ` : '';
const noteHtml = w.admin_note ? `<div class="w-note"><b>Ответ:</b> ${esc(w.admin_note)}</div>` : '';
let manage = '';
if (isAdmin) {
const opts = ST_ORDER.map(s => `<option value="${s}"${w.status === s ? ' selected' : ''}>${ST_LABEL[s]}</option>`).join('');
manage = `<div class="w-manage">
<select class="wf-sel" id="st-${w.id}">${opts}</select>
<textarea class="wf-area" id="note-${w.id}" placeholder="Ответ автору (необязательно)…">${esc(w.admin_note || '')}</textarea>
<button class="w-btn w-btn-primary" onclick="saveWish(${w.id})">Сохранить</button>
<button class="w-btn w-btn-danger" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button>
</div>`;
} else if (w.status === 'new') {
manage = `<div class="w-manage"><button class="w-btn w-btn-danger" onclick="delWish(${w.id})"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить</button></div>`;
}
return `<div class="w-card" style="--wc:${ST_COLOR[w.status] || '#9B5DE5'}">
<div class="w-head">
<span class="w-title">${esc(w.title)}</span>
<span class="w-badge wb-${w.status}">${ST_LABEL[w.status] || w.status}</span>
</div>
<div class="w-meta">${author}<span class="w-cat">${CAT_LABEL[w.category] || w.category}</span> · ${fmtDate(w.created_at)}</div>
${w.body ? `<div class="w-body">${esc(w.body)}</div>` : ''}
${noteHtml}
${manage}
</div>`;
}
async function submitWish() {
const title = document.getElementById('wf-title').value.trim();
if (!title) { LS.toast('Введите заголовок', 'warn'); return; }
const btn = document.getElementById('wf-submit');
btn.disabled = true;
try {
await LS.wishCreate({
title,
category: document.getElementById('wf-cat').value,
body: document.getElementById('wf-body').value.trim(),
});
document.getElementById('wf-title').value = '';
document.getElementById('wf-body').value = '';
LS.toast('Пожелание отправлено — спасибо!', 'success');
_statusFilter = null;
await load();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
finally { btn.disabled = false; }
}
async function saveWish(id) {
try {
await LS.wishUpdate(id, {
status: document.getElementById('st-' + id).value,
admin_note: document.getElementById('note-' + id).value.trim(),
});
LS.toast('Сохранено', 'success');
await load();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function delWish(id) {
if (!await LS.confirm('Удалить это пожелание?', { title: 'Удаление', confirmText: 'Удалить', danger: true })) return;
try {
await LS.wishDelete(id);
_wishes = _wishes.filter(w => w.id !== id);
render();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
load();
if (window.lucide) lucide.createIcons();
</script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>