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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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