refactor
This commit is contained in:
parent
bdb2dcf263
commit
8e648752ee
270
static/script/select.js
Normal file
270
static/script/select.js
Normal file
@ -0,0 +1,270 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const root = document.getElementById('dd-modal-root') || document.body;
|
||||
|
||||
// Утилиты парсинга
|
||||
function parseList(attr) {
|
||||
if (!attr) return [];
|
||||
try {
|
||||
const fixed = attr.replace(/'/g, '"');
|
||||
return JSON.parse(fixed);
|
||||
} catch (e) {
|
||||
return String(attr).split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSavedValue(el) {
|
||||
const v = el.getAttribute('data-value');
|
||||
if (!v) return [];
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch (e) {
|
||||
return v.includes(',')
|
||||
? v.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [v];
|
||||
}
|
||||
}
|
||||
|
||||
async function openModalForInput(triggerEl) {
|
||||
const listRaw = triggerEl.getAttribute('data-list') || '[]';
|
||||
const options = parseList(listRaw);
|
||||
const isRadio = triggerEl.getAttribute('data-radio') === '1' ||
|
||||
triggerEl.getAttribute('data-radio') === 'true';
|
||||
const takeAll = triggerEl.getAttribute('data-takeall') === '1' ||
|
||||
triggerEl.getAttribute('data-takeall') === 'true';
|
||||
const allowCustom = triggerEl.getAttribute('data-custom') === '1' ||
|
||||
triggerEl.getAttribute('data-custom') === 'true';
|
||||
|
||||
const title = triggerEl.getAttribute('data-title') || 'Выберите';
|
||||
const saveLabel = triggerEl.getAttribute('data-label') || 'Сохранить';
|
||||
const customPlaceholder = triggerEl.getAttribute('data-custom_placeholder') || 'Добавить...';
|
||||
|
||||
const initial = parseSavedValue(triggerEl);
|
||||
|
||||
return new Promise(resolve => {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'dd-modal-backdrop';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'dd-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="dd-header">
|
||||
<strong>${title}</strong>
|
||||
<button class="dd-close" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<div class="dd-custom-container" style="margin-top:8px;"></div>
|
||||
<div class="dd-body"></div>
|
||||
<div class="dd-actions" style="margin-top:12px;">
|
||||
<button class="dd-save">${saveLabel}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
root.appendChild(backdrop);
|
||||
|
||||
const body = modal.querySelector('.dd-body');
|
||||
const customContainer = modal.querySelector('.dd-custom-container');
|
||||
const saveBtn = modal.querySelector('.dd-save');
|
||||
const closeBtn = modal.querySelector('.dd-close');
|
||||
|
||||
const groupName = 'dd-group-' + Date.now() + Math.random().toString(36).slice(2);
|
||||
|
||||
let currentOptions = options.slice();
|
||||
let selected = initial.slice();
|
||||
|
||||
function renderOptions() {
|
||||
body.innerHTML = '';
|
||||
|
||||
// "Выбрать все"
|
||||
if (takeAll) {
|
||||
const wrapper = createOption('Выбрать все', true, groupName, isRadio);
|
||||
const takeAllInput = wrapper.querySelector('input');
|
||||
takeAllInput.id = 'takeAll';
|
||||
takeAllInput.checked = selected.length === 0 || selected.includes('Выбрать все');
|
||||
body.appendChild(wrapper);
|
||||
}
|
||||
|
||||
currentOptions.forEach(opt => {
|
||||
const label = typeof opt === 'object' ? opt.name : String(opt);
|
||||
const tooltip = typeof opt === 'object' ? opt.tooltip : null;
|
||||
const count = typeof opt === 'object' ? opt.count : null;
|
||||
|
||||
|
||||
const wrapper = createOption(label, false, groupName, isRadio, tooltip, count);
|
||||
const input = wrapper.querySelector('input');
|
||||
input.checked = selected.includes(label);
|
||||
body.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
|
||||
function createOption(label, isTakeAll, name, isRadio, tooltip = null, count = null) {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'dd-option';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = isRadio ? 'radio' : 'checkbox';
|
||||
input.name = name;
|
||||
input.value = label;
|
||||
input.className = 'offer-checkbox';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'some-class';
|
||||
|
||||
// count
|
||||
if (count !== undefined && count !== null) {
|
||||
const countNode = document.createElement('span');
|
||||
countNode.textContent = count;
|
||||
div.appendChild(countNode);
|
||||
}
|
||||
// tooltip
|
||||
if (tooltip) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'tooltip-trigger';
|
||||
img.dataset.tooltip = tooltip;
|
||||
img.src = '/static/help.svg';
|
||||
img.alt = 'Подсказка';
|
||||
div.appendChild(img);
|
||||
}
|
||||
span.appendChild(div);
|
||||
wrapper.append(input, span);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
if (allowCustom) {
|
||||
customContainer.innerHTML = `
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-direction:column;">
|
||||
<input type="text" class="dd-custom-input" placeholder="${customPlaceholder}"
|
||||
style="flex:1;padding:6px;border:1px solid #ccc;width:100%;border-radius:4px;">
|
||||
<button class="dd-custom-add" style="padding:6px 8px;width:100%;border-radius:5px;background:#5F1798;color:white;">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const customInput = customContainer.querySelector('.dd-custom-input');
|
||||
const customAdd = customContainer.querySelector('.dd-custom-add');
|
||||
|
||||
const addCustom = (val) => {
|
||||
val = String(val).trim();
|
||||
if (!val) return;
|
||||
|
||||
if (!currentOptions.some(o => String(o) === val && typeof o !== 'object')) {
|
||||
currentOptions.push(val);
|
||||
triggerEl.setAttribute('data-list', JSON.stringify(currentOptions));
|
||||
}
|
||||
|
||||
if (isRadio) {
|
||||
selected = [val];
|
||||
} else if (!selected.includes(val)) {
|
||||
selected.push(val);
|
||||
}
|
||||
|
||||
renderOptions();
|
||||
updateCheckedState();
|
||||
};
|
||||
|
||||
customAdd.addEventListener('click', () => {
|
||||
addCustom(customInput.value);
|
||||
customInput.value = '';
|
||||
customInput.focus();
|
||||
});
|
||||
|
||||
customInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addCustom(customInput.value);
|
||||
customInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCheckedState() {
|
||||
Array.from(body.querySelectorAll('input')).forEach(inp => {
|
||||
inp.checked = selected.includes(inp.value);
|
||||
});
|
||||
}
|
||||
|
||||
function collectChecked() {
|
||||
return Array.from(body.querySelectorAll('input:checked')).map(i => i.value);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const result = collectChecked();
|
||||
triggerEl.setAttribute('data-value', isRadio ? (result[0] ?? '') : JSON.stringify(result));
|
||||
|
||||
if (triggerEl.type && triggerEl.type !== 'hidden') {
|
||||
triggerEl.value = isRadio ? (result[0] ?? '') : result.join(', ');
|
||||
}
|
||||
|
||||
backdrop.remove();
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
// Обработчики закрытия
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
saveBtn.addEventListener('click', closeModal);
|
||||
backdrop.addEventListener('click', e => {
|
||||
if (e.target === backdrop) closeModal();
|
||||
});
|
||||
|
||||
// Логика "Выбрать все"
|
||||
function setupTakeAll() {
|
||||
const takeAllInput = document.getElementById('takeAll');
|
||||
if (!takeAllInput) return;
|
||||
|
||||
const allInputs = () => Array.from(body.querySelectorAll('input.offer-checkbox'));
|
||||
|
||||
takeAllInput.addEventListener('change', () => {
|
||||
if (takeAllInput.checked) {
|
||||
selected = [];
|
||||
allInputs().forEach(inp => {
|
||||
if (inp !== takeAllInput) inp.checked = false;
|
||||
});
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Сброс "Выбрать все" при выборе других
|
||||
body.addEventListener('change', e => {
|
||||
const inp = e.target;
|
||||
if (inp.type !== 'checkbox' || inp === takeAllInput) return;
|
||||
|
||||
if (inp.checked) {
|
||||
takeAllInput.checked = false;
|
||||
} else {
|
||||
const anyChecked = allInputs().some(x => x !== takeAllInput && x.checked);
|
||||
takeAllInput.checked = !anyChecked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderOptions();
|
||||
setupTakeAll();
|
||||
updateCheckedState();
|
||||
});
|
||||
}
|
||||
|
||||
// Главный обработчик
|
||||
document.addEventListener('click', async function(e) {
|
||||
const trigger = e.target.closest('[data-select="1"]');
|
||||
if (!trigger) return;
|
||||
|
||||
const input = trigger.querySelector('input');
|
||||
if (input) {
|
||||
input.readOnly = true;
|
||||
input.blur();
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const result = await openModalForInput(trigger);
|
||||
if (result && result.length) {
|
||||
trigger.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}, true);
|
||||
|
||||
})();
|
||||
242
static/script/share.js
Normal file
242
static/script/share.js
Normal file
@ -0,0 +1,242 @@
|
||||
async function imageWithTextBlob(imageUrl, text, options = {}) {
|
||||
const response = await fetch(imageUrl, {
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const imageBlob = await response.blob();
|
||||
const imageBitmap = await createImageBitmap(imageBlob);
|
||||
// OffscreenCanvas (works in modern browsers; fallback to normal canvas if needed)
|
||||
const canvas = typeof OffscreenCanvas !== 'undefined'
|
||||
? new OffscreenCanvas(
|
||||
options.width ?? imageBitmap.width,
|
||||
options.height ?? imageBitmap.height
|
||||
)
|
||||
: document.createElement('canvas');
|
||||
const width = canvas.width ?? (options.width ?? imageBitmap.width);
|
||||
const height = canvas.height ?? (options.height ?? imageBitmap.height);
|
||||
if (!(canvas instanceof OffscreenCanvas)) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
// Draw image
|
||||
ctx.drawImage(imageBitmap, 0, 0, width, height);
|
||||
// No text: return original bitmap as blob (rendered)
|
||||
if (!text?.trim()) {
|
||||
if (canvas.convertToBlob) {
|
||||
return canvas.convertToBlob({
|
||||
type: options.type ?? 'image/png',
|
||||
quality: options.quality ?? 0.92,
|
||||
});
|
||||
}
|
||||
return await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), options.type ?? 'image/png', options.quality ?? 0.92)
|
||||
);
|
||||
}
|
||||
// === Text rendering ===
|
||||
const basePadding = 6;
|
||||
const fontSize = options.fontSize ?? Math.round(width * 0.05);
|
||||
ctx.font = `${fontSize}px ${options.fontFamily ?? 'sans-serif'}`;
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.textAlign = 'center';
|
||||
const textColor = options.textColor ?? '#5F1798';
|
||||
const bgColor = options.bgColor ?? '#fff';
|
||||
const strokeColor = options.strokeColor ?? 'rgba(0,0,0,0.35)';
|
||||
const shadowBlur = options.shadowBlur ?? 6;
|
||||
const maxWidth = width - basePadding * 2 - (options.extraPadding ?? 0);
|
||||
// Wrap text into lines
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
const lines = [];
|
||||
for (const word of words) {
|
||||
const testLine = line ? line + ' ' + word : word;
|
||||
if (ctx.measureText(testLine).width > maxWidth && line) {
|
||||
lines.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
const lineHeight = fontSize + (options.lineGap ?? 6);
|
||||
const boxHeight = lines.length * lineHeight + basePadding * 2;
|
||||
const maxLineWidth = Math.max(...lines.map((l) => ctx.measureText(l).width));
|
||||
const boxWidth = maxLineWidth + basePadding * 2;
|
||||
const bottomPadding = options.bottomPadding ?? Math.round(fontSize * 0.6);
|
||||
const boxX = (width - boxWidth) / 2;
|
||||
const boxY = height - bottomPadding - boxHeight;
|
||||
const radius = options.radius ?? 8;
|
||||
// Background rounded rect
|
||||
ctx.save();
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.shadowColor = strokeColor;
|
||||
ctx.shadowBlur = shadowBlur;
|
||||
roundRect(ctx, boxX, boxY, boxWidth, boxHeight, radius, true, false);
|
||||
ctx.restore();
|
||||
// Text
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.shadowColor = 'transparent';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineY = boxY + basePadding + (i + 1) * lineHeight - (lineHeight - fontSize) / 2;
|
||||
const lineX = width / 2;
|
||||
|
||||
if (options.outline) {
|
||||
ctx.lineWidth = options.outlineWidth ?? 2;
|
||||
ctx.strokeStyle = options.outlineColor ?? 'rgba(255,255,255,0.8)';
|
||||
ctx.strokeText(lines[i], lineX, lineY);
|
||||
}
|
||||
ctx.fillText(lines[i], lineX, lineY);
|
||||
}
|
||||
// Convert to blob
|
||||
if (canvas.convertToBlob) {
|
||||
return await canvas.convertToBlob({
|
||||
type: options.type ?? 'image/png',
|
||||
quality: options.quality ?? 0.92,
|
||||
});
|
||||
}
|
||||
return await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), options.type ?? 'image/png', options.quality ?? 0.92)
|
||||
);
|
||||
}
|
||||
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
|
||||
if (typeof r === 'number') r = { tl: r, tr: r, br: r, bl: r };
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r.tl, y);
|
||||
ctx.lineTo(x + w - r.tr, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
|
||||
ctx.lineTo(x + w, y + h - r.br);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
|
||||
ctx.lineTo(x + r.bl, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
|
||||
ctx.lineTo(x, y + r.tl);
|
||||
ctx.quadraticCurveTo(x, y, x + r.tl, y);
|
||||
ctx.closePath();
|
||||
if (fill) ctx.fill();
|
||||
if (stroke) ctx.stroke();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const shareModal = document.getElementById('shareModal');
|
||||
const closeshareModal = document.getElementById('closeModal');
|
||||
const fb = document.getElementById('fb');
|
||||
const vk = document.getElementById('vk');
|
||||
const tg = document.getElementById('tg');
|
||||
const ok = document.getElementById('ok');
|
||||
const wa = document.getElementById('wa');
|
||||
const inst = document.getElementById('inst');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const copyMsg = document.getElementById('copyMsg');
|
||||
function buildShareUrl(platform, url, text = '') {
|
||||
const u = encodeURIComponent(url);
|
||||
const t = encodeURIComponent(text);
|
||||
switch (platform) {
|
||||
case 'tw': return `https://twitter.com/intent/tweet?url=${u}&text=${t}`;
|
||||
case 'vk': return `https://vk.com/share.php?url=${u}&title=${t}`;
|
||||
case 'tg': return `https://telegram.me/share/url?url=${u}&text=${t}`;
|
||||
case 'fb': return `https://www.facebook.com/sharer/sharer.php?u=${u}"e=${t}`;
|
||||
case 'ok': return `https://connect.ok.ru/offer?url=${u}&title=${t}`;
|
||||
case 'wa': return `https://api.whatsapp.com/send?text=${t} ${u}`;
|
||||
case 'inst': return `https://www.instagram.com/?url=${u}`;
|
||||
default: return '#';
|
||||
}
|
||||
}
|
||||
const shareBtns = document.querySelectorAll('#shareBtn');
|
||||
shareBtns.forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const link = window.location.origin + btn.dataset.link || window.location.href;
|
||||
const text = btn.dataset.text || document.title;
|
||||
const image = btn.dataset.image;
|
||||
const imagetext = btn.dataset.imagetext || '';
|
||||
let blob = null;
|
||||
let file = null;
|
||||
let objectUrl = null;
|
||||
try {
|
||||
if (image) {
|
||||
const imageUrl = window.location.origin + image;
|
||||
// В обработчике кнопки
|
||||
const blob = await imageWithTextBlob(imageUrl, imagetext, {
|
||||
textColor: '#5F1798',
|
||||
});
|
||||
console.log(`Generated blob for "${imagetext}" | size: ${blob.size} | url: ${imageUrl}`);
|
||||
const filename = `shared-image-${Date.now()}.png`;
|
||||
file = new File([blob], filename, { type: blob.type });
|
||||
// Создаём object URL только если хотим (необязательно для navigator.share)
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
title: text,
|
||||
text: text,
|
||||
url: link,
|
||||
files: [file],
|
||||
});
|
||||
} else {
|
||||
await navigator.share({
|
||||
title: text,
|
||||
text: '',
|
||||
url: link,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await navigator.share({ title: text, text: '', url: link });
|
||||
}
|
||||
} catch (err) {
|
||||
openshareModal(link, text);
|
||||
} finally {
|
||||
// Очистка
|
||||
try {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
// Удаляем ссылки, чтобы GC мог собрать объекты
|
||||
blob = null;
|
||||
file = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
function openshareModal(link, text) {
|
||||
vk.href = buildShareUrl('vk', link, text);
|
||||
tg.href = buildShareUrl('tg', link, text);
|
||||
fb.href = buildShareUrl('fb', link, text);
|
||||
ok.href = buildShareUrl('ok', link, text);
|
||||
wa.href = buildShareUrl('wa', link, text);
|
||||
inst.href = buildShareUrl('inst', link, text);
|
||||
shareModal.style.display = 'flex';
|
||||
copyBtn.dataset.link = link;
|
||||
shareModal.style.display = 'flex'
|
||||
}
|
||||
closeshareModal.addEventListener('click', () => {
|
||||
shareModal.style.display = 'none';
|
||||
});
|
||||
shareModal.addEventListener('click', (e) => {
|
||||
if (e.target === shareModal) shareModal.style.display = 'none';
|
||||
});
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
showCopyMessage();
|
||||
} catch (e) {
|
||||
showToast({message: 'Не удалось скопировать ссылку', status: 'error'});
|
||||
}
|
||||
}
|
||||
function showCopyMessage() {
|
||||
copyMsg.style.display = 'inline';
|
||||
setTimeout(() => copyMsg.style.display = 'none', 2000);
|
||||
}
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const link = copyBtn.dataset.link || window.location.href;
|
||||
copyToClipboard(link);
|
||||
});
|
||||
});
|
||||
@ -171,323 +171,7 @@ function showToast(e={}){let{message:t="",status:a="info",duration:l=4e3}=e,n=do
|
||||
style="margin-left:8px;color:green;display:none;">Скопировано!</span></button></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function imageWithTextBlob(imageUrl, text, options = {}) {
|
||||
const response = await fetch(imageUrl, {
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const imageBlob = await response.blob();
|
||||
const imageBitmap = await createImageBitmap(imageBlob);
|
||||
|
||||
// OffscreenCanvas (works in modern browsers; fallback to normal canvas if needed)
|
||||
const canvas = typeof OffscreenCanvas !== 'undefined'
|
||||
? new OffscreenCanvas(
|
||||
options.width ?? imageBitmap.width,
|
||||
options.height ?? imageBitmap.height
|
||||
)
|
||||
: document.createElement('canvas');
|
||||
|
||||
const width = canvas.width ?? (options.width ?? imageBitmap.width);
|
||||
const height = canvas.height ?? (options.height ?? imageBitmap.height);
|
||||
|
||||
if (!(canvas instanceof OffscreenCanvas)) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(imageBitmap, 0, 0, width, height);
|
||||
|
||||
// No text: return original bitmap as blob (rendered)
|
||||
if (!text?.trim()) {
|
||||
if (canvas.convertToBlob) {
|
||||
return canvas.convertToBlob({
|
||||
type: options.type ?? 'image/png',
|
||||
quality: options.quality ?? 0.92,
|
||||
});
|
||||
}
|
||||
return await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), options.type ?? 'image/png', options.quality ?? 0.92)
|
||||
);
|
||||
}
|
||||
|
||||
// === Text rendering ===
|
||||
const basePadding = 6;
|
||||
const fontSize = options.fontSize ?? Math.round(width * 0.05);
|
||||
ctx.font = `${fontSize}px ${options.fontFamily ?? 'sans-serif'}`;
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
const textColor = options.textColor ?? '#5F1798';
|
||||
const bgColor = options.bgColor ?? '#fff';
|
||||
const strokeColor = options.strokeColor ?? 'rgba(0,0,0,0.35)';
|
||||
const shadowBlur = options.shadowBlur ?? 6;
|
||||
const maxWidth = width - basePadding * 2 - (options.extraPadding ?? 0);
|
||||
|
||||
// Wrap text into lines
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
const lines = [];
|
||||
for (const word of words) {
|
||||
const testLine = line ? line + ' ' + word : word;
|
||||
if (ctx.measureText(testLine).width > maxWidth && line) {
|
||||
lines.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
|
||||
const lineHeight = fontSize + (options.lineGap ?? 6);
|
||||
const boxHeight = lines.length * lineHeight + basePadding * 2;
|
||||
const maxLineWidth = Math.max(...lines.map((l) => ctx.measureText(l).width));
|
||||
const boxWidth = maxLineWidth + basePadding * 2;
|
||||
|
||||
const bottomPadding = options.bottomPadding ?? Math.round(fontSize * 0.6);
|
||||
const boxX = (width - boxWidth) / 2;
|
||||
const boxY = height - bottomPadding - boxHeight;
|
||||
const radius = options.radius ?? 8;
|
||||
|
||||
// Background rounded rect
|
||||
ctx.save();
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.shadowColor = strokeColor;
|
||||
ctx.shadowBlur = shadowBlur;
|
||||
roundRect(ctx, boxX, boxY, boxWidth, boxHeight, radius, true, false);
|
||||
ctx.restore();
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.shadowColor = 'transparent';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineY = boxY + basePadding + (i + 1) * lineHeight - (lineHeight - fontSize) / 2;
|
||||
const lineX = width / 2;
|
||||
|
||||
if (options.outline) {
|
||||
ctx.lineWidth = options.outlineWidth ?? 2;
|
||||
ctx.strokeStyle = options.outlineColor ?? 'rgba(255,255,255,0.8)';
|
||||
ctx.strokeText(lines[i], lineX, lineY);
|
||||
}
|
||||
ctx.fillText(lines[i], lineX, lineY);
|
||||
}
|
||||
|
||||
// Convert to blob
|
||||
if (canvas.convertToBlob) {
|
||||
return await canvas.convertToBlob({
|
||||
type: options.type ?? 'image/png',
|
||||
quality: options.quality ?? 0.92,
|
||||
});
|
||||
}
|
||||
return await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), options.type ?? 'image/png', options.quality ?? 0.92)
|
||||
);
|
||||
}
|
||||
|
||||
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
|
||||
if (typeof r === 'number') r = { tl: r, tr: r, br: r, bl: r };
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r.tl, y);
|
||||
ctx.lineTo(x + w - r.tr, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
|
||||
ctx.lineTo(x + w, y + h - r.br);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
|
||||
ctx.lineTo(x + r.bl, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
|
||||
ctx.lineTo(x, y + r.tl);
|
||||
ctx.quadraticCurveTo(x, y, x + r.tl, y);
|
||||
ctx.closePath();
|
||||
if (fill) ctx.fill();
|
||||
if (stroke) ctx.stroke();
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const shareModal = document.getElementById('shareModal');
|
||||
const closeshareModal = document.getElementById('closeModal');
|
||||
const fb = document.getElementById('fb');
|
||||
const vk = document.getElementById('vk');
|
||||
const tg = document.getElementById('tg');
|
||||
const ok = document.getElementById('ok');
|
||||
const wa = document.getElementById('wa');
|
||||
const inst = document.getElementById('inst');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const copyMsg = document.getElementById('copyMsg');
|
||||
function buildShareUrl(platform, url, text = '') {
|
||||
const u = encodeURIComponent(url);
|
||||
const t = encodeURIComponent(text);
|
||||
switch (platform) {
|
||||
case 'tw': return `https://twitter.com/intent/tweet?url=${u}&text=${t}`;
|
||||
case 'vk': return `https://vk.com/share.php?url=${u}&title=${t}`;
|
||||
case 'tg': return `https://telegram.me/share/url?url=${u}&text=${t}`;
|
||||
case 'fb': return `https://www.facebook.com/sharer/sharer.php?u=${u}"e=${t}`;
|
||||
case 'ok': return `https://connect.ok.ru/offer?url=${u}&title=${t}`;
|
||||
case 'wa': return `https://api.whatsapp.com/send?text=${t} ${u}`;
|
||||
case 'inst': return `https://www.instagram.com/?url=${u}`;
|
||||
default: return '#';
|
||||
}
|
||||
}
|
||||
const shareBtns = document.querySelectorAll('#shareBtn');
|
||||
shareBtns.forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const link = window.location.origin + btn.dataset.link || window.location.href;
|
||||
const text = btn.dataset.text || document.title;
|
||||
const image = btn.dataset.image;
|
||||
const imagetext = btn.dataset.imagetext || '';
|
||||
|
||||
let blob = null;
|
||||
let file = null;
|
||||
let objectUrl = null;
|
||||
|
||||
try {
|
||||
if (image) {
|
||||
const imageUrl = window.location.origin + image;
|
||||
// В обработчике кнопки
|
||||
const blob = await imageWithTextBlob(imageUrl, imagetext, {
|
||||
textColor: '#5F1798',
|
||||
});
|
||||
console.log(`Generated blob for "${imagetext}" | size: ${blob.size} | url: ${imageUrl}`);
|
||||
const filename = `shared-image-${Date.now()}.png`;
|
||||
file = new File([blob], filename, { type: blob.type });
|
||||
|
||||
// Создаём object URL только если хотим (необязательно для navigator.share)
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
title: text,
|
||||
text: text,
|
||||
url: link,
|
||||
files: [file],
|
||||
});
|
||||
} else {
|
||||
await navigator.share({
|
||||
title: text,
|
||||
text: '',
|
||||
url: link,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await navigator.share({ title: text, text: '', url: link });
|
||||
}
|
||||
} catch (err) {
|
||||
openshareModal(link, text);
|
||||
} finally {
|
||||
// Очистка
|
||||
try {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Удаляем ссылки, чтобы GC мог собрать объекты
|
||||
blob = null;
|
||||
file = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function openshareModal(link, text) {
|
||||
vk.href = buildShareUrl('vk', link, text);
|
||||
tg.href = buildShareUrl('tg', link, text);
|
||||
fb.href = buildShareUrl('fb', link, text);
|
||||
ok.href = buildShareUrl('ok', link, text);
|
||||
wa.href = buildShareUrl('wa', link, text);
|
||||
inst.href = buildShareUrl('inst', link, text);
|
||||
shareModal.style.display = 'flex';
|
||||
copyBtn.dataset.link = link;
|
||||
shareModal.style.display = 'flex'
|
||||
}
|
||||
|
||||
closeshareModal.addEventListener('click', () => {
|
||||
shareModal.style.display = 'none';
|
||||
});
|
||||
|
||||
shareModal.addEventListener('click', (e) => {
|
||||
if (e.target === shareModal) shareModal.style.display = 'none';
|
||||
});
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
showCopyMessage();
|
||||
} catch (e) {
|
||||
showToast({message: 'Не удалось скопировать ссылку', status: 'error'});
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyMessage() {
|
||||
copyMsg.style.display = 'inline';
|
||||
setTimeout(() => copyMsg.style.display = 'none', 2000);
|
||||
}
|
||||
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const link = copyBtn.dataset.link || window.location.href;
|
||||
copyToClipboard(link);
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
<script>
|
||||
(async () => {
|
||||
const parts_cookie = `; ${document.cookie}`.split(`; token=`);
|
||||
const token_old = parts_cookie.pop().split(';')[0];
|
||||
|
||||
if (!token_old) return;
|
||||
|
||||
try {
|
||||
let response = await fetch('/api/me');
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch('/api/old/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.username) {
|
||||
response = await fetch('/api/old/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was still not ok');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('There was a problem with the fetch operation:', error);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script defer src="/static/script/share.js"></script>
|
||||
<script>
|
||||
let deferredPrompt = null;
|
||||
let hasUserInteracted = false;
|
||||
@ -577,288 +261,7 @@ shareBtns.forEach(btn => {
|
||||
}, 1500);
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const root = document.getElementById('dd-modal-root') || document.body;
|
||||
|
||||
// Утилиты парсинга
|
||||
function parseList(attr) {
|
||||
if (!attr) return [];
|
||||
try {
|
||||
const fixed = attr.replace(/'/g, '"');
|
||||
return JSON.parse(fixed);
|
||||
} catch (e) {
|
||||
return String(attr).split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSavedValue(el) {
|
||||
const v = el.getAttribute('data-value');
|
||||
if (!v) return [];
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch (e) {
|
||||
return v.includes(',')
|
||||
? v.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [v];
|
||||
}
|
||||
}
|
||||
|
||||
async function openModalForInput(triggerEl) {
|
||||
const listRaw = triggerEl.getAttribute('data-list') || '[]';
|
||||
const options = parseList(listRaw);
|
||||
const isRadio = triggerEl.getAttribute('data-radio') === '1' ||
|
||||
triggerEl.getAttribute('data-radio') === 'true';
|
||||
const takeAll = triggerEl.getAttribute('data-takeall') === '1' ||
|
||||
triggerEl.getAttribute('data-takeall') === 'true';
|
||||
const allowCustom = triggerEl.getAttribute('data-custom') === '1' ||
|
||||
triggerEl.getAttribute('data-custom') === 'true';
|
||||
|
||||
const title = triggerEl.getAttribute('data-title') || 'Выберите';
|
||||
const saveLabel = triggerEl.getAttribute('data-label') || 'Сохранить';
|
||||
const customPlaceholder = triggerEl.getAttribute('data-custom_placeholder') || 'Добавить...';
|
||||
|
||||
const initial = parseSavedValue(triggerEl);
|
||||
|
||||
return new Promise(resolve => {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'dd-modal-backdrop';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'dd-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="dd-header">
|
||||
<strong>${title}</strong>
|
||||
<button class="dd-close" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<div class="dd-custom-container" style="margin-top:8px;"></div>
|
||||
<div class="dd-body"></div>
|
||||
<div class="dd-actions" style="margin-top:12px;">
|
||||
<button class="dd-save">${saveLabel}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
root.appendChild(backdrop);
|
||||
|
||||
const body = modal.querySelector('.dd-body');
|
||||
const customContainer = modal.querySelector('.dd-custom-container');
|
||||
const saveBtn = modal.querySelector('.dd-save');
|
||||
const closeBtn = modal.querySelector('.dd-close');
|
||||
|
||||
const groupName = 'dd-group-' + Date.now() + Math.random().toString(36).slice(2);
|
||||
|
||||
let currentOptions = options.slice();
|
||||
let selected = initial.slice();
|
||||
|
||||
function renderOptions() {
|
||||
body.innerHTML = '';
|
||||
|
||||
// "Выбрать все"
|
||||
if (takeAll) {
|
||||
const wrapper = createOption('Выбрать все', true, groupName, isRadio);
|
||||
const takeAllInput = wrapper.querySelector('input');
|
||||
takeAllInput.id = 'takeAll';
|
||||
takeAllInput.checked = selected.length === 0 || selected.includes('Выбрать все');
|
||||
body.appendChild(wrapper);
|
||||
}
|
||||
|
||||
currentOptions.forEach(opt => {
|
||||
const label = typeof opt === 'object' ? opt.name : String(opt);
|
||||
const tooltip = typeof opt === 'object' ? opt.tooltip : null;
|
||||
const count = typeof opt === 'object' ? opt.count : null;
|
||||
|
||||
|
||||
const wrapper = createOption(label, false, groupName, isRadio, tooltip, count);
|
||||
const input = wrapper.querySelector('input');
|
||||
input.checked = selected.includes(label);
|
||||
body.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
|
||||
function createOption(label, isTakeAll, name, isRadio, tooltip = null, count = null) {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'dd-option';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = isRadio ? 'radio' : 'checkbox';
|
||||
input.name = name;
|
||||
input.value = label;
|
||||
input.className = 'offer-checkbox';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'some-class';
|
||||
|
||||
// count
|
||||
if (count !== undefined && count !== null) {
|
||||
const countNode = document.createElement('span');
|
||||
countNode.textContent = count;
|
||||
div.appendChild(countNode);
|
||||
}
|
||||
// tooltip
|
||||
if (tooltip) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'tooltip-trigger';
|
||||
img.dataset.tooltip = tooltip;
|
||||
img.src = '/static/help.svg';
|
||||
img.alt = 'Подсказка';
|
||||
div.appendChild(img);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// куда-то добавляем div
|
||||
span.appendChild(div);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
wrapper.append(input, span);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Кастомные значения
|
||||
if (allowCustom) {
|
||||
customContainer.innerHTML = `
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-direction:column;">
|
||||
<input type="text" class="dd-custom-input" placeholder="${customPlaceholder}"
|
||||
style="flex:1;padding:6px;border:1px solid #ccc;width:100%;border-radius:4px;">
|
||||
<button class="dd-custom-add" style="padding:6px 8px;width:100%;border-radius:5px;background:#5F1798;color:white;">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const customInput = customContainer.querySelector('.dd-custom-input');
|
||||
const customAdd = customContainer.querySelector('.dd-custom-add');
|
||||
|
||||
const addCustom = (val) => {
|
||||
val = String(val).trim();
|
||||
if (!val) return;
|
||||
|
||||
if (!currentOptions.some(o => String(o) === val && typeof o !== 'object')) {
|
||||
currentOptions.push(val);
|
||||
triggerEl.setAttribute('data-list', JSON.stringify(currentOptions));
|
||||
}
|
||||
|
||||
if (isRadio) {
|
||||
selected = [val];
|
||||
} else if (!selected.includes(val)) {
|
||||
selected.push(val);
|
||||
}
|
||||
|
||||
renderOptions();
|
||||
updateCheckedState();
|
||||
};
|
||||
|
||||
customAdd.addEventListener('click', () => {
|
||||
addCustom(customInput.value);
|
||||
customInput.value = '';
|
||||
customInput.focus();
|
||||
});
|
||||
|
||||
customInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addCustom(customInput.value);
|
||||
customInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCheckedState() {
|
||||
Array.from(body.querySelectorAll('input')).forEach(inp => {
|
||||
inp.checked = selected.includes(inp.value);
|
||||
});
|
||||
}
|
||||
|
||||
function collectChecked() {
|
||||
return Array.from(body.querySelectorAll('input:checked')).map(i => i.value);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const result = collectChecked();
|
||||
triggerEl.setAttribute('data-value', isRadio ? (result[0] ?? '') : JSON.stringify(result));
|
||||
|
||||
if (triggerEl.type && triggerEl.type !== 'hidden') {
|
||||
triggerEl.value = isRadio ? (result[0] ?? '') : result.join(', ');
|
||||
}
|
||||
|
||||
backdrop.remove();
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
// Обработчики закрытия
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
saveBtn.addEventListener('click', closeModal);
|
||||
backdrop.addEventListener('click', e => {
|
||||
if (e.target === backdrop) closeModal();
|
||||
});
|
||||
|
||||
// Логика "Выбрать все"
|
||||
function setupTakeAll() {
|
||||
const takeAllInput = document.getElementById('takeAll');
|
||||
if (!takeAllInput) return;
|
||||
|
||||
const allInputs = () => Array.from(body.querySelectorAll('input.offer-checkbox'));
|
||||
|
||||
takeAllInput.addEventListener('change', () => {
|
||||
if (takeAllInput.checked) {
|
||||
selected = [];
|
||||
allInputs().forEach(inp => {
|
||||
if (inp !== takeAllInput) inp.checked = false;
|
||||
});
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Сброс "Выбрать все" при выборе других
|
||||
body.addEventListener('change', e => {
|
||||
const inp = e.target;
|
||||
if (inp.type !== 'checkbox' || inp === takeAllInput) return;
|
||||
|
||||
if (inp.checked) {
|
||||
takeAllInput.checked = false;
|
||||
} else {
|
||||
const anyChecked = allInputs().some(x => x !== takeAllInput && x.checked);
|
||||
takeAllInput.checked = !anyChecked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderOptions();
|
||||
setupTakeAll();
|
||||
updateCheckedState();
|
||||
});
|
||||
}
|
||||
|
||||
// Главный обработчик
|
||||
document.addEventListener('click', async function(e) {
|
||||
const trigger = e.target.closest('[data-select="1"]');
|
||||
if (!trigger) return;
|
||||
|
||||
const input = trigger.querySelector('input');
|
||||
if (input) {
|
||||
input.readOnly = true;
|
||||
input.blur();
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const result = await openModalForInput(trigger);
|
||||
if (result && result.length) {
|
||||
trigger.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}, true);
|
||||
|
||||
})();
|
||||
</script>
|
||||
<script defer src="/static/script/select.js"></script>
|
||||
<div id="dd-modal-root"></div>
|
||||
|
||||
<div id="modalToolTip">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user