/* «Слайды на главной»: шапка-витрина, превью слайдов, фильтр по статусу, порядок перетаскиванием. Форма слайда — прежние bannerFields / normalizeBanner / editBanner. */ const BN_THEME_BG = { navy: '#2f4a68', lilac: '#8f86c9', orange: '#f08a3c' }; const BN_ICONS = { grip: '', pencil: '', dots: '', plus: '', }; const bnIcon = (n) => ``; let bannersCache = []; const Banners = { state(b) { const now = Date.now(); if (!b.published) return ['hidden', 'Скрыт', 'grey']; if (b.ends_at && new Date(b.ends_at).getTime() <= now) return ['expired', 'Показ окончен', 'off']; if (b.starts_at && new Date(b.starts_at).getTime() > now) return ['waiting', 'Скоро', 'wait']; return ['live', 'На сайте', '']; }, render() { const f = $('#bn-filter').value; const list = bannersCache.filter((b) => !f || Banners.state(b)[0] === f); const live = bannersCache.filter((b) => Banners.state(b)[0] === 'live').length; $('#bn-count').textContent = `${list.length} ${plural(list.length, 'слайд', 'слайда', 'слайдов')}`; $('#bn-live').textContent = `на сайте сейчас: ${live}`; const tb = $('#bn-body'); tb.textContent = ''; if (!list.length) { const tr = el('tr'); tr.append(el('td', { colSpan: 9, className: 'muted dr-empty', textContent: 'Слайдов с таким статусом нет.' })); tb.append(tr); } // перетаскивать можно только полный список: иначе порядок скрытых строк непонятен const draggable = !f; for (const b of list) tb.append(Banners.row(b, draggable)); }, thumb(b) { const box = el('div', { className: 'bn-thumb' }); if (b.layout === 'image' && b.image) { box.append(el('img', { src: b.image, alt: '', loading: 'lazy' })); return box; } box.classList.add('bn-thumb--made'); box.style.background = BN_THEME_BG[b.theme] || BN_THEME_BG.navy; box.append(el('b', { textContent: b.title || 'Без заголовка' })); if (b.subtitle) box.append(el('small', { textContent: b.subtitle })); if (b.layout === 'doctor' && b.image) box.append(el('img', { className: 'bn-thumb__doc', src: b.image, alt: '', loading: 'lazy' })); return box; }, row(b, draggable) { const tr = el('tr'); tr.dataset.id = b.id; const grip = el('td', { className: 'bn-grip' }); if (draggable) { const h = el('button', { type: 'button', className: 'grip', title: 'Перетащите, чтобы поменять порядок' }); h.setAttribute('aria-label', 'Переместить слайд «' + (b.title || 'картинка') + '». Стрелки вверх и вниз меняют порядок'); h.innerHTML = bnIcon('grip'); h.onkeydown = (e) => { if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; e.preventDefault(); Banners.move(b.id, e.key === 'ArrowUp' ? -1 : 1); }; grip.append(h); tr.draggable = true; tr.addEventListener('dragstart', (e) => { Banners.dragId = b.id; tr.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); tr.addEventListener('dragend', () => { tr.classList.remove('dragging'); Banners.dragId = null; }); tr.addEventListener('dragover', (e) => { if (Banners.dragId && Banners.dragId !== b.id) { e.preventDefault(); tr.classList.add('drop'); } }); tr.addEventListener('dragleave', () => tr.classList.remove('drop')); tr.addEventListener('drop', (e) => { e.preventDefault(); tr.classList.remove('drop'); Banners.dropOn(b.id); }); } const pic = el('td'); pic.append(Banners.thumb(b)); const title = el('td', { className: 'bn-title' }); title.append(el('b', { textContent: b.title || '(картинка без заголовка)' })); const from = ruDate(localDate(b.starts_at)), to = ruDate(localDate(b.ends_at)); const show = from && to ? `${from} —\n${to}` : to ? 'до ' + to : from ? 'с ' + from : 'всегда'; const sort = el('td'); sort.append(el('span', { className: 'bn-sort', textContent: b.sort })); const [, label, cls] = Banners.state(b); const st = el('td'); st.append(el('span', { className: 'pill bn-pill ' + cls, textContent: label })); const act = el('td', { className: 'dr-actions' }); const edit = el('button', { type: 'button', className: 'bn-edit' }); edit.innerHTML = bnIcon('pencil') + 'Изменить'; edit.onclick = () => editBanner(b); const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' }); more.setAttribute('aria-label', 'Ещё действия со слайдом'); more.setAttribute('aria-haspopup', 'menu'); more.innerHTML = bnIcon('dots'); more.onclick = (e) => { e.stopPropagation(); Banners.menu(b, more); }; act.append(edit, more); tr.append(grip, pic, title, el('td', { className: 'muted bn-small', textContent: LAYOUTS[b.layout] || b.layout }), el('td', { className: 'muted bn-small bn-link', textContent: b.link_url || '—' }), el('td', { className: 'muted bn-small bn-dates', textContent: show }), sort, st, act); return tr; }, menu(b, anchor) { Doctors.closeMenu(); const m = el('div', { className: 'menu', id: 'dr-menu', role: 'menu' }); const item = (text, fn, danger) => { const x = el('button', { type: 'button', role: 'menuitem', className: danger ? 'danger' : '', textContent: text }); x.onclick = () => { Doctors.closeMenu(); fn(); }; m.append(x); }; if (b.link_url) item('Открыть ссылку слайда', () => window.open(b.link_url, '_blank', 'noopener')); item(b.published ? 'Скрыть с сайта' : 'Показать на сайте', () => Banners.save(b, { published: !b.published }, b.published ? 'Слайд скрыт' : 'Слайд на сайте')); item('Удалить слайд', () => delBanner(b), true); document.body.append(m); const r = anchor.getBoundingClientRect(); m.style.top = (r.bottom + window.scrollY + 6) + 'px'; m.style.left = Math.max(12, r.right + window.scrollX - m.offsetWidth) + 'px'; m.querySelector('button').focus(); anchor.setAttribute('aria-expanded', 'true'); Doctors.menuAnchor = anchor; }, body(b, change = {}) { return { position: b.position || 'home_hero', layout: b.layout, theme: b.theme, title: b.title, subtitle: b.subtitle, badge: b.badge, chips: b.chips || [], image_id: b.image_id ?? null, mobile_image_id: b.mobile_image_id ?? null, link_url: b.link_url, button_text: b.button_text, disclaimer: b.disclaimer, starts_at: b.starts_at || null, ends_at: b.ends_at || null, sort: b.sort, published: b.published, ...change, }; }, async save(b, change, message) { try { await api('/admin/banners/' + b.id, { method: 'PUT', body: JSON.stringify(Banners.body(b, change)) }); toast(message); await loadBanners(); } catch (e) { toast(e.message, true); } }, // Новый порядок: 10, 20, 30… — сохраняем только слайды, у которых номер изменился. async reorder(ids) { const byId = new Map(bannersCache.map((b) => [b.id, b])); const changed = ids.map((id, i) => [byId.get(id), (i + 1) * 10]).filter(([b, sort]) => b.sort !== sort); if (!changed.length) return; try { for (const [b, sort] of changed) { await api('/admin/banners/' + b.id, { method: 'PUT', body: JSON.stringify(Banners.body(b, { sort })) }); } toast('Порядок слайдов сохранён'); } catch (e) { toast(e.message, true); } await loadBanners(); }, dropOn(targetId) { const ids = bannersCache.map((b) => b.id); const from = ids.indexOf(Banners.dragId), to = ids.indexOf(targetId); if (from < 0 || to < 0) return; ids.splice(to, 0, ids.splice(from, 1)[0]); Banners.reorder(ids); }, async move(id, dir) { const ids = bannersCache.map((b) => b.id); const i = ids.indexOf(id), j = i + dir; if (j < 0 || j >= ids.length) return; [ids[i], ids[j]] = [ids[j], ids[i]]; await Banners.reorder(ids); $(`#bn-body tr[data-id="${id}"] .grip`)?.focus(); }, // Фото холла в шапке — assets/hall.webp; без файла остаётся мягкий градиент. async hero() { const ok = await fetch('assets/hall.webp', { method: 'HEAD', credentials: 'same-origin' }).then((r) => r.ok).catch(() => false); $('#bn-hero').classList.toggle('has-photo', ok); }, }; async function loadBanners() { const r = await api('/admin/banners?position=home_hero'); bannersCache = (r.data || []).slice().sort((a, b) => a.sort - b.sort || a.id - b.id); Banners.render(); } $('#bn-filter').onchange = () => Banners.render(); $('#bn-add').insertAdjacentHTML('afterbegin', bnIcon('plus')); $('#bn-add').onclick = () => { const next = (bannersCache.reduce((m, b) => Math.max(m, b.sort), 0) || 0) + 10; openDialog('Новый слайд', bannerFields({ published: true, sort: next, layout: 'image', theme: 'navy' }), async (d) => { await api('/admin/banners', { method: 'POST', body: JSON.stringify(normalizeBanner(d)) }); toast('Слайд добавлен'); loadBanners(); }); }; Banners.hero();