Files
Learn_System/frontend/wishes.html
T
Maxim Dolgolyov efba722977 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>
2026-06-23 16:26:32 +03:00

385 lines
23 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: 860px; margin: 0 auto; padding: 26px 32px 100px; }
/* ── hero ── */
.wq-hero { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.wq-hero-icon { width: 46px; height: 46px; border-radius: 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #9B5DE5, #06B6D4); color: #fff; box-shadow: 0 6px 18px rgba(155,93,229,0.3); }
.wq-hero-txt { flex: 1; min-width: 200px; }
.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; }
.wq-new-btn { display: inline-flex; align-items: center; gap: 7px; padding: 10px 18px; border-radius: 12px; border: none;
background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.86rem; font-weight: 700; cursor: pointer;
transition: transform .15s, box-shadow .15s; box-shadow: 0 4px 14px rgba(155,93,229,0.28); white-space: nowrap; }
.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; }
/* ── submit form (collapsible) ── */
.wq-form { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 18px; padding: 18px 20px; margin-bottom: 22px;
overflow: hidden; max-height: 600px; transition: max-height .3s ease, opacity .25s, padding .25s, margin .25s; }
.wq-form.collapsed { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; margin-bottom: 0; border-width: 0; }
.wq-flabel { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .03em; margin-bottom: 7px; }
.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); }
/* ── 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-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 15px 17px;
display: flex; gap: 13px; transition: box-shadow .15s, transform .15s; animation: wqIn .25s ease both; }
.w-card:hover { box-shadow: 0 4px 16px rgba(15,23,42,0.07); }
@keyframes wqIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.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-in_progress{ background: rgba(245,158,11,0.15); color: #d97706; }
.wb-done { background: rgba(5,150,82,0.13); color: #059652; }
.wb-declined { background: rgba(15,23,42,0.07); color: #64748B; }
.w-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
.w-author { font-weight: 700; color: var(--violet); }
.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);
border-radius: 11px; padding: 9px 12px; margin-top: 10px; line-height: 1.5; display: flex; gap: 8px; }
.w-note i { width: 14px; height: 14px; color: var(--violet); flex-shrink: 0; margin-top: 2px; }
/* 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-sel { 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; color: #0F172A; cursor: pointer; outline: none; min-width: 150px; }
.w-sel:focus { border-color: var(--violet); }
.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-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
.w-btn-primary:hover { opacity: .9; color: #fff; }
.w-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.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); }
/* empty / skeleton */
.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>
</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="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-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 -->
<div class="wq-form collapsed" id="wq-form">
<div class="wq-flabel">Категория</div>
<div class="wq-cat-pick" id="wq-cat-pick"></div>
<input class="wq-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" oninput="updCounter()" />
<textarea class="wq-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
<div class="wq-form-foot">
<span class="wq-counter" id="wf-counter">0 / 200</span>
<button class="w-btn w-btn-primary" id="wf-submit" onclick="submitWish()">
<i data-lucide="send" style="width:14px;height:14px"></i> Отправить
</button>
</div>
</div>
<div class="wq-stats" id="wq-stats"></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-skel"></div><div class="w-skel"></div><div class="w-skel"></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 = {
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'];
let _wishes = [], _statusFilter = null, _catFilter = null, _q = '', _formCat = 'feature', _formOpen = false;
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' });
}
function icons() { if (window.lucide) lucide.createIcons(); }
/* ── form ── */
function renderCatPick() {
document.getElementById('wq-cat-pick').innerHTML = CAT_ORDER.map(k =>
`<button type="button" class="wq-cat-opt${_formCat === k ? ' sel' : ''}" style="--cc:${CAT[k].color}" onclick="pickCat('${k}')">
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
icons();
}
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) {
_formOpen = forceOpen === undefined ? !_formOpen : forceOpen;
document.getElementById('wq-form').classList.toggle('collapsed', !_formOpen);
const btn = document.getElementById('wq-new-btn');
btn.classList.toggle('open', _formOpen);
document.getElementById('wq-new-lbl').textContent = _formOpen ? 'Свернуть' : 'Поделиться идеей';
btn.querySelector('i').setAttribute('data-lucide', _formOpen ? 'chevron-up' : 'plus');
icons();
if (_formOpen) setTimeout(() => document.getElementById('wf-title').focus(), 80);
}
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 {
const row = await LS.wishCreate({ title, category: _formCat, body: document.getElementById('wf-body').value.trim() });
if (isAdmin && user) { row.author_name = user.name; }
_wishes.unshift(row);
document.getElementById('wf-title').value = '';
document.getElementById('wf-body').value = '';
_formCat = 'feature'; renderCatPick(); updCounter();
toggleForm(false);
LS.toast('Пожелание отправлено — спасибо!', 'success');
_statusFilter = null; _catFilter = null;
renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
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) {
try {
const upd = await LS.wishUpdate(id, {
status: document.getElementById('st-' + id).value,
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');
renderAll();
} 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);
renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
renderCatPick();
load();
icons();
</script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>