From 68f4560bd53c2759b25f0ed9527f768b7d4a79a1 Mon Sep 17 00:00:00 2001 From: zuevav <34027267+zuevav@users.noreply.github.com> Date: Thu, 14 May 2026 21:16:37 +0300 Subject: [PATCH] Add digital badge tab for round-display photo prep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api.php | 133 ++++++++ css/style.css | 280 ++++++++++++++++ index.php | 150 +++++++++ js/app.js | 882 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1445 insertions(+) diff --git a/api.php b/api.php index 6020685..8ec0f46 100644 --- a/api.php +++ b/api.php @@ -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']); } diff --git a/css/style.css b/css/style.css index c6c3b06..91b05cd 100644 --- a/css/style.css +++ b/css/style.css @@ -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; +} diff --git a/index.php b/index.php index 0e1d7e2..1eef724 100644 --- a/index.php +++ b/index.php @@ -81,6 +81,7 @@ if (isset($_GET['logout'])) {