dana-medical-site/internal/api/adminui/banners.js
2026-10-14 13:33:13 +00:00

190 lines
9.6 KiB
JavaScript
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.

/* «Слайды на главной»: шапка-витрина, превью слайдов, фильтр по статусу, порядок перетаскиванием.
Форма слайда — прежние bannerFields / normalizeBanner / editBanner. */
const BN_THEME_BG = { navy: '#2f4a68', lilac: '#8f86c9', orange: '#f08a3c' };
const BN_ICONS = {
grip: '<path d="M5 8h14M5 12h14M5 16h14"/>',
pencil: '<path d="M15.2 5.2l3.6 3.6M4.5 19.5l1-4.4L15.9 4.7a1.8 1.8 0 0 1 2.5 0l.9.9a1.8 1.8 0 0 1 0 2.5L8.9 18.5z"/>',
dots: '<circle cx="6" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="18" cy="12" r="1.3"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
};
const bnIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${BN_ICONS[n]}</svg>`;
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') + '<span>Изменить</span>';
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();