diff --git a/static/script/select.js b/static/script/select.js new file mode 100644 index 0000000..78d44d5 --- /dev/null +++ b/static/script/select.js @@ -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 = ` +
+ ${title} + +
+
+
+
+ +
+ `; + + 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 = ` +
+ + +
+ `; + + 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); + +})(); \ No newline at end of file diff --git a/static/script/share.js b/static/script/share.js new file mode 100644 index 0000000..5a12b58 --- /dev/null +++ b/static/script/share.js @@ -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); + }); +}); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index ab85e47..1fd37f5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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;">Скопировано! - - + - +