Add digital badge tab for round-display photo prep
Adds a new "Цифровой бейдж" tab for preparing 240×240 PNG circles for round digital displays. Photos come from device upload or the existing Flickr selection. Each photo can be drag-cropped and zoomed (mouse, wheel, or pinch on touch), have a yellow price tag (arc band or rectangular plate, with auto thousands separator and ₽), and a curved nickname signature — which auto-moves to the top of the circle when a price tag occupies the bottom. Saves are routed through the Web Share API on phones so the badge lands in the iOS/Android Photos app directly; history of saved badges is kept under data/badges/. Default nickname is stored in Settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4224,4 +4224,886 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
loadAlbums(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ DIGITAL BADGE (round display) ============
|
||||
{
|
||||
const BADGE_SIZE = 240; // Final output (round display)
|
||||
const CANVAS_DISPLAY_SIZE = 480; // Editor internal resolution
|
||||
const FONT_STACK = '-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
|
||||
|
||||
const badgeState = {
|
||||
items: [],
|
||||
activeId: null,
|
||||
defaultNickname: '',
|
||||
};
|
||||
|
||||
const el = {
|
||||
fileInput: document.getElementById('badge-file-upload'),
|
||||
btnUpload: document.getElementById('btn-badge-upload'),
|
||||
btnFromFlickr: document.getElementById('btn-badge-from-flickr'),
|
||||
itemsGrid: document.getElementById('badge-items-grid'),
|
||||
itemsCount: document.getElementById('badge-items-count'),
|
||||
editor: document.getElementById('badge-editor'),
|
||||
canvas: document.getElementById('badge-canvas'),
|
||||
zoom: document.getElementById('badge-zoom'),
|
||||
zoomValue: document.getElementById('badge-zoom-value'),
|
||||
btnReset: document.getElementById('btn-badge-reset'),
|
||||
priceEnabled: document.getElementById('badge-price-enabled'),
|
||||
priceOptions: document.getElementById('badge-price-options'),
|
||||
priceValue: document.getElementById('badge-price-value'),
|
||||
nickEnabled: document.getElementById('badge-nickname-enabled'),
|
||||
nickOptions: document.getElementById('badge-nickname-options'),
|
||||
nickValue: document.getElementById('badge-nickname-value'),
|
||||
btnDownload: document.getElementById('btn-badge-download'),
|
||||
btnRemove: document.getElementById('btn-badge-remove'),
|
||||
batchActions: document.getElementById('badge-batch-actions'),
|
||||
btnDownloadAll: document.getElementById('btn-badge-download-all'),
|
||||
historyGrid: document.getElementById('badge-history-grid'),
|
||||
// Settings tab
|
||||
nickDefault: document.getElementById('badge-nickname-default'),
|
||||
btnSaveNick: document.getElementById('btn-save-badge-nickname'),
|
||||
nickSaveStatus: document.getElementById('badge-nickname-save-status'),
|
||||
};
|
||||
|
||||
if (!el.canvas) return; // Defensive: tab not present.
|
||||
|
||||
const ctx = el.canvas.getContext('2d');
|
||||
|
||||
// Detect Web Share with file support to label the button appropriately on phones.
|
||||
const canShareFiles = (() => {
|
||||
try {
|
||||
if (typeof File === 'undefined' || !navigator.canShare) return false;
|
||||
const probe = new File([''], 'p.png', { type: 'image/png' });
|
||||
return navigator.canShare({ files: [probe] });
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const labelEl = document.getElementById('badge-download-label');
|
||||
if (labelEl && canShareFiles) {
|
||||
labelEl.textContent = 'Сохранить в Фото';
|
||||
}
|
||||
|
||||
// ---------- Drawing helpers ----------
|
||||
|
||||
function formatPrice(v) {
|
||||
if (v === '' || v == null) return '';
|
||||
const n = Number(v);
|
||||
if (!isFinite(n)) return String(v);
|
||||
return Math.round(n).toLocaleString('ru-RU') + ' ₽';
|
||||
}
|
||||
|
||||
function roundRect(c, x, y, w, h, r) {
|
||||
c.beginPath();
|
||||
c.moveTo(x + r, y);
|
||||
c.lineTo(x + w - r, y);
|
||||
c.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
c.lineTo(x + w, y + h - r);
|
||||
c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
c.lineTo(x + r, y + h);
|
||||
c.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
c.lineTo(x, y + r);
|
||||
c.quadraticCurveTo(x, y, x + r, y);
|
||||
c.closePath();
|
||||
}
|
||||
|
||||
function drawPriceArc(c, size, text) {
|
||||
const yTop = size * 0.74;
|
||||
const yBottom = size * 0.965;
|
||||
c.save();
|
||||
c.fillStyle = '#FFCC00';
|
||||
c.fillRect(0, yTop, size, yBottom - yTop);
|
||||
const grad = c.createLinearGradient(0, yTop, 0, yTop + size * 0.012);
|
||||
grad.addColorStop(0, 'rgba(255,255,255,0.35)');
|
||||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = grad;
|
||||
c.fillRect(0, yTop, size, size * 0.012);
|
||||
const fontSize = (yBottom - yTop) * 0.55;
|
||||
c.fillStyle = '#000';
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
c.font = `700 ${fontSize}px ${FONT_STACK}`;
|
||||
c.fillText(text, size / 2, (yTop + yBottom) / 2 - size * 0.005);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function drawPriceRect(c, size, text) {
|
||||
const w = size * 0.78;
|
||||
const h = size * 0.13;
|
||||
const x = (size - w) / 2;
|
||||
const y = size * 0.755;
|
||||
c.save();
|
||||
c.fillStyle = 'rgba(0,0,0,0.22)';
|
||||
roundRect(c, x, y + size * 0.008, w, h, h * 0.28);
|
||||
c.fill();
|
||||
c.fillStyle = '#FFCC00';
|
||||
roundRect(c, x, y, w, h, h * 0.28);
|
||||
c.fill();
|
||||
const fontSize = h * 0.62;
|
||||
c.fillStyle = '#000';
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
c.font = `700 ${fontSize}px ${FONT_STACK}`;
|
||||
c.fillText(text, size / 2, y + h / 2);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function drawNicknameArc(c, size, text, color, position) {
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const R = size / 2;
|
||||
const textRadius = R * 0.86;
|
||||
const fontSize = Math.max(11, size * 0.075);
|
||||
c.save();
|
||||
c.font = `700 ${fontSize}px ${FONT_STACK}`;
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
const chars = Array.from(text);
|
||||
const widths = chars.map(ch => c.measureText(ch).width + size * 0.003);
|
||||
const totalW = widths.reduce((a, b) => a + b, 0);
|
||||
const totalAngle = totalW / textRadius;
|
||||
|
||||
// Bottom arc: text reads right→left visually (start at upper-right of bottom, sweep counterclockwise).
|
||||
// Top arc: text reads left→right (start at upper-left of top, sweep clockwise).
|
||||
let startAngle, direction, rotationOffset;
|
||||
if (position === 'top') {
|
||||
startAngle = -Math.PI / 2 - totalAngle / 2;
|
||||
direction = 1;
|
||||
rotationOffset = Math.PI / 2;
|
||||
} else {
|
||||
startAngle = Math.PI / 2 + totalAngle / 2;
|
||||
direction = -1;
|
||||
rotationOffset = -Math.PI / 2;
|
||||
}
|
||||
|
||||
let cumulative = 0;
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const w = widths[i];
|
||||
const a = startAngle + direction * (cumulative + w / 2) / textRadius;
|
||||
cumulative += w;
|
||||
const x = cx + Math.cos(a) * textRadius;
|
||||
const y = cy + Math.sin(a) * textRadius;
|
||||
c.save();
|
||||
c.translate(x, y);
|
||||
c.rotate(a + rotationOffset);
|
||||
if (color === 'black') {
|
||||
c.shadowColor = 'rgba(255,255,255,0.7)';
|
||||
c.shadowBlur = size * 0.012;
|
||||
c.fillStyle = '#000';
|
||||
} else {
|
||||
c.shadowColor = 'rgba(0,0,0,0.75)';
|
||||
c.shadowBlur = size * 0.016;
|
||||
c.shadowOffsetY = size * 0.003;
|
||||
c.fillStyle = '#fff';
|
||||
}
|
||||
c.fillText(chars[i], 0, 0);
|
||||
c.restore();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function drawBadge(c, size, item) {
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const R = size / 2;
|
||||
const ratio = size / CANVAS_DISPLAY_SIZE;
|
||||
|
||||
c.clearRect(0, 0, size, size);
|
||||
c.save();
|
||||
c.beginPath();
|
||||
c.arc(cx, cy, R, 0, Math.PI * 2);
|
||||
c.closePath();
|
||||
c.clip();
|
||||
|
||||
if (item.image && item.image.complete && item.image.naturalWidth) {
|
||||
const img = item.image;
|
||||
const baseFit = Math.max(size / img.naturalWidth, size / img.naturalHeight);
|
||||
const scale = baseFit * (item.zoom / 100);
|
||||
const drawW = img.naturalWidth * scale;
|
||||
const drawH = img.naturalHeight * scale;
|
||||
const dx = cx - drawW / 2 + item.offsetX * ratio;
|
||||
const dy = cy - drawH / 2 + item.offsetY * ratio;
|
||||
c.drawImage(img, dx, dy, drawW, drawH);
|
||||
} else {
|
||||
c.fillStyle = '#eee';
|
||||
c.fillRect(0, 0, size, size);
|
||||
c.fillStyle = '#999';
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
c.font = `500 ${size * 0.05}px ${FONT_STACK}`;
|
||||
c.fillText('Загрузка...', cx, cy);
|
||||
}
|
||||
|
||||
const priceText = item.showPrice ? formatPrice(item.priceValue) : '';
|
||||
const hasPrice = !!priceText;
|
||||
|
||||
if (hasPrice) {
|
||||
if (item.priceStyle === 'rect') {
|
||||
drawPriceRect(c, size, priceText);
|
||||
} else {
|
||||
drawPriceArc(c, size, priceText);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.showNickname) {
|
||||
const nick = (item.nickname || '').trim();
|
||||
if (nick) {
|
||||
// Auto-position: if price occupies the bottom, draw nickname as a top arc.
|
||||
drawNicknameArc(c, size, nick, item.nickColor || 'white', hasPrice ? 'top' : 'bottom');
|
||||
}
|
||||
}
|
||||
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
drawBadge(ctx, CANVAS_DISPLAY_SIZE, item);
|
||||
}
|
||||
|
||||
function exportItemToBlob(item) {
|
||||
return new Promise(resolve => {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = BADGE_SIZE;
|
||||
off.height = BADGE_SIZE;
|
||||
const offCtx = off.getContext('2d');
|
||||
drawBadge(offCtx, BADGE_SIZE, item);
|
||||
off.toBlob(blob => resolve({ blob, canvas: off }), 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Image loading ----------
|
||||
|
||||
function loadImage(src, useCrossOrigin) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
if (useCrossOrigin) img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Не удалось загрузить изображение'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Item management ----------
|
||||
|
||||
function currentItem() {
|
||||
return badgeState.items.find(it => it.id === badgeState.activeId) || null;
|
||||
}
|
||||
|
||||
function makeItem(srcUrl, name, isRemote) {
|
||||
return {
|
||||
id: 'b_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8),
|
||||
src: srcUrl,
|
||||
name: name || 'photo',
|
||||
isRemote: !!isRemote,
|
||||
image: null,
|
||||
imageLoading: true,
|
||||
imageError: null,
|
||||
zoom: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
showPrice: false,
|
||||
priceValue: '',
|
||||
priceStyle: 'arc',
|
||||
showNickname: false,
|
||||
nickname: '',
|
||||
nickColor: 'white',
|
||||
};
|
||||
}
|
||||
|
||||
async function addItemFromUrl(srcUrl, name, isRemote) {
|
||||
const item = makeItem(srcUrl, name, isRemote);
|
||||
badgeState.items.push(item);
|
||||
renderItemsGrid();
|
||||
try {
|
||||
const img = await loadImage(srcUrl, isRemote);
|
||||
item.image = img;
|
||||
item.imageLoading = false;
|
||||
renderItemsGrid();
|
||||
if (badgeState.activeId === item.id) renderPreview();
|
||||
} catch (err) {
|
||||
item.imageLoading = false;
|
||||
item.imageError = err.message || 'ошибка';
|
||||
renderItemsGrid();
|
||||
showNotification(`Не удалось загрузить ${name}`, 'error');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function selectItem(id) {
|
||||
const wasHidden = el.editor.classList.contains('hidden');
|
||||
badgeState.activeId = id;
|
||||
const item = currentItem();
|
||||
if (!item) {
|
||||
el.editor.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
el.editor.classList.remove('hidden');
|
||||
if (wasHidden && window.innerWidth < 900) {
|
||||
setTimeout(() => {
|
||||
el.editor.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 60);
|
||||
}
|
||||
// Populate controls from item state
|
||||
el.zoom.value = item.zoom;
|
||||
el.zoomValue.textContent = item.zoom + '%';
|
||||
el.priceEnabled.checked = item.showPrice;
|
||||
el.priceOptions.classList.toggle('hidden', !item.showPrice);
|
||||
el.priceValue.value = item.priceValue;
|
||||
document.querySelectorAll('input[name="badge-price-style"]').forEach(r => {
|
||||
r.checked = r.value === item.priceStyle;
|
||||
});
|
||||
el.nickEnabled.checked = item.showNickname;
|
||||
el.nickOptions.classList.toggle('hidden', !item.showNickname);
|
||||
el.nickValue.value = item.nickname || badgeState.defaultNickname || '';
|
||||
document.querySelectorAll('input[name="badge-nick-color"]').forEach(r => {
|
||||
r.checked = r.value === item.nickColor;
|
||||
});
|
||||
renderItemsGrid();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function removeItem(id) {
|
||||
const idx = badgeState.items.findIndex(it => it.id === id);
|
||||
if (idx === -1) return;
|
||||
badgeState.items.splice(idx, 1);
|
||||
if (badgeState.activeId === id) {
|
||||
const next = badgeState.items[0];
|
||||
if (next) {
|
||||
selectItem(next.id);
|
||||
} else {
|
||||
badgeState.activeId = null;
|
||||
el.editor.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
renderItemsGrid();
|
||||
}
|
||||
|
||||
function renderItemsGrid() {
|
||||
const grid = el.itemsGrid;
|
||||
if (!grid) return;
|
||||
el.itemsCount.textContent = String(badgeState.items.length);
|
||||
el.batchActions.classList.toggle('hidden', badgeState.items.length < 2);
|
||||
|
||||
if (badgeState.items.length === 0) {
|
||||
grid.innerHTML = '<p class="placeholder">Выберите фото из галереи или загрузите с устройства</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = '';
|
||||
badgeState.items.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'badge-item' + (item.id === badgeState.activeId ? ' active' : '');
|
||||
if (item.showPrice || item.showNickname) div.classList.add('has-extras');
|
||||
div.dataset.id = item.id;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'badge-item-preview';
|
||||
img.alt = item.name;
|
||||
img.src = item.src;
|
||||
if (item.isRemote) img.crossOrigin = 'anonymous';
|
||||
div.appendChild(img);
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'badge-item-remove';
|
||||
remove.title = 'Убрать';
|
||||
remove.textContent = '×';
|
||||
remove.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeItem(item.id);
|
||||
});
|
||||
div.appendChild(remove);
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'badge-item-badge-dot';
|
||||
dot.title = 'С декором (цена/никнейм)';
|
||||
div.appendChild(dot);
|
||||
|
||||
div.addEventListener('click', () => selectItem(item.id));
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Drag interactions ----------
|
||||
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let pinchStart = null;
|
||||
|
||||
function pointerPosOnCanvas(e) {
|
||||
const rect = el.canvas.getBoundingClientRect();
|
||||
const t = (e.touches && e.touches[0]) ? e.touches[0] : e;
|
||||
return {
|
||||
x: (t.clientX - rect.left) * (CANVAS_DISPLAY_SIZE / rect.width),
|
||||
y: (t.clientY - rect.top) * (CANVAS_DISPLAY_SIZE / rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function touchDistance(touches) {
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
function setZoom(item, value) {
|
||||
const next = Math.max(100, Math.min(400, Math.round(value)));
|
||||
item.zoom = next;
|
||||
el.zoom.value = next;
|
||||
el.zoomValue.textContent = next + '%';
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
const p = pointerPosOnCanvas(e);
|
||||
dragStart = { px: p.x, py: p.y, ox: item.offsetX, oy: item.offsetY };
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragging || !dragStart) return;
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const p = pointerPosOnCanvas(e);
|
||||
item.offsetX = dragStart.ox + (p.x - dragStart.px);
|
||||
item.offsetY = dragStart.oy + (p.y - dragStart.py);
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
}
|
||||
|
||||
function onCanvasTouchStart(e) {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
if (e.touches.length >= 2) {
|
||||
e.preventDefault();
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
pinchStart = { dist: touchDistance(e.touches), zoom: item.zoom };
|
||||
return;
|
||||
}
|
||||
onPointerDown(e);
|
||||
}
|
||||
|
||||
function onCanvasTouchMove(e) {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
if (e.touches.length >= 2 && pinchStart) {
|
||||
e.preventDefault();
|
||||
const dist = touchDistance(e.touches);
|
||||
if (pinchStart.dist > 0) {
|
||||
setZoom(item, pinchStart.zoom * (dist / pinchStart.dist));
|
||||
renderPreview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
onPointerMove(e);
|
||||
}
|
||||
|
||||
function onCanvasTouchEnd(e) {
|
||||
if (e.touches.length < 2) pinchStart = null;
|
||||
if (e.touches.length === 0) onPointerUp();
|
||||
}
|
||||
|
||||
el.canvas.addEventListener('mousedown', onPointerDown);
|
||||
window.addEventListener('mousemove', onPointerMove);
|
||||
window.addEventListener('mouseup', onPointerUp);
|
||||
el.canvas.addEventListener('touchstart', onCanvasTouchStart, { passive: false });
|
||||
el.canvas.addEventListener('touchmove', onCanvasTouchMove, { passive: false });
|
||||
el.canvas.addEventListener('touchend', onCanvasTouchEnd);
|
||||
el.canvas.addEventListener('touchcancel', onCanvasTouchEnd);
|
||||
el.canvas.addEventListener('wheel', (e) => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -5 : 5;
|
||||
setZoom(item, item.zoom + delta);
|
||||
renderPreview();
|
||||
}, { passive: false });
|
||||
|
||||
// ---------- Controls ----------
|
||||
|
||||
el.zoom.addEventListener('input', () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
const v = parseInt(el.zoom.value, 10) || 100;
|
||||
item.zoom = v;
|
||||
el.zoomValue.textContent = v + '%';
|
||||
renderPreview();
|
||||
});
|
||||
|
||||
el.btnReset.addEventListener('click', () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
item.zoom = 100;
|
||||
item.offsetX = 0;
|
||||
item.offsetY = 0;
|
||||
el.zoom.value = 100;
|
||||
el.zoomValue.textContent = '100%';
|
||||
renderPreview();
|
||||
});
|
||||
|
||||
el.priceEnabled.addEventListener('change', () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
item.showPrice = el.priceEnabled.checked;
|
||||
el.priceOptions.classList.toggle('hidden', !item.showPrice);
|
||||
renderPreview();
|
||||
renderItemsGrid();
|
||||
});
|
||||
|
||||
el.priceValue.addEventListener('input', () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
item.priceValue = el.priceValue.value;
|
||||
renderPreview();
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="badge-price-style"]').forEach(r => {
|
||||
r.addEventListener('change', () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
if (r.checked) {
|
||||
item.priceStyle = r.value;
|
||||
renderPreview();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
el.nickEnabled.addEventListener('change', () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
item.showNickname = el.nickEnabled.checked;
|
||||
el.nickOptions.classList.toggle('hidden', !item.showNickname);
|
||||
if (item.showNickname && !item.nickname && badgeState.defaultNickname) {
|
||||
item.nickname = badgeState.defaultNickname;
|
||||
el.nickValue.value = badgeState.defaultNickname;
|
||||
}
|
||||
renderPreview();
|
||||
renderItemsGrid();
|
||||
});
|
||||
|
||||
el.nickValue.addEventListener('input', () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
item.nickname = el.nickValue.value;
|
||||
renderPreview();
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="badge-nick-color"]').forEach(r => {
|
||||
r.addEventListener('change', () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
if (r.checked) {
|
||||
item.nickColor = r.value;
|
||||
renderPreview();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
el.btnRemove.addEventListener('click', () => {
|
||||
const item = currentItem();
|
||||
if (item) removeItem(item.id);
|
||||
});
|
||||
|
||||
// ---------- File upload ----------
|
||||
|
||||
el.btnUpload.addEventListener('click', () => el.fileInput.click());
|
||||
|
||||
el.fileInput.addEventListener('change', (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
files.forEach(file => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showNotification(`${file.name}: не изображение`, 'error');
|
||||
return;
|
||||
}
|
||||
if (file.size > 25 * 1024 * 1024) {
|
||||
showNotification(`${file.name}: слишком большой (макс 25MB)`, 'error');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (ev) => {
|
||||
const item = await addItemFromUrl(ev.target.result, file.name, false);
|
||||
if (!badgeState.activeId) selectItem(item.id);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// ---------- Flickr selection ----------
|
||||
|
||||
el.btnFromFlickr.addEventListener('click', async () => {
|
||||
const photos = state.selectedPhotos || [];
|
||||
if (photos.length === 0) {
|
||||
showNotification('Сначала выберите фото в Галерее', 'info');
|
||||
document.querySelector('.nav-btn[data-tab="gallery"]')?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip duplicates already in badge list
|
||||
const existingSrcs = new Set(badgeState.items.map(it => it.src));
|
||||
let added = 0;
|
||||
for (const photo of photos) {
|
||||
const url = (photo.urls && (photo.urls.large || photo.urls.medium640 || photo.urls.medium)) || null;
|
||||
if (!url) continue;
|
||||
if (existingSrcs.has(url)) continue;
|
||||
const item = await addItemFromUrl(url, photo.title || ('photo_' + photo.id), true);
|
||||
if (!badgeState.activeId) selectItem(item.id);
|
||||
added++;
|
||||
}
|
||||
if (added > 0) {
|
||||
showNotification(`Добавлено ${added} фото из Flickr`, 'success');
|
||||
} else {
|
||||
showNotification('Нечего добавлять (уже в списке)', 'info');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Download / save ----------
|
||||
|
||||
function triggerDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
// On phones, prefer the OS share sheet ("Save to Photos" on iOS, "Save image" on Android).
|
||||
// Falls back to a regular download elsewhere.
|
||||
async function saveOrShare(blob, filename) {
|
||||
try {
|
||||
if (typeof File !== 'undefined' && navigator.canShare) {
|
||||
const file = new File([blob], filename, { type: 'image/png' });
|
||||
if (navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({ files: [file], title: 'Цифровой бейдж' });
|
||||
return 'shared';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') return 'aborted';
|
||||
// fall through to download
|
||||
}
|
||||
triggerDownload(blob, filename);
|
||||
return 'downloaded';
|
||||
}
|
||||
|
||||
function badgeFilename(item, idx) {
|
||||
const safeName = (item.name || 'badge')
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.replace(/[^\wа-яА-ЯёЁ\-_.]+/g, '_')
|
||||
.slice(0, 32) || 'badge';
|
||||
return `badge_${safeName}${idx != null ? '_' + (idx + 1) : ''}.png`;
|
||||
}
|
||||
|
||||
async function saveToServerHistory(canvas, item) {
|
||||
try {
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'badge_save');
|
||||
fd.append('image', dataUrl);
|
||||
fd.append('has_price', item.showPrice ? '1' : '0');
|
||||
fd.append('has_nickname', item.showNickname ? '1' : '0');
|
||||
fd.append('label', item.name || '');
|
||||
const res = await fetch('api.php', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
console.warn('Не удалось сохранить в историю:', data.error);
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.warn('История недоступна:', err);
|
||||
}
|
||||
}
|
||||
|
||||
el.btnDownload.addEventListener('click', async () => {
|
||||
const item = currentItem();
|
||||
if (!item) return;
|
||||
if (!item.image) {
|
||||
showNotification('Фото ещё не загружено', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { blob, canvas } = await exportItemToBlob(item);
|
||||
if (!blob) {
|
||||
showNotification('Не удалось сохранить (CORS?)', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await saveOrShare(blob, badgeFilename(item));
|
||||
await saveToServerHistory(canvas, item);
|
||||
loadHistory();
|
||||
if (result === 'shared') {
|
||||
showNotification('Готово — сохраните в «Фото»', 'success');
|
||||
} else if (result === 'downloaded') {
|
||||
showNotification('Файл скачан', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification('Ошибка: ' + (err.message || err), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
el.btnDownloadAll.addEventListener('click', async () => {
|
||||
if (badgeState.items.length === 0) return;
|
||||
const ready = badgeState.items.filter(it => it.image);
|
||||
if (ready.length === 0) {
|
||||
showNotification('Нет загруженных фото', 'error');
|
||||
return;
|
||||
}
|
||||
showNotification(`Сохраняю ${ready.length} бейджей...`, 'info');
|
||||
for (let i = 0; i < ready.length; i++) {
|
||||
const item = ready[i];
|
||||
try {
|
||||
const { blob, canvas } = await exportItemToBlob(item);
|
||||
if (!blob) continue;
|
||||
triggerDownload(blob, badgeFilename(item, i));
|
||||
await saveToServerHistory(canvas, item);
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
} catch (err) {
|
||||
console.warn('Skip item:', err);
|
||||
}
|
||||
}
|
||||
loadHistory();
|
||||
});
|
||||
|
||||
// ---------- History ----------
|
||||
|
||||
async function loadHistory() {
|
||||
if (!el.historyGrid) return;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'badge_list');
|
||||
const res = await fetch('api.php', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
renderHistory(data.items || []);
|
||||
} catch (err) {
|
||||
console.warn('Не удалось загрузить историю:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory(items) {
|
||||
if (!items.length) {
|
||||
el.historyGrid.innerHTML = '<p class="placeholder">История пуста — сгенерированные бейджи будут появляться здесь.</p>';
|
||||
return;
|
||||
}
|
||||
el.historyGrid.innerHTML = '';
|
||||
items.forEach(it => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'badge-history-item';
|
||||
|
||||
const date = document.createElement('span');
|
||||
date.className = 'badge-history-date';
|
||||
date.textContent = (it.created_at || '').slice(5, 16).replace(' ', ' ');
|
||||
div.appendChild(date);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = it.url;
|
||||
img.alt = it.filename;
|
||||
div.appendChild(img);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'badge-history-actions';
|
||||
const dl = document.createElement('button');
|
||||
dl.type = 'button';
|
||||
dl.textContent = 'Сохранить';
|
||||
dl.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch(it.url);
|
||||
const blob = await res.blob();
|
||||
await saveOrShare(blob, it.filename);
|
||||
} catch (err) {
|
||||
const a = document.createElement('a');
|
||||
a.href = it.url;
|
||||
a.download = it.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
});
|
||||
actions.appendChild(dl);
|
||||
|
||||
const rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.className = 'danger';
|
||||
rm.textContent = 'Удалить';
|
||||
rm.addEventListener('click', async () => {
|
||||
if (!confirm('Удалить этот бейдж из истории?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'badge_delete');
|
||||
fd.append('filename', it.filename);
|
||||
await fetch('api.php', { method: 'POST', body: fd });
|
||||
loadHistory();
|
||||
});
|
||||
actions.appendChild(rm);
|
||||
div.appendChild(actions);
|
||||
|
||||
el.historyGrid.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Settings (nickname) ----------
|
||||
|
||||
async function loadBadgeSettings() {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'get_badge_settings');
|
||||
const res = await fetch('api.php', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
const nick = (data.settings && data.settings.nickname) || '';
|
||||
badgeState.defaultNickname = nick;
|
||||
if (el.nickDefault) el.nickDefault.value = nick;
|
||||
} catch (err) {
|
||||
console.warn('Badge settings load failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (el.btnSaveNick) {
|
||||
el.btnSaveNick.addEventListener('click', async () => {
|
||||
const nick = el.nickDefault.value.trim();
|
||||
el.nickSaveStatus.textContent = 'Сохранение...';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'save_badge_settings');
|
||||
fd.append('nickname', nick);
|
||||
const res = await fetch('api.php', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
badgeState.defaultNickname = nick;
|
||||
el.nickSaveStatus.textContent = '✓ Сохранено';
|
||||
el.nickSaveStatus.style.color = '#34C759';
|
||||
setTimeout(() => { el.nickSaveStatus.textContent = ''; }, 2000);
|
||||
} else {
|
||||
el.nickSaveStatus.textContent = 'Ошибка: ' + (data.error || '');
|
||||
el.nickSaveStatus.style.color = '#FF3B30';
|
||||
}
|
||||
} catch (err) {
|
||||
el.nickSaveStatus.textContent = 'Ошибка сети';
|
||||
el.nickSaveStatus.style.color = '#FF3B30';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Tab activation ----------
|
||||
|
||||
document.querySelectorAll('.nav-btn[data-tab="badge"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
loadBadgeSettings();
|
||||
loadHistory();
|
||||
});
|
||||
});
|
||||
|
||||
// Initial settings load (so default nickname is available even before opening the tab)
|
||||
loadBadgeSettings();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user