feat(wishes): редизайн страницы — удобнее и красивее

Полный фронт-редизайн /wishes (бэкенд не тронут):
- Hero с градиентной иконкой; «Поделиться идеей» — сворачиваемая форма (по умолчанию
  свёрнута, если пожелания уже есть; список сразу виден).
- Визуальный выбор категории чипами с иконками/цветом вместо select; счётчик символов.
- Статус-пилюли вверху с counts — кликабельный фильтр (для всех ролей, не только админ).
- Подбар: фильтр по категориям + живой поиск (по заголовку/тексту/автору); адаптивно
  скрывается, когда мало данных.
- Карточки: цветная иконка категории, статус-бейдж с иконкой, ответ админа в выделенном
  блоке, анимация появления, hover. Дружелюбные empty-состояния (нет идей / ничего не найдено)
  и скелетоны при загрузке.
- Клиентская фильтрация (один fetch, мгновенно) + точечные обновления списка без перезагрузки
  после создания/сохранения/удаления.

Verified: рендер-смоук 13/13 (карточки, иконки категорий, статусы, ответ, фильтры
status/cat/поиск с тогглом, empty); node --check инлайна; эмодзи нет (иконки — lucide).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 16:26:32 +03:00
parent be9fdfa703
commit efba722977
+281 -139
View File
@@ -10,62 +10,120 @@
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style> <style>
.sb-content { background: #f4f5f8; } .sb-content { background: #f4f5f8; }
.container { max-width: 820px; margin: 0 auto; padding: 28px 32px 100px; } .container { max-width: 860px; margin: 0 auto; padding: 26px 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 */ /* ── hero ── */
.wf-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 18px 20px; margin-bottom: 24px; } .wq-hero { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.wf-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; } .wq-hero-icon { width: 46px; height: 46px; border-radius: 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
.wf-inp, .wf-sel, .wf-area { background: linear-gradient(135deg, #9B5DE5, #06B6D4); color: #fff; box-shadow: 0 6px 18px rgba(155,93,229,0.3); }
width: 100%; padding: 9px 12px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px; .wq-hero-txt { flex: 1; min-width: 200px; }
font-family: 'Manrope', sans-serif; font-size: 0.86rem; color: #0F172A; outline: none; transition: border-color .15s; .page-title { font-family: 'Unbounded', sans-serif; font-size: 1.18rem; font-weight: 800; color: #0F172A; margin-bottom: 4px; }
} .page-sub { font-size: 0.82rem; color: var(--text-3); line-height: 1.5; }
.wf-inp:focus, .wf-sel:focus, .wf-area:focus { border-color: var(--violet); } .wq-new-btn { display: inline-flex; align-items: center; gap: 7px; padding: 10px 18px; border-radius: 12px; border: none;
.wf-area { min-height: 70px; resize: vertical; } background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.86rem; font-weight: 700; cursor: pointer;
.wf-sel { cursor: pointer; } transition: transform .15s, box-shadow .15s; box-shadow: 0 4px 14px rgba(155,93,229,0.28); white-space: nowrap; }
.wf-actions { display: flex; justify-content: flex-end; gap: 10px; } .wq-new-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(155,93,229,0.34); }
.wq-new-btn.open { background: #fff; color: var(--text-2); border: 1.5px solid rgba(15,23,42,0.12); box-shadow: none; }
/* filters */ /* ── submit form (collapsible) ── */
.w-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; } .wq-form { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 18px; padding: 18px 20px; margin-bottom: 22px;
.w-fchip { padding: 6px 13px; border-radius: 999px; border: 1.5px solid rgba(15,23,42,0.1); background: transparent; overflow: hidden; max-height: 600px; transition: max-height .3s ease, opacity .25s, padding .25s, margin .25s; }
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all .15s; } .wq-form.collapsed { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; margin-bottom: 0; border-width: 0; }
.w-fchip:hover { border-color: var(--violet); color: var(--violet); } .wq-flabel { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .03em; margin-bottom: 7px; }
.w-fchip.active { background: rgba(155,93,229,0.08); border-color: var(--violet); color: var(--violet); } .wq-cat-pick { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 14px; }
.wq-cat-opt { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; cursor: pointer;
border: 1.5px solid rgba(15,23,42,0.1); background: #fff; font-size: 0.78rem; font-weight: 600; color: var(--text-2); transition: all .15s; }
.wq-cat-opt:hover { border-color: var(--cc); }
.wq-cat-opt.sel { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
.wq-cat-opt i { width: 14px; height: 14px; }
.wq-inp, .wq-area { width: 100%; padding: 11px 13px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 12px;
font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: #0F172A; outline: none; transition: border-color .15s; }
.wq-inp:focus, .wq-area:focus { border-color: var(--violet); }
.wq-area { min-height: 74px; resize: vertical; margin-top: 10px; }
.wq-form-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 10px; }
.wq-counter { font-size: 0.72rem; color: var(--text-3); }
/* wish list */ /* ── stat / status filter pills ── */
.wq-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.wq-stat { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 13px; cursor: pointer;
background: #fff; border: 1.5px solid rgba(15,23,42,0.07); transition: all .15s; }
.wq-stat:hover { border-color: var(--sc, #9B5DE5); }
.wq-stat.active { border-color: var(--sc, #9B5DE5); background: color-mix(in srgb, var(--sc, #9B5DE5) 9%, #fff); }
.wq-stat-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--sc, #9B5DE5); flex-shrink: 0; }
.wq-stat-lbl { font-size: 0.78rem; font-weight: 600; color: var(--text-2); }
.wq-stat-num { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: #0F172A; }
/* ── sub-bar: category filter + search ── */
.wq-subbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.wq-cats { display: flex; gap: 6px; flex-wrap: wrap; }
.wq-cchip { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; cursor: pointer;
border: 1.5px solid rgba(15,23,42,0.1); background: transparent; font-size: 0.73rem; font-weight: 600; color: var(--text-3); transition: all .15s; }
.wq-cchip:hover { border-color: var(--cc); color: var(--cc); }
.wq-cchip.active { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
.wq-cchip i { width: 12px; height: 12px; }
.wq-search { margin-left: auto; min-width: 180px; flex: 1; max-width: 280px; padding: 8px 13px; border: 1.5px solid rgba(15,23,42,0.1);
border-radius: 11px; font-family: 'Manrope', sans-serif; font-size: 0.82rem; outline: none; transition: border-color .15s; }
.wq-search:focus { border-color: var(--violet); }
/* ── wish cards ── */
.w-list { display: flex; flex-direction: column; gap: 12px; } .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-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 15px 17px;
.w-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; } display: flex; gap: 13px; transition: box-shadow .15s, transform .15s; animation: wqIn .25s ease both; }
.w-title { font-size: 0.92rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; } .w-card:hover { box-shadow: 0 4px 16px rgba(15,23,42,0.07); }
.w-badge { font-size: 0.68rem; font-weight: 700; padding: 3px 10px; border-radius: 999px; white-space: nowrap; } @keyframes wqIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.wb-new { background: rgba(6,214,224,0.12); color: #06aab3; } .w-cat-ic { width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--cc) 13%, #fff); color: var(--cc); }
.w-cat-ic i { width: 19px; height: 19px; }
.w-main { flex: 1; min-width: 0; }
.w-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 3px; }
.w-title { font-size: 0.93rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; }
.w-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.w-badge i { width: 11px; height: 11px; }
.wb-new { background: rgba(6,182,212,0.12); color: #06aab3; }
.wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; } .wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.wb-in_progress{ background: rgba(255,179,71,0.14); color: #d97706; } .wb-in_progress{ background: rgba(245,158,11,0.15); color: #d97706; }
.wb-done { background: rgba(5,150,82,0.12); color: #059652; } .wb-done { background: rgba(5,150,82,0.13); color: #059652; }
.wb-declined { background: rgba(15,23,42,0.07); color: var(--text-3); } .wb-declined { background: rgba(15,23,42,0.07); color: #64748B; }
.w-cat { font-size: 0.7rem; font-weight: 700; color: var(--text-3); } .w-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
.w-meta { font-size: 0.72rem; color: var(--text-3); } .w-author { font-weight: 700; color: var(--violet); }
.w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.5; margin-top: 4px; white-space: pre-wrap; word-break: break-word; } .w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.55; margin-top: 6px; 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); .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; } border-radius: 11px; padding: 9px 12px; margin-top: 10px; line-height: 1.5; display: flex; gap: 8px; }
.w-note b { color: var(--violet); } .w-note i { width: 14px; height: 14px; color: var(--violet); flex-shrink: 0; margin-top: 2px; }
.w-author { font-size: 0.72rem; font-weight: 700; color: var(--violet); }
/* admin manage */ /* 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 { 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-sel { padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px; font-family: 'Manrope', sans-serif;
.w-manage .wf-area { flex: 1; min-width: 200px; min-height: 40px; } font-size: 0.8rem; color: #0F172A; cursor: pointer; outline: none; min-width: 150px; }
.w-btn { padding: 7px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12); background: #fff; .w-sel:focus { border-color: var(--violet); }
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700; color: #3D4F6B; cursor: pointer; transition: all .15s; } .w-note-inp { flex: 1; min-width: 200px; padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; outline: none; resize: vertical; min-height: 38px; }
.w-note-inp:focus { border-color: var(--violet); }
.w-btn { display: inline-flex; align-items: center; gap: 5px; padding: 8px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12);
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; color: var(--text-2); cursor: pointer; transition: all .15s; }
.w-btn:hover { border-color: var(--violet); color: var(--violet); } .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 { background: var(--grad-1); color: #fff; border-color: transparent; }
.w-btn-primary:hover { opacity: .9; color: #fff; } .w-btn-primary:hover { opacity: .9; color: #fff; }
.w-btn-danger { border-color: rgba(239,71,111,0.25); color: #EF476F; } .w-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.w-btn-danger:hover { background: rgba(239,71,111,0.06); } .w-btn-icon { padding: 8px; color: var(--text-3); }
.w-btn-icon:hover { background: rgba(239,71,111,0.08); color: #EF476F; border-color: rgba(239,71,111,0.25); }
.w-empty { text-align: center; padding: 50px 20px; color: var(--text-3); } /* empty / skeleton */
@media (max-width: 600px) { .container { padding: 16px 14px 80px; } } .w-empty { text-align: center; padding: 54px 20px; color: var(--text-3); }
.w-empty-art { width: 80px; height: 80px; margin: 0 auto 14px; border-radius: 22px; display: flex; align-items: center; justify-content: center;
background: rgba(155,93,229,0.08); color: var(--violet); }
.w-empty-art i { width: 38px; height: 38px; }
.w-empty-t { font-size: 0.92rem; font-weight: 700; color: var(--text-2); margin-bottom: 4px; }
.w-empty-s { font-size: 0.8rem; }
.w-skel { height: 78px; border-radius: 16px; background: linear-gradient(90deg,#eef0f4 25%,#f6f7f9 50%,#eef0f4 75%); background-size: 200% 100%; animation: wqShim 1.3s infinite; }
@keyframes wqShim { to { background-position: -200% 0; } }
@media (max-width: 600px) {
.container { padding: 16px 14px 80px; }
.wq-new-btn { width: 100%; justify-content: center; }
.wq-search { margin-left: 0; max-width: none; }
.w-card { padding: 13px; gap: 10px; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -74,35 +132,41 @@
<div class="notif-drop" id="notif-drop"></div> <div class="notif-drop" id="notif-drop"></div>
<div class="sb-content"> <div class="sb-content">
<div class="container"> <div class="container">
<div class="wq-hero">
<div class="wq-hero-icon"><i data-lucide="lightbulb" style="width:24px;height:24px"></i></div>
<div class="wq-hero-txt">
<div class="page-title">Пожелания по улучшению</div> <div class="page-title">Пожелания по улучшению</div>
<div class="page-sub" id="w-sub">Предложите, что улучшить в системе — мы это увидим и ответим.</div> <div class="page-sub" id="w-sub">Есть идея, как сделать систему лучше? Расскажите — мы прочитаем и ответим.</div>
</div>
<button class="wq-new-btn" id="wq-new-btn" onclick="toggleForm()">
<i data-lucide="plus" style="width:15px;height:15px"></i> <span id="wq-new-lbl">Поделиться идеей</span>
</button>
</div>
<!-- Submit form --> <!-- Submit form -->
<div class="wf-card"> <div class="wq-form collapsed" id="wq-form">
<div class="wf-row"> <div class="wq-flabel">Категория</div>
<input class="wf-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" style="flex:2;min-width:200px" /> <div class="wq-cat-pick" id="wq-cat-pick"></div>
<select class="wf-sel" id="wf-cat" style="flex:1;min-width:150px"> <input class="wq-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" oninput="updCounter()" />
<option value="feature">Новая функция</option> <textarea class="wq-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
<option value="ui">Интерфейс</option> <div class="wq-form-foot">
<option value="content">Контент</option> <span class="wq-counter" id="wf-counter">0 / 200</span>
<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()"> <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> Отправить <i data-lucide="send" style="width:14px;height:14px"></i> Отправить
</button> </button>
</div> </div>
</div> </div>
<!-- Admin filters --> <div class="wq-stats" id="wq-stats"></div>
<div class="w-filters" id="w-filters" style="display:none"></div> <div class="wq-subbar" id="wq-subbar" style="display:none">
<div class="wq-cats" id="wq-cats"></div>
<input class="wq-search" id="wq-search" placeholder="Поиск по пожеланиям…" oninput="onSearch(this.value)" />
</div>
<div class="w-list" id="w-list"><div class="w-empty">Загрузка…</div></div> <div class="w-list" id="w-list">
<div class="w-skel"></div><div class="w-skel"></div><div class="w-skel"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -116,81 +180,53 @@
LS.showBoardIfAllowed(); LS.showBoardIfAllowed();
LS.notif.init(); LS.notif.init();
const CAT_LABEL = { ui: 'Интерфейс', content: 'Контент', feature: 'Новая функция', bug: 'Баг', other: 'Другое' }; const CAT = {
const ST_LABEL = { new: 'Новое', planned: 'Запланировано', in_progress: 'В работе', done: 'Готово', declined: 'Отклонено' }; feature: { label: 'Новая функция', icon: 'sparkles', color: '#9B5DE5' },
ui: { label: 'Интерфейс', icon: 'layout-panel-top', color: '#06B6D4' },
content: { label: 'Контент', icon: 'book-open', color: '#2563EB' },
bug: { label: 'Баг / ошибка', icon: 'bug', color: '#EF476F' },
other: { label: 'Другое', icon: 'message-circle', color: '#64748B' },
};
const CAT_ORDER = ['feature', 'ui', 'content', 'bug', 'other'];
const ST = {
new: { label: 'Новое', icon: 'sparkle', color: '#06aab3' },
planned: { label: 'Запланировано', icon: 'calendar-clock', color: '#9B5DE5' },
in_progress: { label: 'В работе', icon: 'loader', color: '#d97706' },
done: { label: 'Готово', icon: 'check-circle-2', color: '#059652' },
declined: { label: 'Отклонено', icon: 'x-circle', color: '#64748B' },
};
const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined']; const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined'];
let _statusFilter = null;
let _wishes = []; let _wishes = [], _statusFilter = null, _catFilter = null, _q = '', _formCat = 'feature', _formOpen = false;
function fmtDate(s) { function fmtDate(s) {
if (!s) return ''; if (!s) return '';
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z'); const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z');
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' }); return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
} }
function icons() { if (window.lucide) lucide.createIcons(); }
async function load() { /* ── form ── */
try { function renderCatPick() {
const params = _statusFilter ? { status: _statusFilter } : {}; document.getElementById('wq-cat-pick').innerHTML = CAT_ORDER.map(k =>
const data = await LS.wishesList(params); `<button type="button" class="wq-cat-opt${_formCat === k ? ' sel' : ''}" style="--cc:${CAT[k].color}" onclick="pickCat('${k}')">
_wishes = data.wishes || []; <i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
if (data.isAdmin) renderFilters(data.counts || {}); icons();
render();
} catch (e) {
document.getElementById('w-list').innerHTML = `<div class="w-empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
} }
function pickCat(k) { _formCat = k; renderCatPick(); }
function updCounter() {
const n = document.getElementById('wf-title').value.length;
document.getElementById('wf-counter').textContent = n + ' / 200';
} }
function toggleForm(forceOpen) {
function renderFilters(counts) { _formOpen = forceOpen === undefined ? !_formOpen : forceOpen;
const el = document.getElementById('w-filters'); document.getElementById('wq-form').classList.toggle('collapsed', !_formOpen);
el.style.display = ''; const btn = document.getElementById('wq-new-btn');
const total = Object.values(counts).reduce((a, b) => a + b, 0); btn.classList.toggle('open', _formOpen);
let html = `<button class="w-fchip${!_statusFilter ? ' active' : ''}" onclick="setFilter(null)">Все ${total ? '· ' + total : ''}</button>`; document.getElementById('wq-new-lbl').textContent = _formOpen ? 'Свернуть' : 'Поделиться идеей';
html += ST_ORDER.map(s => counts[s] btn.querySelector('i').setAttribute('data-lucide', _formOpen ? 'chevron-up' : 'plus');
? `<button class="w-fchip${_statusFilter === s ? ' active' : ''}" onclick="setFilter('${s}')">${ST_LABEL[s]} · ${counts[s]}</button>` icons();
: '').join(''); if (_formOpen) setTimeout(() => document.getElementById('wf-title').focus(), 80);
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() { async function submitWish() {
@@ -199,28 +235,133 @@
const btn = document.getElementById('wf-submit'); const btn = document.getElementById('wf-submit');
btn.disabled = true; btn.disabled = true;
try { try {
await LS.wishCreate({ const row = await LS.wishCreate({ title, category: _formCat, body: document.getElementById('wf-body').value.trim() });
title, if (isAdmin && user) { row.author_name = user.name; }
category: document.getElementById('wf-cat').value, _wishes.unshift(row);
body: document.getElementById('wf-body').value.trim(),
});
document.getElementById('wf-title').value = ''; document.getElementById('wf-title').value = '';
document.getElementById('wf-body').value = ''; document.getElementById('wf-body').value = '';
_formCat = 'feature'; renderCatPick(); updCounter();
toggleForm(false);
LS.toast('Пожелание отправлено — спасибо!', 'success'); LS.toast('Пожелание отправлено — спасибо!', 'success');
_statusFilter = null; _statusFilter = null; _catFilter = null;
await load(); renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
finally { btn.disabled = false; } finally { btn.disabled = false; }
} }
/* ── load + render ── */
async function load() {
try {
const data = await LS.wishesList();
_wishes = data.wishes || [];
renderAll();
} catch (e) {
document.getElementById('w-list').innerHTML = `<div class="w-empty"><div class="w-empty-t">Не удалось загрузить</div><div class="w-empty-s">${esc(e.message || '')}</div></div>`;
}
}
function counts() {
const c = {}; ST_ORDER.forEach(s => c[s] = 0);
_wishes.forEach(w => { c[w.status] = (c[w.status] || 0) + 1; });
return c;
}
function renderAll() { renderStats(); renderSubbar(); renderList(); }
function renderStats() {
const c = counts();
const total = _wishes.length;
let html = `<button class="wq-stat${!_statusFilter ? ' active' : ''}" style="--sc:#9B5DE5" onclick="setStatus(null)">
<span class="wq-stat-lbl">Все</span><span class="wq-stat-num">${total}</span></button>`;
html += ST_ORDER.filter(s => c[s] > 0).map(s =>
`<button class="wq-stat${_statusFilter === s ? ' active' : ''}" style="--sc:${ST[s].color}" onclick="setStatus('${s}')">
<span class="wq-stat-dot"></span><span class="wq-stat-lbl">${ST[s].label}</span><span class="wq-stat-num">${c[s]}</span></button>`).join('');
document.getElementById('wq-stats').innerHTML = html;
}
function renderSubbar() {
const cats = [...new Set(_wishes.map(w => w.category))];
const bar = document.getElementById('wq-subbar');
// показываем подбар только если есть смысл (несколько категорий или много пожеланий)
if (cats.length < 2 && _wishes.length < 4) { bar.style.display = 'none'; return; }
bar.style.display = '';
document.getElementById('wq-cats').innerHTML = CAT_ORDER.filter(k => cats.includes(k)).map(k =>
`<button class="wq-cchip${_catFilter === k ? ' active' : ''}" style="--cc:${CAT[k].color}" onclick="setCat('${k}')">
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
document.getElementById('wq-search').style.display = _wishes.length >= 4 ? '' : 'none';
icons();
}
function setStatus(s) { _statusFilter = (_statusFilter === s) ? null : s; renderAll(); }
function setCat(k) { _catFilter = (_catFilter === k) ? null : k; renderAll(); }
function onSearch(v) { _q = v.trim().toLowerCase(); renderList(); }
function renderList() {
const el = document.getElementById('w-list');
let list = _wishes;
if (_statusFilter) list = list.filter(w => w.status === _statusFilter);
if (_catFilter) list = list.filter(w => w.category === _catFilter);
if (_q) list = list.filter(w =>
(w.title || '').toLowerCase().includes(_q) ||
(w.body || '').toLowerCase().includes(_q) ||
(w.author_name || '').toLowerCase().includes(_q));
if (!list.length) {
const fresh = !_wishes.length;
el.innerHTML = `<div class="w-empty">
<div class="w-empty-art"><i data-lucide="${fresh ? 'lightbulb' : 'search-x'}"></i></div>
<div class="w-empty-t">${fresh ? (isAdmin ? 'Пожеланий пока нет' : 'У вас пока нет пожеланий') : 'Ничего не найдено'}</div>
<div class="w-empty-s">${fresh ? (isAdmin ? 'Они появятся здесь, когда пользователи их оставят.' : 'Поделитесь идеей — нажмите «Поделиться идеей» выше.') : 'Попробуйте изменить фильтр или запрос.'}</div>
</div>`;
icons();
return;
}
el.innerHTML = list.map(cardHtml).join('');
icons();
}
function cardHtml(w) {
const cat = CAT[w.category] || CAT.other;
const st = ST[w.status] || ST.new;
const author = (isAdmin && w.author_name) ? `<span class="w-author">${esc(w.author_name)}</span><span>·</span>` : '';
const note = w.admin_note ? `<div class="w-note"><i data-lucide="message-square-reply"></i><div><b>Ответ:</b> ${esc(w.admin_note)}</div></div>` : '';
let manage = '';
if (isAdmin) {
const opts = ST_ORDER.map(s => `<option value="${s}"${w.status === s ? ' selected' : ''}>${ST[s].label}</option>`).join('');
manage = `<div class="w-manage">
<select class="w-sel" id="st-${w.id}">${opts}</select>
<textarea class="w-note-inp" id="note-${w.id}" placeholder="Ответ автору (необязательно)…">${esc(w.admin_note || '')}</textarea>
<button class="w-btn w-btn-primary" onclick="saveWish(${w.id})"><i data-lucide="check" style="width:13px;height:13px"></i> Сохранить</button>
<button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>
</div>`;
} else if (w.status === 'new') {
manage = `<div class="w-manage"><button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i> Удалить</button></div>`;
}
return `<div class="w-card" style="--cc:${cat.color}">
<div class="w-cat-ic"><i data-lucide="${cat.icon}"></i></div>
<div class="w-main">
<div class="w-head">
<span class="w-title">${esc(w.title)}</span>
<span class="w-badge wb-${w.status}"><i data-lucide="${st.icon}"></i>${st.label}</span>
</div>
<div class="w-meta">${author}<span>${cat.label}</span><span>·</span><span>${fmtDate(w.created_at)}</span></div>
${w.body ? `<div class="w-body">${esc(w.body)}</div>` : ''}
${note}
${manage}
</div>
</div>`;
}
async function saveWish(id) { async function saveWish(id) {
try { try {
await LS.wishUpdate(id, { const upd = await LS.wishUpdate(id, {
status: document.getElementById('st-' + id).value, status: document.getElementById('st-' + id).value,
admin_note: document.getElementById('note-' + id).value.trim(), admin_note: document.getElementById('note-' + id).value.trim(),
}); });
const i = _wishes.findIndex(w => w.id === id);
if (i >= 0) { _wishes[i] = { ..._wishes[i], ...upd }; }
LS.toast('Сохранено', 'success'); LS.toast('Сохранено', 'success');
await load(); renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} }
@@ -229,12 +370,13 @@
try { try {
await LS.wishDelete(id); await LS.wishDelete(id);
_wishes = _wishes.filter(w => w.id !== id); _wishes = _wishes.filter(w => w.id !== id);
render(); renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} }
renderCatPick();
load(); load();
if (window.lucide) lucide.createIcons(); icons();
</script> </script>
<script src="/js/search.js"></script> <script src="/js/search.js"></script>
<script src="/js/mobile.js"></script> <script src="/js/mobile.js"></script>