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