/* «Слайды на главной»: шапка-витрина, превью слайдов, фильтр по статусу, порядок перетаскиванием.
Форма слайда — прежние 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();