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;
|
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:
|
default:
|
||||||
echo json_encode(['error' => 'Unknown action']);
|
echo json_encode(['error' => 'Unknown action']);
|
||||||
}
|
}
|
||||||
|
|||||||
+280
@@ -4006,3 +4006,283 @@ select {
|
|||||||
max-height: 75vh;
|
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">
|
<nav class="main-nav">
|
||||||
<button class="nav-btn" data-tab="gallery">Галерея</button>
|
<button class="nav-btn" data-tab="gallery">Галерея</button>
|
||||||
<button class="nav-btn active" data-tab="posting">Публикация</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="converter">Конвертер</button>
|
||||||
<button class="nav-btn" data-tab="widget">Виджет</button>
|
<button class="nav-btn" data-tab="widget">Виджет</button>
|
||||||
<button class="nav-btn" data-tab="settings">Настройки</button>
|
<button class="nav-btn" data-tab="settings">Настройки</button>
|
||||||
@@ -561,6 +562,143 @@ https://live.staticflickr.com/65535/12345678901_abcdef1234_b.jpg"></textarea>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Tab: Settings -->
|
||||||
<section id="tab-settings" class="tab-content">
|
<section id="tab-settings" class="tab-content">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -683,6 +821,18 @@ foreach ($channels as $ch) {
|
|||||||
<span id="cross-promo-save-status" class="save-status"></span>
|
<span id="cross-promo-save-status" class="save-status"></span>
|
||||||
</div>
|
</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 -->
|
<!-- Theme -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Оформление</h3>
|
<h3>Оформление</h3>
|
||||||
|
|||||||
@@ -4224,4 +4224,886 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
loadAlbums(false);
|
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