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:
zuevav
2026-05-14 21:16:37 +03:00
parent a0dd7b1ed4
commit 68f4560bd5
4 changed files with 1445 additions and 0 deletions
+133
View File
@@ -976,6 +976,139 @@ try {
}
break;
// ============ DIGITAL BADGE (round display) ============
case 'get_badge_settings':
$settingsFile = __DIR__ . '/data/badge_settings.json';
if (file_exists($settingsFile)) {
$settings = json_decode(file_get_contents($settingsFile), true) ?: [];
echo json_encode(['success' => true, 'settings' => $settings]);
} else {
echo json_encode(['success' => true, 'settings' => ['nickname' => '']]);
}
break;
case 'save_badge_settings':
$dataDir = __DIR__ . '/data';
if (!is_dir($dataDir)) {
mkdir($dataDir, 0755, true);
}
$settingsFile = $dataDir . '/badge_settings.json';
$settings = [
'nickname' => trim((string)($_POST['nickname'] ?? '')),
];
if (file_put_contents($settingsFile, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['error' => 'Не удалось сохранить настройки']);
}
break;
case 'badge_save':
// Save generated badge PNG (sent as base64 data URL) to /data/badges/.
$dataUrl = $_POST['image'] ?? '';
if (strpos($dataUrl, 'data:image/png;base64,') !== 0) {
echo json_encode(['error' => 'Некорректный формат изображения']);
exit;
}
$base64 = substr($dataUrl, strlen('data:image/png;base64,'));
$binary = base64_decode($base64, true);
if ($binary === false) {
echo json_encode(['error' => 'Не удалось декодировать изображение']);
exit;
}
if (strlen($binary) > 5 * 1024 * 1024) {
echo json_encode(['error' => 'Файл слишком большой']);
exit;
}
$badgesDir = __DIR__ . '/data/badges';
if (!is_dir($badgesDir)) {
mkdir($badgesDir, 0755, true);
}
$filename = 'badge_' . date('Ymd_His') . '_' . substr(bin2hex(random_bytes(4)), 0, 8) . '.png';
$filepath = $badgesDir . '/' . $filename;
if (file_put_contents($filepath, $binary) === false) {
echo json_encode(['error' => 'Не удалось сохранить файл']);
exit;
}
$indexFile = $badgesDir . '/index.json';
$index = [];
if (file_exists($indexFile)) {
$index = json_decode(file_get_contents($indexFile), true) ?: [];
}
array_unshift($index, [
'filename' => $filename,
'created_at' => date('Y-m-d H:i:s'),
'has_price' => !empty($_POST['has_price']),
'has_nickname' => !empty($_POST['has_nickname']),
'label' => trim((string)($_POST['label'] ?? '')),
]);
// Keep at most 100 entries
$index = array_slice($index, 0, 100);
file_put_contents($indexFile, json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$path = dirname($_SERVER['REQUEST_URI']);
$url = $protocol . '://' . $host . $path . '/data/badges/' . $filename;
echo json_encode(['success' => true, 'filename' => $filename, 'url' => $url]);
break;
case 'badge_list':
$indexFile = __DIR__ . '/data/badges/index.json';
if (!file_exists($indexFile)) {
echo json_encode(['success' => true, 'items' => []]);
break;
}
$index = json_decode(file_get_contents($indexFile), true) ?: [];
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$path = dirname($_SERVER['REQUEST_URI']);
$items = [];
foreach ($index as $entry) {
$fname = $entry['filename'] ?? '';
if (!$fname) continue;
$absPath = __DIR__ . '/data/badges/' . $fname;
if (!file_exists($absPath)) continue;
$items[] = [
'filename' => $fname,
'created_at' => $entry['created_at'] ?? '',
'has_price' => !empty($entry['has_price']),
'has_nickname' => !empty($entry['has_nickname']),
'label' => $entry['label'] ?? '',
'url' => $protocol . '://' . $host . $path . '/data/badges/' . $fname,
];
}
echo json_encode(['success' => true, 'items' => $items]);
break;
case 'badge_delete':
$filename = basename($_POST['filename'] ?? '');
if (!$filename || strpos($filename, 'badge_') !== 0) {
echo json_encode(['error' => 'Некорректное имя файла']);
exit;
}
$badgesDir = __DIR__ . '/data/badges';
$filepath = $badgesDir . '/' . $filename;
if (file_exists($filepath)) {
@unlink($filepath);
}
$indexFile = $badgesDir . '/index.json';
if (file_exists($indexFile)) {
$index = json_decode(file_get_contents($indexFile), true) ?: [];
$index = array_values(array_filter($index, function($e) use ($filename) {
return ($e['filename'] ?? '') !== $filename;
}));
file_put_contents($indexFile, json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
echo json_encode(['success' => true]);
break;
default:
echo json_encode(['error' => 'Unknown action']);
}
+280
View File
@@ -4006,3 +4006,283 @@ select {
max-height: 75vh;
}
}
/* ============ Digital Badge Tab ============ */
.badge-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
margin-top: 8px;
}
.badge-item {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
background: var(--bg-tertiary);
border: 2px solid transparent;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.badge-item:hover {
transform: translateY(-2px);
}
.badge-item.active {
border-color: #007AFF;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.18);
}
.badge-item .badge-item-preview {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.badge-item .badge-item-remove {
position: absolute;
top: 6px;
right: 6px;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
}
.badge-item:hover .badge-item-remove {
opacity: 1;
}
/* Touch devices: always-visible controls (no hover state). */
@media (hover: none) {
.badge-item .badge-item-remove,
.badge-history-item .badge-history-actions {
opacity: 1;
}
.badge-actions .btn,
.badge-items-grid,
.badge-history-grid {
-webkit-tap-highlight-color: transparent;
}
.badge-items-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
/* Larger touch targets for action buttons. */
.badge-history-item .badge-history-actions button {
font-size: 13px;
padding: 6px 10px;
}
}
.badge-item .badge-item-badge-dot {
position: absolute;
bottom: 6px;
right: 6px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #34C759;
box-shadow: 0 0 0 2px #fff;
display: none;
}
.badge-item.has-extras .badge-item-badge-dot {
display: block;
}
.badge-editor {
margin-top: 20px;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
}
.badge-editor.hidden,
#badge-batch-actions.hidden,
.badge-sub-options.hidden {
display: none;
}
.badge-editor-layout {
display: grid;
grid-template-columns: minmax(280px, 480px) 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 800px) {
.badge-editor-layout {
grid-template-columns: 1fr;
}
}
.badge-canvas-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
#badge-canvas {
width: 100%;
max-width: 480px;
height: auto;
aspect-ratio: 1 / 1;
background:
linear-gradient(45deg, #d8d8de 25%, transparent 25%) 0 0/16px 16px,
linear-gradient(-45deg, #d8d8de 25%, transparent 25%) 0 8px/16px 16px,
linear-gradient(45deg, transparent 75%, #d8d8de 75%) 8px -8px/16px 16px,
linear-gradient(-45deg, transparent 75%, #d8d8de 75%) -8px 0/16px 16px,
#f0f0f3;
border-radius: 12px;
cursor: grab;
touch-action: none;
user-select: none;
}
[data-theme="dark"] #badge-canvas {
background:
linear-gradient(45deg, #2a2a2e 25%, transparent 25%) 0 0/16px 16px,
linear-gradient(-45deg, #2a2a2e 25%, transparent 25%) 0 8px/16px 16px,
linear-gradient(45deg, transparent 75%, #2a2a2e 75%) 8px -8px/16px 16px,
linear-gradient(-45deg, transparent 75%, #2a2a2e 75%) -8px 0/16px 16px,
#1c1c1e;
}
#badge-canvas:active {
cursor: grabbing;
}
.badge-canvas-hint {
font-size: 0.85em;
color: var(--text-secondary);
text-align: center;
}
.badge-controls {
display: flex;
flex-direction: column;
gap: 4px;
}
.badge-controls .form-group {
margin-bottom: 8px;
}
.badge-controls input[type="range"] {
width: 100%;
accent-color: #007AFF;
}
.badge-radio-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 4px;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 0.92em;
color: var(--text-primary);
}
.radio-label input[type="radio"] {
accent-color: #007AFF;
cursor: pointer;
}
.badge-sub-options {
padding-left: 22px;
margin-top: 6px;
}
.badge-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 8px;
}
.badge-history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-top: 8px;
}
.badge-history-item {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 12px;
overflow: hidden;
background: var(--bg-tertiary);
}
.badge-history-item img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.badge-history-item .badge-history-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 6px 8px;
background: linear-gradient(180deg, rgba(0,0,0,0), rgba(0,0,0,0.55));
opacity: 0;
transition: opacity 0.15s ease;
}
.badge-history-item:hover .badge-history-actions {
opacity: 1;
}
.badge-history-item .badge-history-actions button {
border: none;
background: rgba(255,255,255,0.92);
color: #1c1c1e;
border-radius: 8px;
font-size: 12px;
padding: 4px 8px;
cursor: pointer;
}
.badge-history-item .badge-history-actions button.danger {
background: rgba(255, 59, 48, 0.92);
color: #fff;
}
.badge-history-item .badge-history-date {
position: absolute;
top: 6px;
left: 6px;
font-size: 10px;
color: #fff;
background: rgba(0,0,0,0.5);
padding: 2px 6px;
border-radius: 6px;
}
+150
View File
@@ -81,6 +81,7 @@ if (isset($_GET['logout'])) {
<nav class="main-nav">
<button class="nav-btn" data-tab="gallery">Галерея</button>
<button class="nav-btn active" data-tab="posting">Публикация</button>
<button class="nav-btn" data-tab="badge">Цифровой бейдж</button>
<button class="nav-btn" data-tab="converter">Конвертер</button>
<button class="nav-btn" data-tab="widget">Виджет</button>
<button class="nav-btn" data-tab="settings">Настройки</button>
@@ -561,6 +562,143 @@ https://live.staticflickr.com/65535/12345678901_abcdef1234_b.jpg"></textarea>
</div>
</section>
<!-- Tab: Digital Badge (round display) -->
<section id="tab-badge" class="tab-content">
<div class="panel">
<h2>Цифровой бейдж</h2>
<p class="help-text">Подготовьте фотографии под круглые дисплеи 240×240 пикселей. Кадрируйте, добавьте ценник или подпись никнеймом дугой снизу.</p>
<!-- Photo sources -->
<div class="form-group">
<label>Источник фотографий:</label>
<div class="photo-source-buttons">
<input type="file" id="badge-file-upload" multiple accept="image/*" style="display: none;">
<button type="button" class="btn btn-secondary" id="btn-badge-upload">
<span class="btn-icon-text">📤</span> Загрузить с устройства
</button>
<button type="button" class="btn btn-secondary" id="btn-badge-from-flickr">
<span class="btn-icon-text">🖼</span> Выбранные в галерее Flickr
</button>
</div>
<p class="hint">Размер итогового кружка 240×240 PNG с прозрачным фоном.</p>
</div>
<!-- Items grid (selected photos for badge processing) -->
<div class="form-group">
<label>Фотографии для бейджа: <span id="badge-items-count" class="photo-counter">0</span></label>
<div id="badge-items-grid" class="badge-items-grid">
<p class="placeholder">Выберите фото из галереи или загрузите с устройства</p>
</div>
</div>
<!-- Editor (visible when a photo is selected) -->
<div id="badge-editor" class="badge-editor hidden">
<div class="badge-editor-layout">
<!-- Canvas preview -->
<div class="badge-canvas-wrapper">
<canvas id="badge-canvas" width="480" height="480"></canvas>
<div class="badge-canvas-hint">Один палец сдвиг, два пальца масштаб. На компьютере мышь и колесо.</div>
</div>
<!-- Controls -->
<div class="badge-controls">
<div class="form-group">
<label>Масштаб: <span id="badge-zoom-value">100%</span></label>
<input type="range" id="badge-zoom" min="100" max="400" step="1" value="100">
</div>
<div class="form-group">
<button type="button" id="btn-badge-reset" class="btn btn-secondary btn-small">Сбросить кадрирование</button>
</div>
<hr style="border:none; border-top:1px solid var(--border-color, #ddd); margin:12px 0;">
<!-- Price tag -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="badge-price-enabled">
<span>Добавить ценник</span>
</label>
</div>
<div id="badge-price-options" class="badge-sub-options hidden">
<div class="form-group">
<label for="badge-price-value">Цена ():</label>
<input type="number" inputmode="numeric" pattern="[0-9]*" id="badge-price-value" min="0" step="1" placeholder="2500">
</div>
<div class="form-group">
<label>Стиль плашки:</label>
<div class="badge-radio-row">
<label class="radio-label">
<input type="radio" name="badge-price-style" value="arc" checked>
<span>Дуга по низу</span>
</label>
<label class="radio-label">
<input type="radio" name="badge-price-style" value="rect">
<span>Прямоугольник</span>
</label>
</div>
</div>
</div>
<hr style="border:none; border-top:1px solid var(--border-color, #ddd); margin:12px 0;">
<!-- Nickname -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="badge-nickname-enabled">
<span>Добавить никнейм (дугой снизу)</span>
</label>
</div>
<div id="badge-nickname-options" class="badge-sub-options hidden">
<div class="form-group">
<label for="badge-nickname-value">Текст:</label>
<input type="text" id="badge-nickname-value" maxlength="40" placeholder="Никнейм">
<span class="hint">По умолчанию из настроек.</span>
</div>
<div class="form-group">
<label>Цвет текста:</label>
<div class="badge-radio-row">
<label class="radio-label">
<input type="radio" name="badge-nick-color" value="white" checked>
<span>Белый (с тенью)</span>
</label>
<label class="radio-label">
<input type="radio" name="badge-nick-color" value="black">
<span>Чёрный</span>
</label>
</div>
</div>
</div>
<hr style="border:none; border-top:1px solid var(--border-color, #ddd); margin:12px 0;">
<div class="badge-actions">
<button type="button" id="btn-badge-download" class="btn btn-primary">
<span class="btn-icon-text"></span> <span id="badge-download-label">Сохранить PNG</span>
</button>
<button type="button" id="btn-badge-remove" class="btn btn-secondary btn-small">Убрать из списка</button>
</div>
</div>
</div>
</div>
<!-- Batch actions -->
<div id="badge-batch-actions" class="form-group hidden" style="margin-top: 16px;">
<button type="button" id="btn-badge-download-all" class="btn btn-secondary">
<span class="btn-icon-text"></span> Скачать все бейджи
</button>
</div>
<!-- History -->
<div class="settings-section" style="margin-top: 24px;">
<h3>История сохранённых бейджей</h3>
<div id="badge-history-grid" class="badge-history-grid">
<p class="placeholder">История пуста сгенерированные бейджи будут появляться здесь.</p>
</div>
</div>
</div>
</section>
<!-- Tab: Settings -->
<section id="tab-settings" class="tab-content">
<div class="panel">
@@ -683,6 +821,18 @@ foreach ($channels as $ch) {
<span id="cross-promo-save-status" class="save-status"></span>
</div>
<!-- Digital Badge Settings -->
<div class="settings-section">
<h3>Цифровой бейдж</h3>
<p class="help-text">Никнейм по умолчанию для подписи дугой снизу.</p>
<div class="form-group">
<label for="badge-nickname-default">Никнейм для бейджа:</label>
<input type="text" id="badge-nickname-default" maxlength="40" placeholder="Ваш никнейм">
<button id="btn-save-badge-nickname" class="btn btn-primary btn-small" style="margin-left: 10px;">Сохранить</button>
<span id="badge-nickname-save-status" class="save-status"></span>
</div>
</div>
<!-- Theme -->
<div class="settings-section">
<h3>Оформление</h3>
+882
View File
@@ -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();
}
});