${escapeHtml(post.text.substring(0, 150))}${post.text.length > 150 ? '...' : ''}
` : ''} + ${post.tags?.length ? `` : ''} +diff --git a/SYSTEM.md b/SYSTEM.md
new file mode 100644
index 0000000..751221b
--- /dev/null
+++ b/SYSTEM.md
@@ -0,0 +1,409 @@
+# VH Posting System - Описание системы
+
+**Версия:** 1.0
+**Дата:** 2026-01-07
+**Совместимость:** PHP 7.2+
+**Хостинг:** reg.ru (shared hosting)
+
+---
+
+## Общее описание
+
+VH Posting System — веб-приложение для работы с изображениями Flickr. Позволяет конвертировать ссылки в различные форматы для публикации на форумах, блогах и социальных сетях, а также напрямую публиковать в Telegram-каналы.
+
+---
+
+## Структура проекта
+
+```
+VH_posting_system/
+├── index.php # Главная страница (требует авторизации)
+├── login.php # Страница входа
+├── setup.php # Первоначальная настройка (создание админа)
+├── api.php # API эндпоинты для AJAX-запросов
+├── config.php # Конфигурация (API ключи) - НЕ в git
+├── config.example.php # Пример конфигурации
+├── auth_config.php # Данные пользователей - НЕ в git, создаётся автоматически
+├── .htaccess # Настройки Apache
+├── .gitignore # Исключения для git
+│
+├── classes/ # PHP классы
+│ ├── Auth.php # Система авторизации
+│ ├── FlickrAPI.php # Клиент Flickr API
+│ ├── FlickrParser.php # Парсер ссылок Flickr
+│ ├── FormatGenerator.php # Генератор форматов вывода
+│ └── TelegramBot.php # Клиент Telegram Bot API
+│
+├── css/
+│ └── style.css # Стили интерфейса
+│
+├── js/
+│ └── app.js # Фронтенд логика
+│
+└── templates/ # (зарезервировано для шаблонов)
+```
+
+---
+
+## Функциональные модули
+
+### 1. Link Converter (Конвертер ссылок)
+
+**Назначение:** Преобразование ссылок Flickr в различные форматы.
+
+**Поддерживаемые входные форматы:**
+- Страница фото: `https://www.flickr.com/photos/username/12345678901/`
+- Короткая ссылка: `https://flic.kr/p/ABC123`
+- Прямая ссылка: `https://live.staticflickr.com/65535/12345678901_abcdef1234_b.jpg`
+
+**Форматы вывода:**
+
+| Ключ | Название | Шаблон | Применение |
+|------|----------|--------|------------|
+| `bbcode` | BBCode | `[img]{url}[/img]` | Форумы phpBB, vBulletin, IPB |
+| `bbcode_linked` | BBCode (clickable) | `[url={original}][img]{url}[/img][/url]` | Кликабельные превью |
+| `html` | HTML | `` | Веб-сайты |
+| `html_linked` | HTML (clickable) | `
',
+ 'separator' => "\n",
+ ],
+ 'html_linked' => [
+ 'name' => 'HTML (clickable)',
+ 'description' => 'HTML img with link to original',
+ 'template' => '
',
+ 'separator' => "\n",
+ ],
+ 'html_figure' => [
+ 'name' => 'HTML Figure',
+ 'description' => 'HTML5 figure with caption',
+ 'template' => "
\n
',
+ 'separator' => "\n\n",
+ 'category' => 'forum',
+ ],
+ 'babiki_simple' => [
+ 'name' => 'Babiki.ru (простой)',
+ 'description' => 'Только картинки для Бэбиков',
+ 'template' => '
',
+ 'separator' => "\n",
+ 'category' => 'forum',
+ ],
+ 'doll_forum' => [
+ 'name' => 'Кукольный форум',
+ 'description' => 'Универсальный BBCode для кукольных форумов',
+ 'template' => '[url={original}][img]{url}[/img][/url]',
+ 'separator' => "\n",
+ 'category' => 'forum',
+ ],
+ ];
+
+ /**
+ * Add a custom format
+ *
+ * @param string $key Format key
+ * @param string $name Display name
+ * @param string $template Template with placeholders
+ * @param string $separator Separator between multiple images
+ * @param string $description Format description
+ */
+ public function addFormat($key, $name, $template, $separator = "\n", $description = '')
+ {
+ $this->formats[$key] = [
+ 'name' => $name,
+ 'description' => $description,
+ 'template' => $template,
+ 'separator' => $separator,
+ ];
+ }
+
+ /**
+ * Get all available formats
+ *
+ * @return array Format definitions
+ */
+ public function getFormats()
+ {
+ return $this->formats;
+ }
+
+ /**
+ * Generate formatted output for a single image
+ *
+ * @param string $formatKey Format key
+ * @param array $imageData Image data with url, original, title
+ * @return string Formatted output
+ */
+ public function generate($formatKey, $imageData)
+ {
+ if (!isset($this->formats[$formatKey])) {
+ throw new InvalidArgumentException("Unknown format: {$formatKey}");
+ }
+
+ $template = $this->formats[$formatKey]['template'];
+
+ $replacements = [
+ '{url}' => isset($imageData['url']) ? $imageData['url'] : '',
+ '{original}' => isset($imageData['original']) ? $imageData['original'] : (isset($imageData['url']) ? $imageData['url'] : ''),
+ '{title}' => htmlspecialchars(isset($imageData['title']) ? $imageData['title'] : 'Image', ENT_QUOTES),
+ '{description}' => htmlspecialchars(isset($imageData['description']) ? $imageData['description'] : '', ENT_QUOTES),
+ '{photo_id}' => isset($imageData['photo_id']) ? $imageData['photo_id'] : '',
+ '{width}' => isset($imageData['width']) ? $imageData['width'] : '',
+ '{height}' => isset($imageData['height']) ? $imageData['height'] : '',
+ ];
+
+ return str_replace(array_keys($replacements), array_values($replacements), $template);
+ }
+
+ /**
+ * Generate formatted output for multiple images
+ *
+ * @param string $formatKey Format key
+ * @param array $images Array of image data
+ * @return string Formatted output
+ */
+ public function generateMultiple($formatKey, $images)
+ {
+ if (!isset($this->formats[$formatKey])) {
+ throw new InvalidArgumentException("Unknown format: {$formatKey}");
+ }
+
+ $separator = $this->formats[$formatKey]['separator'];
+ $outputs = [];
+
+ foreach ($images as $imageData) {
+ $outputs[] = $this->generate($formatKey, $imageData);
+ }
+
+ return implode($separator, $outputs);
+ }
+
+ /**
+ * Generate output in all formats at once
+ *
+ * @param array $images Array of image data
+ * @return array Keyed by format key
+ */
+ public function generateAll($images)
+ {
+ $result = [];
+
+ foreach (array_keys($this->formats) as $formatKey) {
+ $result[$formatKey] = [
+ 'name' => $this->formats[$formatKey]['name'],
+ 'description' => $this->formats[$formatKey]['description'],
+ 'output' => $this->generateMultiple($formatKey, $images),
+ ];
+ }
+
+ return $result;
+ }
+}
diff --git a/classes/TelegramBot.php b/classes/TelegramBot.php
new file mode 100644
index 0000000..41f0ea6
--- /dev/null
+++ b/classes/TelegramBot.php
@@ -0,0 +1,283 @@
+botToken = $botToken;
+ }
+
+ /**
+ * Set default channels for posting
+ *
+ * @param array $channels Array of channel usernames or IDs
+ */
+ public function setDefaultChannels($channels)
+ {
+ $this->defaultChannels = $channels;
+ }
+
+ /**
+ * Make API request to Telegram
+ *
+ * @param string $method API method
+ * @param array $params Parameters
+ * @return array Response
+ */
+ private function request($method, $params = [])
+ {
+ $url = $this->baseUrl . $this->botToken . '/' . $method;
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $params,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 60,
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+
+ $response = curl_exec($ch);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ if ($error) {
+ throw new RuntimeException("Telegram API error: {$error}");
+ }
+
+ $data = json_decode($response, true);
+
+ if (!$data['ok']) {
+ throw new RuntimeException("Telegram API error: " . (isset($data['description']) ? $data['description'] : 'Unknown error'));
+ }
+
+ return isset($data['result']) ? $data['result'] : [];
+ }
+
+ /**
+ * Send a text message
+ *
+ * @param string $chatId Chat/Channel ID or username
+ * @param string $text Message text
+ * @param string $parseMode Parse mode (HTML, Markdown, MarkdownV2)
+ * @param bool $disablePreview Disable link preview
+ * @return array Message info
+ */
+ public function sendMessage($chatId, $text, $parseMode = 'HTML', $disablePreview = false)
+ {
+ return $this->request('sendMessage', [
+ 'chat_id' => $chatId,
+ 'text' => $text,
+ 'parse_mode' => $parseMode,
+ 'disable_web_page_preview' => $disablePreview,
+ ]);
+ }
+
+ /**
+ * Send a single photo
+ *
+ * @param string $chatId Chat/Channel ID
+ * @param string $photo Photo URL or file_id
+ * @param string $caption Photo caption
+ * @param string $parseMode Parse mode for caption
+ * @return array Message info
+ */
+ public function sendPhoto($chatId, $photo, $caption = '', $parseMode = 'HTML')
+ {
+ $params = [
+ 'chat_id' => $chatId,
+ 'photo' => $photo,
+ ];
+
+ if ($caption) {
+ $params['caption'] = $caption;
+ $params['parse_mode'] = $parseMode;
+ }
+
+ return $this->request('sendPhoto', $params);
+ }
+
+ /**
+ * Send multiple photos as media group (album)
+ *
+ * @param string $chatId Chat/Channel ID
+ * @param array $photos Array of photo URLs
+ * @param string $caption Caption for first photo
+ * @param string $parseMode Parse mode
+ * @return array Messages info
+ */
+ public function sendMediaGroup($chatId, $photos, $caption = '', $parseMode = 'HTML')
+ {
+ $media = [];
+
+ foreach ($photos as $index => $photo) {
+ $item = [
+ 'type' => 'photo',
+ 'media' => $photo,
+ ];
+
+ // Caption only on first photo
+ if ($index === 0 && $caption) {
+ $item['caption'] = $caption;
+ $item['parse_mode'] = $parseMode;
+ }
+
+ $media[] = $item;
+ }
+
+ return $this->request('sendMediaGroup', [
+ 'chat_id' => $chatId,
+ 'media' => json_encode($media),
+ ]);
+ }
+
+ /**
+ * Post photos with text to a channel
+ * Smart method that chooses best approach based on content
+ *
+ * @param string $chatId Chat/Channel ID
+ * @param array $photos Array of photo URLs
+ * @param string $text Post text
+ * @param string $parseMode Parse mode
+ * @return array Result info
+ */
+ public function post($chatId, $photos, $text = '', $parseMode = 'HTML')
+ {
+ $photoCount = count($photos);
+
+ // Text only
+ if ($photoCount === 0) {
+ return ['type' => 'text', 'message' => $this->sendMessage($chatId, $text, $parseMode)];
+ }
+
+ // Single photo
+ if ($photoCount === 1) {
+ return ['type' => 'photo', 'message' => $this->sendPhoto($chatId, $photos[0], $text, $parseMode)];
+ }
+
+ // Multiple photos (2-10) - use media group
+ if ($photoCount <= 10) {
+ return ['type' => 'album', 'messages' => $this->sendMediaGroup($chatId, $photos, $text, $parseMode)];
+ }
+
+ // More than 10 photos - split into multiple albums
+ $results = [];
+ $chunks = array_chunk($photos, 10);
+
+ foreach ($chunks as $index => $chunk) {
+ $caption = ($index === 0) ? $text : '';
+ $results[] = $this->sendMediaGroup($chatId, $chunk, $caption, $parseMode);
+ }
+
+ return ['type' => 'multiple_albums', 'messages' => $results];
+ }
+
+ /**
+ * Post to multiple channels at once
+ *
+ * @param array $chatIds Array of Chat/Channel IDs
+ * @param array $photos Array of photo URLs
+ * @param string $text Post text
+ * @param string $parseMode Parse mode
+ * @return array Results for each channel
+ */
+ public function postToMultiple($chatIds, $photos, $text = '', $parseMode = 'HTML')
+ {
+ $results = [];
+
+ foreach ($chatIds as $chatId) {
+ try {
+ $results[$chatId] = [
+ 'success' => true,
+ 'result' => $this->post($chatId, $photos, $text, $parseMode),
+ ];
+ } catch (Exception $e) {
+ $results[$chatId] = [
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ ];
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get bot info
+ *
+ * @return array Bot info
+ */
+ public function getMe()
+ {
+ return $this->request('getMe');
+ }
+
+ /**
+ * Get chat info
+ *
+ * @param string $chatId Chat/Channel ID
+ * @return array Chat info
+ */
+ public function getChat($chatId)
+ {
+ return $this->request('getChat', ['chat_id' => $chatId]);
+ }
+
+ /**
+ * Validate that bot has access to a channel
+ *
+ * @param string $chatId Channel ID or username
+ * @return array Validation result
+ */
+ public function validateChannel($chatId)
+ {
+ try {
+ $chat = $this->getChat($chatId);
+ return [
+ 'valid' => true,
+ 'title' => isset($chat['title']) ? $chat['title'] : (isset($chat['username']) ? $chat['username'] : $chatId),
+ 'type' => $chat['type'],
+ ];
+ } catch (Exception $e) {
+ return [
+ 'valid' => false,
+ 'error' => $e->getMessage(),
+ ];
+ }
+ }
+
+ /**
+ * Format text for Telegram HTML mode
+ *
+ * @param string $text Plain text
+ * @return string Escaped HTML
+ */
+ public static function escapeHtml($text)
+ {
+ return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ }
+
+ /**
+ * Format text for Telegram MarkdownV2 mode
+ *
+ * @param string $text Plain text
+ * @return string Escaped Markdown
+ */
+ public static function escapeMarkdown($text)
+ {
+ $chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
+ foreach ($chars as $char) {
+ $text = str_replace($char, '\\' . $char, $text);
+ }
+ return $text;
+ }
+}
diff --git a/classes/VKAPI.php b/classes/VKAPI.php
new file mode 100644
index 0000000..d6959a2
--- /dev/null
+++ b/classes/VKAPI.php
@@ -0,0 +1,403 @@
+accessToken = $accessToken;
+ }
+
+ /**
+ * Make API request to VK
+ *
+ * @param string $method API method
+ * @param array $params Parameters
+ * @return array Response
+ */
+ private function request($method, $params = [])
+ {
+ $params['access_token'] = $this->accessToken;
+ $params['v'] = $this->apiVersion;
+
+ $url = $this->baseUrl . $method;
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => http_build_query($params),
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 60,
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+
+ $response = curl_exec($ch);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ if ($error) {
+ throw new RuntimeException("VK API connection error: {$error}");
+ }
+
+ $data = json_decode($response, true);
+
+ if (isset($data['error'])) {
+ $errorMsg = isset($data['error']['error_msg']) ? $data['error']['error_msg'] : 'Unknown error';
+ $errorCode = isset($data['error']['error_code']) ? $data['error']['error_code'] : 0;
+ throw new RuntimeException("VK API error [{$errorCode}]: {$errorMsg}");
+ }
+
+ return isset($data['response']) ? $data['response'] : [];
+ }
+
+ /**
+ * Get current user info
+ *
+ * @return array User info
+ */
+ public function getMe()
+ {
+ $result = $this->request('users.get', [
+ 'fields' => 'photo_100,screen_name'
+ ]);
+
+ if (!empty($result[0])) {
+ $this->userId = $result[0]['id'];
+ return $result[0];
+ }
+
+ return [];
+ }
+
+ /**
+ * Get groups where user can post
+ *
+ * @param int $count Number of groups to return
+ * @return array Groups list
+ */
+ public function getGroups($count = 100)
+ {
+ $result = $this->request('groups.get', [
+ 'extended' => 1,
+ 'filter' => 'admin,editor,moder',
+ 'fields' => 'name,screen_name,photo_100,can_post',
+ 'count' => $count
+ ]);
+
+ $groups = [];
+ if (isset($result['items'])) {
+ foreach ($result['items'] as $group) {
+ // Only groups where posting is allowed
+ if (!empty($group['can_post']) || isset($group['admin_level'])) {
+ $groups[] = [
+ 'id' => '-' . $group['id'], // Negative for group wall
+ 'name' => $group['name'],
+ 'screen_name' => isset($group['screen_name']) ? $group['screen_name'] : '',
+ 'photo' => isset($group['photo_100']) ? $group['photo_100'] : '',
+ ];
+ }
+ }
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Upload photo to VK from URL
+ *
+ * @param int $groupId Group ID (without minus, positive number)
+ * @param string $photoUrl Photo URL to upload
+ * @return string Attachment string (photo{owner}_{id})
+ */
+ public function uploadPhotoFromUrl($groupId, $photoUrl)
+ {
+ $groupId = abs((int)$groupId);
+
+ // Get upload server
+ try {
+ $uploadServer = $this->request('photos.getWallUploadServer', [
+ 'group_id' => $groupId
+ ]);
+ } catch (Exception $e) {
+ throw new RuntimeException('Не удалось получить сервер загрузки VK: ' . $e->getMessage());
+ }
+
+ if (!isset($uploadServer['upload_url'])) {
+ throw new RuntimeException('VK не вернул URL для загрузки фото');
+ }
+
+ // Download photo from URL with context for HTTPS
+ $context = stream_context_create([
+ 'http' => [
+ 'timeout' => 30,
+ 'user_agent' => 'VH-Posting-System/1.0'
+ ],
+ 'ssl' => [
+ 'verify_peer' => true,
+ 'verify_peer_name' => true
+ ]
+ ]);
+
+ $photoData = @file_get_contents($photoUrl, false, $context);
+ if ($photoData === false) {
+ throw new RuntimeException('Не удалось скачать фото с Flickr: ' . $photoUrl);
+ }
+
+ // Detect mime type
+ $finfo = new finfo(FILEINFO_MIME_TYPE);
+ $mimeType = $finfo->buffer($photoData) ?: 'image/jpeg';
+ $extension = $mimeType === 'image/png' ? 'png' : 'jpg';
+
+ // Save to temp file
+ $tempFile = tempnam(sys_get_temp_dir(), 'vk_photo_');
+ file_put_contents($tempFile, $photoData);
+
+ // Upload photo using CURLFile
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $uploadServer['upload_url'],
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => [
+ 'photo' => new CURLFile($tempFile, $mimeType, 'photo.' . $extension)
+ ],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 120,
+ ]);
+
+ $uploadResponse = curl_exec($ch);
+ $curlError = curl_error($ch);
+ curl_close($ch);
+ unlink($tempFile);
+
+ if ($curlError) {
+ throw new RuntimeException('Ошибка загрузки фото на VK: ' . $curlError);
+ }
+
+ $uploadData = json_decode($uploadResponse, true);
+ if (!$uploadData) {
+ throw new RuntimeException('Неверный ответ от сервера VK при загрузке');
+ }
+
+ if (empty($uploadData['photo']) || $uploadData['photo'] === '[]') {
+ $errorMsg = isset($uploadData['error']) ? $uploadData['error'] : 'пустой ответ';
+ throw new RuntimeException('VK не принял фото: ' . $errorMsg);
+ }
+
+ // Save photo
+ try {
+ $savedPhoto = $this->request('photos.saveWallPhoto', [
+ 'group_id' => $groupId,
+ 'photo' => $uploadData['photo'],
+ 'server' => $uploadData['server'],
+ 'hash' => $uploadData['hash']
+ ]);
+ } catch (Exception $e) {
+ throw new RuntimeException('Не удалось сохранить фото в VK: ' . $e->getMessage());
+ }
+
+ if (empty($savedPhoto[0])) {
+ throw new RuntimeException('VK не вернул данные сохранённого фото');
+ }
+
+ $photo = $savedPhoto[0];
+ return 'photo' . $photo['owner_id'] . '_' . $photo['id'];
+ }
+
+ /**
+ * Post to wall/group
+ *
+ * @param int $ownerId User ID or Group ID (negative for groups)
+ * @param string $message Post text
+ * @param array $attachments Array of attachments
+ * @param bool $fromGroup Post from group name (only for groups)
+ * @return array Post info
+ */
+ public function wallPost($ownerId, $message = '', $attachments = [], $fromGroup = true)
+ {
+ $params = [
+ 'owner_id' => $ownerId,
+ 'message' => $message,
+ ];
+
+ if (!empty($attachments)) {
+ $params['attachments'] = implode(',', $attachments);
+ $params['primary_attachments_mode'] = 'grid';
+ }
+
+ // If posting to group, post from group name
+ if ($ownerId < 0 && $fromGroup) {
+ $params['from_group'] = 1;
+ }
+
+ return $this->request('wall.post', $params);
+ }
+
+ /**
+ * Post photos and text to a group
+ *
+ * @param int $groupId Group ID (will be converted to negative)
+ * @param array $photoUrls Array of photo URLs
+ * @param string $message Post text
+ * @return array Result
+ */
+ public function post($groupId, $photoUrls = [], $message = '')
+ {
+ $attachments = [];
+ $uploadErrors = [];
+ $permissionError = false;
+
+ // Make sure group ID is numeric
+ $numericGroupId = (int)$groupId;
+ if ($numericGroupId > 0) {
+ $numericGroupId = -$numericGroupId;
+ }
+
+ // Try to upload each photo
+ foreach ($photoUrls as $url) {
+ try {
+ $attachments[] = $this->uploadPhotoFromUrl(abs($numericGroupId), $url);
+ } catch (Exception $e) {
+ $errorMsg = $e->getMessage();
+ $uploadErrors[] = $errorMsg;
+ error_log("VK photo upload failed: " . $errorMsg);
+
+ // Check if it's a permission error (error 15 = Access denied, error 27 = group auth)
+ if (strpos($errorMsg, 'error [15]') !== false ||
+ strpos($errorMsg, 'error [27]') !== false ||
+ strpos($errorMsg, 'Access denied') !== false ||
+ strpos($errorMsg, 'group auth') !== false) {
+ $permissionError = true;
+ break; // Don't try other photos if it's a permission issue
+ }
+ }
+ }
+
+ // If permission error, try to post with photo links in text
+ if ($permissionError && !empty($photoUrls)) {
+ $photoLinks = "\n\n📷 Фото:\n" . implode("\n", $photoUrls);
+ $messageWithPhotos = $message . $photoLinks;
+
+ try {
+ $result = $this->wallPost($numericGroupId, $messageWithPhotos, []);
+ $result['warning'] = 'Фото добавлены как ссылки. Community-токен не поддерживает загрузку фото - нужен пользовательский токен.';
+ return $result;
+ } catch (Exception $e) {
+ throw new RuntimeException('Ошибка постинга: ' . $e->getMessage() . '. Также нет прав на загрузку фото.');
+ }
+ }
+
+ // If all photos failed to upload for non-permission reasons, report the first error
+ if (!empty($photoUrls) && empty($attachments) && !empty($uploadErrors)) {
+ throw new RuntimeException('Ошибка загрузки фото: ' . $uploadErrors[0]);
+ }
+
+ return $this->wallPost($numericGroupId, $message, $attachments);
+ }
+
+ /**
+ * Post to multiple groups at once
+ *
+ * @param array $groupIds Array of group IDs
+ * @param array $photoUrls Array of photo URLs
+ * @param string $message Post text
+ * @return array Results for each group
+ */
+ public function postToMultiple($groupIds, $photoUrls = [], $message = '')
+ {
+ $results = [];
+
+ foreach ($groupIds as $groupId) {
+ try {
+ $results[$groupId] = [
+ 'success' => true,
+ 'result' => $this->post($groupId, $photoUrls, $message),
+ ];
+ } catch (Exception $e) {
+ $results[$groupId] = [
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ ];
+ }
+
+ // VK rate limit: max 3 requests per second
+ usleep(350000);
+ }
+
+ return $results;
+ }
+
+ /**
+ * Validate access token (supports both user and community tokens)
+ *
+ * @return array Validation result
+ */
+ public function validateToken()
+ {
+ // First try user token validation
+ try {
+ $user = $this->getMe();
+ if (!empty($user)) {
+ return [
+ 'valid' => true,
+ 'type' => 'user',
+ 'user_id' => $user['id'],
+ 'user_name' => trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')),
+ 'screen_name' => $user['screen_name'] ?? '',
+ ];
+ }
+ } catch (Exception $e) {
+ // User token failed, try community token
+ }
+
+ // Try community token validation using groups.getById with group_id from token
+ try {
+ // For community tokens, we can get group info using groups.getById
+ // The token should have access to its own group
+ $result = $this->request('groups.getById', [
+ 'fields' => 'name,screen_name,photo_100'
+ ]);
+
+ if (!empty($result['groups'][0])) {
+ $group = $result['groups'][0];
+ return [
+ 'valid' => true,
+ 'type' => 'community',
+ 'user_id' => '-' . $group['id'],
+ 'user_name' => $group['name'] ?? 'Сообщество',
+ 'screen_name' => $group['screen_name'] ?? '',
+ ];
+ }
+ // VK API v5.199+ returns in different format
+ if (!empty($result[0])) {
+ $group = $result[0];
+ return [
+ 'valid' => true,
+ 'type' => 'community',
+ 'user_id' => '-' . $group['id'],
+ 'user_name' => $group['name'] ?? 'Сообщество',
+ 'screen_name' => $group['screen_name'] ?? '',
+ ];
+ }
+ } catch (Exception $e) {
+ return [
+ 'valid' => false,
+ 'error' => $e->getMessage(),
+ ];
+ }
+
+ return ['valid' => false, 'error' => 'Invalid token'];
+ }
+}
diff --git a/config.example.php b/config.example.php
new file mode 100644
index 0000000..cb0a15a
--- /dev/null
+++ b/config.example.php
@@ -0,0 +1,32 @@
+ [
+ 'api_key' => '733ecb91a48dce7be84410d49d96b57e',
+ 'api_secret' => 'c921aaf8b60c5603',
+ ],
+
+ // Your Flickr user ID (e.g., '12345678@N00')
+ // Find it at: https://www.flickr.com/services/api/explore/flickr.people.findByUsername
+ 'flickr_user_id' => '90307077@N07',
+
+ // Default image size for output
+ // Options: 'Square', 'Thumbnail', 'Small', 'Medium', 'Large', 'Original'
+ 'default_size' => 'Large',
+
+ // Available output formats
+ 'formats' => [
+ 'bbcode' => '[img]{url}[/img]',
+ 'bbcode_linked' => '[url={original}][img]{url}[/img][/url]',
+ 'html' => '
',
+ 'html_linked' => '
',
+ 'markdown' => '',
+ 'markdown_linked' => '[]({original})',
+ 'url' => '{url}',
+ ],
+];
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..cb0a15a
--- /dev/null
+++ b/config.php
@@ -0,0 +1,32 @@
+ [
+ 'api_key' => '733ecb91a48dce7be84410d49d96b57e',
+ 'api_secret' => 'c921aaf8b60c5603',
+ ],
+
+ // Your Flickr user ID (e.g., '12345678@N00')
+ // Find it at: https://www.flickr.com/services/api/explore/flickr.people.findByUsername
+ 'flickr_user_id' => '90307077@N07',
+
+ // Default image size for output
+ // Options: 'Square', 'Thumbnail', 'Small', 'Medium', 'Large', 'Original'
+ 'default_size' => 'Large',
+
+ // Available output formats
+ 'formats' => [
+ 'bbcode' => '[img]{url}[/img]',
+ 'bbcode_linked' => '[url={original}][img]{url}[/img][/url]',
+ 'html' => '
',
+ 'html_linked' => '
',
+ 'markdown' => '',
+ 'markdown_linked' => '[]({original})',
+ 'url' => '{url}',
+ ],
+];
diff --git a/cron_publish.php b/cron_publish.php
new file mode 100644
index 0000000..bf81531
--- /dev/null
+++ b/cron_publish.php
@@ -0,0 +1,229 @@
+ $p['status'] === 'pending'));
+logMessage("Found $pendingCount pending posts");
+
+foreach ($posts as &$post) {
+ // Skip if not pending
+ if ($post['status'] !== 'pending') {
+ continue;
+ }
+
+ $scheduledTime = strtotime($post['scheduled_time']);
+ logMessage("Post {$post['id']}: scheduled for " . date('Y-m-d H:i:s', $scheduledTime) . " ($scheduledTime)");
+
+ if ($scheduledTime > $now) {
+ $diff = $scheduledTime - $now;
+ logMessage(" -> Not yet time (in $diff seconds)");
+ continue;
+ }
+
+ logMessage(" -> Time to publish!");
+
+ // Prepare text with tags
+ $baseText = $post['text'] ?? '';
+ $tags = $post['tags'] ?? [];
+ if (!empty($tags)) {
+ $tagsString = implode(' ', array_map(function($t) { return '#' . $t; }, $tags));
+ $baseText = $baseText ? $baseText . "\n\n" . $tagsString : $tagsString;
+ }
+
+ // Collect all photo URLs
+ $photoUrls = $post['photos'] ?? [];
+ $uploadedFiles = $post['uploaded_files'] ?? [];
+ foreach ($uploadedFiles as $file) {
+ if (!empty($file['url'])) {
+ $photoUrls[] = $file['url'];
+ }
+ }
+
+ logMessage(" Photos: " . count($photoUrls));
+
+ // Check if cross-promo was enabled for this post
+ $crossPromoEnabled = $post['cross_promo'] ?? false;
+ logMessage(" Cross-promo enabled: " . ($crossPromoEnabled ? 'yes' : 'no'));
+
+ // Load cross-promo settings if enabled
+ $crossPromoFile = __DIR__ . '/data/cross_promo.json';
+ $crossPromo = [];
+ if ($crossPromoEnabled && file_exists($crossPromoFile)) {
+ $crossPromo = json_decode(file_get_contents($crossPromoFile), true) ?: [];
+ }
+ $hasCrossPromo = $crossPromoEnabled && (!empty($crossPromo['telegramLink']) || !empty($crossPromo['vkLink']));
+
+ // Check which platforms we're posting to
+ $platforms = $post['platforms'] ?? [];
+ $postingToTelegram = false;
+ $postingToVk = false;
+ foreach ($platforms as $p) {
+ $pType = $p['type'] ?? $p;
+ if ($pType === 'telegram') $postingToTelegram = true;
+ if ($pType === 'vk') $postingToVk = true;
+ }
+
+ // Prepare platform-specific texts with cross-promo
+ $textForTelegram = $baseText;
+ $textForVk = $baseText;
+
+ if ($hasCrossPromo) {
+ // Add VK link to Telegram posts
+ if (!empty($crossPromo['vkLink']) && $postingToTelegram) {
+ $linkText = $crossPromo['textForTg'] ?? 'Мой канал ВКонтакте';
+ $textForTelegram .= "\n\n{$linkText}";
+ logMessage(" Cross-promo: VK link added to TG text");
+ }
+ // Add Telegram link to VK posts
+ if (!empty($crossPromo['telegramLink']) && $postingToVk) {
+ $linkText = $crossPromo['textForVk'] ?? 'Мой канал в Telegram';
+ $textForVk .= "\n\n{$linkText}: {$crossPromo['telegramLink']}";
+ logMessage(" Cross-promo: TG link added to VK text");
+ }
+ }
+
+ $results = [];
+
+ logMessage(" Platforms: " . json_encode($platforms));
+ logMessage(" TG token set: " . (!empty($config['telegram']['bot_token']) ? 'yes' : 'NO'));
+ logMessage(" VK token set: " . (!empty($config['vk']['access_token']) ? 'yes' : 'NO'));
+
+ if (empty($platforms)) {
+ logMessage(" WARNING: No platforms specified!");
+ }
+
+ foreach ($platforms as $platform) {
+ $type = $platform['type'] ?? $platform;
+ $target = $platform['target'] ?? '';
+
+ logMessage(" Processing platform: $type, target: $target");
+
+ if ($type === 'telegram') {
+ if (empty($config['telegram']['bot_token'])) {
+ logMessage(" Telegram: SKIPPED - no bot token in config");
+ continue;
+ }
+ try {
+ $telegram = new TelegramBot($config['telegram']['bot_token']);
+ // Get first channel if no target specified
+ if (empty($target)) {
+ $channels = $telegram->getChannels();
+ logMessage(" Telegram channels: " . count($channels));
+ if (!empty($channels)) {
+ $target = $channels[0]['id'];
+ }
+ }
+ if ($target) {
+ logMessage(" Posting to Telegram channel: $target");
+ $result = $telegram->post($target, $photoUrls, $textForTelegram, 'HTML');
+ $results['telegram'] = ['success' => true, 'result' => $result];
+ logMessage(" Telegram: OK");
+ } else {
+ $results['telegram'] = ['success' => false, 'error' => 'No target channel'];
+ logMessage(" Telegram: No target channel");
+ }
+ } catch (Exception $e) {
+ $results['telegram'] = ['success' => false, 'error' => $e->getMessage()];
+ logMessage(" Telegram: ERROR - {$e->getMessage()}");
+ }
+ }
+
+ if ($type === 'vk') {
+ if (empty($config['vk']['access_token'])) {
+ logMessage(" VK: SKIPPED - no access token in config");
+ continue;
+ }
+ try {
+ $vk = new VKAPI($config['vk']['access_token']);
+ // Get first group if no target specified
+ if (empty($target)) {
+ $validation = $vk->validateToken();
+ if ($validation['valid'] && ($validation['type'] ?? '') === 'community') {
+ $target = $validation['user_id'];
+ } else {
+ $groups = $vk->getGroups();
+ if (!empty($groups)) {
+ $target = $groups[0]['id'];
+ }
+ }
+ }
+ if ($target) {
+ logMessage(" Posting to VK group: $target");
+ $result = $vk->post($target, $photoUrls, strip_tags($textForVk));
+ $results['vk'] = ['success' => true, 'result' => $result];
+ logMessage(" VK: OK");
+ } else {
+ $results['vk'] = ['success' => false, 'error' => 'No target group'];
+ logMessage(" VK: No target group");
+ }
+ } catch (Exception $e) {
+ $results['vk'] = ['success' => false, 'error' => $e->getMessage()];
+ logMessage(" VK: ERROR - {$e->getMessage()}");
+ }
+ }
+ }
+
+ // Update post status
+ $post['status'] = 'published';
+ $post['published_at'] = date('Y-m-d H:i:s');
+ $post['results'] = $results;
+ $updated = true;
+ logMessage("Post {$post['id']} marked as published");
+}
+
+// Save updated posts
+if ($updated) {
+ file_put_contents($scheduledFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+ logMessage("Posts file updated");
+}
+
+logMessage("Done\n---");
diff --git a/css/style.css b/css/style.css
new file mode 100644
index 0000000..c6c3b06
--- /dev/null
+++ b/css/style.css
@@ -0,0 +1,4008 @@
+/* VH Posting System - Apple Liquid Glass Design */
+/* Русский интерфейс | Light & Dark Theme */
+
+/* ============ CSS Variables - Light Theme ============ */
+:root {
+ /* Core colors */
+ --accent-color: #007AFF;
+ --accent-hover: #0056CC;
+ --accent-light: rgba(0, 122, 255, 0.15);
+ --success-color: #34C759;
+ --error-color: #FF3B30;
+ --warning-color: #FF9500;
+
+ /* Backgrounds */
+ --bg-primary: #F2F2F7;
+ --bg-secondary: #FFFFFF;
+ --bg-tertiary: #E5E5EA;
+
+ /* Glass effect */
+ --glass-bg: rgba(255, 255, 255, 0.72);
+ --glass-bg-solid: rgba(255, 255, 255, 0.92);
+ --glass-border: rgba(255, 255, 255, 0.5);
+ --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
+ --glass-shadow-hover: 0 12px 48px rgba(0, 0, 0, 0.12);
+ --glass-blur: blur(20px);
+
+ /* Text */
+ --text-primary: #1C1C1E;
+ --text-secondary: #8E8E93;
+ --text-tertiary: #AEAEB2;
+ --text-inverse: #FFFFFF;
+
+ /* Borders */
+ --border-color: rgba(0, 0, 0, 0.08);
+ --border-light: rgba(0, 0, 0, 0.04);
+ --divider: rgba(60, 60, 67, 0.12);
+
+ /* Gradients */
+ --gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ --gradient-accent: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
+ --gradient-bg: linear-gradient(180deg, #F2F2F7 0%, #E5E5EA 100%);
+
+ /* Radius */
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --radius-xl: 24px;
+
+ /* Transitions */
+ --transition-fast: 0.15s ease;
+ --transition-normal: 0.25s ease;
+ --transition-slow: 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+}
+
+/* ============ Dark Theme ============ */
+[data-theme="dark"] {
+ --bg-primary: #000000;
+ --bg-secondary: #1C1C1E;
+ --bg-tertiary: #2C2C2E;
+
+ --glass-bg: rgba(44, 44, 46, 0.72);
+ --glass-bg-solid: rgba(44, 44, 46, 0.92);
+ --glass-border: rgba(255, 255, 255, 0.1);
+ --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+ --glass-shadow-hover: 0 12px 48px rgba(0, 0, 0, 0.4);
+
+ --text-primary: #FFFFFF;
+ --text-secondary: #8E8E93;
+ --text-tertiary: #636366;
+
+ --border-color: rgba(255, 255, 255, 0.1);
+ --border-light: rgba(255, 255, 255, 0.05);
+ --divider: rgba(84, 84, 88, 0.65);
+
+ --gradient-bg: linear-gradient(180deg, #1C1C1E 0%, #000000 100%);
+}
+
+/* ============ Base Styles ============ */
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
+ background: var(--gradient-bg);
+ color: var(--text-primary);
+ line-height: 1.5;
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ transition: background var(--transition-normal), color var(--transition-normal);
+}
+
+/* ============ Glass Card Component ============ */
+.glass-card {
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--glass-shadow);
+ transition: all var(--transition-normal);
+}
+
+.glass-card:hover {
+ box-shadow: var(--glass-shadow-hover);
+}
+
+.glass-card-solid {
+ background: var(--glass-bg-solid);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--glass-shadow);
+}
+
+/* ============ App Container ============ */
+.app-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+/* ============ Header ============ */
+.app-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 24px;
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--glass-shadow);
+ margin-bottom: 20px;
+ transition: all var(--transition-normal);
+}
+
+.app-header h1 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ background: var(--gradient-accent);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.user-menu {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.username {
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+}
+
+/* Theme Toggle */
+.theme-toggle {
+ width: 44px;
+ height: 24px;
+ background: var(--bg-tertiary);
+ border: none;
+ border-radius: 12px;
+ cursor: pointer;
+ position: relative;
+ transition: all var(--transition-normal);
+}
+
+.theme-toggle::before {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 20px;
+ height: 20px;
+ background: var(--bg-secondary);
+ border-radius: 50%;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ transition: all var(--transition-normal);
+}
+
+.theme-toggle.dark::before {
+ transform: translateX(20px);
+}
+
+.theme-toggle.dark {
+ background: var(--accent-color);
+}
+
+/* ============ Navigation ============ */
+.main-nav {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 20px;
+ padding: 8px;
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--glass-shadow);
+ flex-wrap: wrap;
+}
+
+.nav-btn {
+ flex: 1;
+ min-width: 120px;
+ padding: 12px 20px;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-size: 0.95rem;
+ font-weight: 500;
+ transition: all var(--transition-fast);
+}
+
+.nav-btn:hover {
+ background: var(--accent-light);
+ color: var(--accent-color);
+}
+
+.nav-btn.active {
+ background: var(--accent-color);
+ color: var(--text-inverse);
+ box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
+}
+
+/* ============ Panels ============ */
+.panel {
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-xl);
+ padding: 28px;
+ box-shadow: var(--glass-shadow);
+ transition: all var(--transition-normal);
+}
+
+.panel h2 {
+ margin: 0 0 8px 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.panel h3 {
+ margin: 24px 0 16px 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+/* ============ Tabs ============ */
+.tab-content {
+ display: none;
+ animation: fadeIn var(--transition-slow);
+}
+
+.tab-content.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ============ Forms ============ */
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: 0.95rem;
+}
+
+.form-row {
+ display: flex;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+.form-row .form-group {
+ flex: 1;
+ min-width: 200px;
+}
+
+input[type="text"],
+input[type="password"],
+input[type="email"],
+select,
+textarea {
+ width: 100%;
+ padding: 14px 18px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ font-size: 1rem;
+ font-family: inherit;
+ color: var(--text-primary);
+ transition: all var(--transition-fast);
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 4px var(--accent-light);
+}
+
+textarea {
+ resize: vertical;
+ min-height: 120px;
+ font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+}
+
+select {
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238E8E93' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 16px center;
+ padding-right: 40px;
+}
+
+.hint {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--text-tertiary);
+ margin-top: 6px;
+}
+
+.help-text {
+ color: var(--text-secondary);
+ margin-bottom: 20px;
+ font-size: 0.95rem;
+}
+
+/* ============ Buttons ============ */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 12px 24px;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-decoration: none;
+}
+
+.btn-primary {
+ background: var(--accent-color);
+ color: var(--text-inverse);
+ box-shadow: 0 4px 12px rgba(0, 122, 255, 0.25);
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+ box-shadow: 0 6px 16px rgba(0, 122, 255, 0.35);
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.btn-secondary:hover {
+ background: var(--border-color);
+}
+
+.btn-accent {
+ background: linear-gradient(135deg, #FF9500 0%, #FF3B30 100%);
+ color: var(--text-inverse);
+ box-shadow: 0 4px 12px rgba(255, 149, 0, 0.25);
+}
+
+.btn-accent:hover {
+ box-shadow: 0 6px 16px rgba(255, 149, 0, 0.35);
+ transform: translateY(-1px);
+}
+
+.btn-success {
+ background: var(--success-color);
+ color: var(--text-inverse);
+}
+
+.btn-small {
+ padding: 8px 16px;
+ font-size: 0.875rem;
+}
+
+.btn-large {
+ padding: 16px 32px;
+ font-size: 1.1rem;
+ border-radius: var(--radius-lg);
+}
+
+.btn-block {
+ width: 100%;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none !important;
+}
+
+.btn:active:not(:disabled) {
+ transform: scale(0.98);
+}
+
+/* ============ Alerts ============ */
+.alert {
+ padding: 16px 20px;
+ border-radius: var(--radius-md);
+ margin-bottom: 20px;
+ font-size: 0.95rem;
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+}
+
+.alert-error {
+ background: rgba(255, 59, 48, 0.15);
+ color: var(--error-color);
+ border: 1px solid rgba(255, 59, 48, 0.3);
+}
+
+.alert-success {
+ background: rgba(52, 199, 89, 0.15);
+ color: var(--success-color);
+ border: 1px solid rgba(52, 199, 89, 0.3);
+}
+
+.alert-warning {
+ background: rgba(255, 149, 0, 0.15);
+ color: var(--warning-color);
+ border: 1px solid rgba(255, 149, 0, 0.3);
+}
+
+/* ============ Status Badges ============ */
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+.status::before {
+ content: '';
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+
+.status.connected {
+ background: rgba(52, 199, 89, 0.15);
+ color: var(--success-color);
+}
+
+.status.connected::before {
+ background: var(--success-color);
+ box-shadow: 0 0 8px var(--success-color);
+}
+
+.status.disconnected {
+ background: rgba(255, 59, 48, 0.15);
+ color: var(--error-color);
+}
+
+.status.disconnected::before {
+ background: var(--error-color);
+}
+
+/* ============ Login Page ============ */
+.login-page {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ background: var(--gradient-primary);
+ padding: 20px;
+}
+
+.login-container {
+ width: 100%;
+ max-width: 420px;
+}
+
+.login-box {
+ background: var(--glass-bg-solid);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-xl);
+ padding: 48px 40px;
+ box-shadow: var(--glass-shadow-hover);
+}
+
+.login-logo {
+ text-align: center;
+ margin-bottom: 24px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.login-logo-img {
+ width: 100px !important;
+ height: 100px !important;
+ max-width: 100px !important;
+ max-height: 100px !important;
+ object-fit: contain;
+ border-radius: var(--radius-lg);
+ display: block;
+}
+
+.login-box h1 {
+ font-size: 1.75rem;
+ font-weight: 700;
+ text-align: center;
+ margin: 0 0 8px 0;
+ background: var(--gradient-accent);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.login-box h2 {
+ color: var(--text-secondary);
+ text-align: center;
+ font-weight: 400;
+ font-size: 1.1rem;
+ margin: 0 0 32px 0;
+}
+
+/* Login page mobile */
+@media (max-width: 480px) {
+ .login-page {
+ padding: 16px;
+ }
+
+ .login-box {
+ padding: 24px 20px;
+ }
+
+ .login-logo-img {
+ width: 80px !important;
+ height: 80px !important;
+ max-width: 80px !important;
+ max-height: 80px !important;
+ }
+
+ .login-box h1 {
+ font-size: 1.4rem;
+ }
+
+ .login-box h2 {
+ font-size: 1rem;
+ margin-bottom: 24px;
+ }
+}
+
+/* ============ Photo Gallery ============ */
+.gallery-controls {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+ align-items: flex-end;
+}
+
+.gallery-controls .form-group {
+ margin-bottom: 0;
+ flex: 1;
+ min-width: 150px;
+}
+
+.selection-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 20px;
+ background: var(--accent-light);
+ border: 1px solid rgba(0, 122, 255, 0.2);
+ border-radius: var(--radius-lg);
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+#selected-count {
+ font-weight: 600;
+ color: var(--accent-color);
+}
+
+.photo-gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.photo-item {
+ position: relative;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ cursor: pointer;
+ aspect-ratio: 1;
+ background: var(--bg-tertiary);
+ transition: all var(--transition-fast);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.photo-item:hover {
+ transform: scale(1.03);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+}
+
+.photo-item img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.photo-item .checkbox {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 26px;
+ height: 26px;
+ background: var(--glass-bg-solid);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 2px solid var(--glass-border);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--transition-fast);
+}
+
+.photo-item.selected .checkbox {
+ background: var(--accent-color);
+ border-color: var(--accent-color);
+ color: var(--text-inverse);
+ box-shadow: 0 2px 8px rgba(0, 122, 255, 0.4);
+}
+
+.photo-item.selected .checkbox::after {
+ content: '✓';
+ font-size: 14px;
+ font-weight: bold;
+}
+
+.photo-item .title {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 10px 12px;
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
+ color: white;
+ font-size: 0.75rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.photo-preview-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 32px;
+ height: 32px;
+ background: var(--glass-bg-solid);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 2px solid var(--glass-border);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 14px;
+ opacity: 0;
+ transition: all var(--transition-fast);
+ z-index: 5;
+}
+
+.photo-item:hover .photo-preview-btn {
+ opacity: 1;
+}
+
+.photo-preview-btn:hover {
+ background: var(--accent-color);
+ border-color: var(--accent-color);
+ transform: scale(1.1);
+}
+
+/* Video Badge */
+.photo-item.is-video {
+ position: relative;
+}
+
+.video-badge {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 48px;
+ height: 48px;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 3;
+ pointer-events: none;
+ transition: all 0.2s ease;
+}
+
+.photo-item:hover .video-badge {
+ background: rgba(234, 67, 53, 0.9);
+ transform: translate(-50%, -50%) scale(1.1);
+}
+
+.video-icon {
+ color: white;
+ font-size: 20px;
+ margin-left: 4px;
+}
+
+.photo-item.is-video .photo-preview-btn {
+ background: rgba(234, 67, 53, 0.9);
+ border-color: rgba(234, 67, 53, 0.9);
+}
+
+/* Pagination */
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+}
+
+#page-info {
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+/* Photos Preview */
+.photos-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 20px;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-md);
+ min-height: 100px;
+}
+
+.photos-preview .placeholder {
+ color: var(--text-tertiary);
+ width: 100%;
+ text-align: center;
+ padding: 24px;
+}
+
+.preview-thumb {
+ width: 72px;
+ height: 72px;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ position: relative;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.preview-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.preview-thumb .remove-btn {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 22px;
+ height: 22px;
+ background: var(--error-color);
+ color: white;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity var(--transition-fast);
+}
+
+.preview-thumb:hover .remove-btn {
+ opacity: 1;
+}
+
+/* Result Message */
+.result-message {
+ padding: 16px 20px;
+ border-radius: var(--radius-md);
+ margin-top: 20px;
+ display: none;
+ animation: fadeIn var(--transition-normal);
+}
+
+.result-message.success {
+ display: block;
+ background: rgba(52, 199, 89, 0.15);
+ color: var(--success-color);
+ border: 1px solid rgba(52, 199, 89, 0.3);
+}
+
+.result-message.error {
+ display: block;
+ background: rgba(255, 59, 48, 0.15);
+ color: var(--error-color);
+ border: 1px solid rgba(255, 59, 48, 0.3);
+}
+
+/* ============ Settings ============ */
+.settings-section {
+ border-bottom: 1px solid var(--divider);
+ padding-bottom: 28px;
+ margin-bottom: 28px;
+}
+
+.settings-section:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+
+/* ============ Loading State ============ */
+.loading {
+ text-align: center;
+ padding: 48px;
+ color: var(--text-secondary);
+}
+
+.loading::after {
+ content: '';
+ display: inline-block;
+ width: 24px;
+ height: 24px;
+ border: 3px solid var(--border-color);
+ border-top-color: var(--accent-color);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ margin-left: 12px;
+ vertical-align: middle;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Placeholder */
+.placeholder {
+ color: var(--text-tertiary);
+ text-align: center;
+ padding: 24px;
+}
+
+/* ============ Platform Cards ============ */
+.platforms-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 16px;
+ margin-top: 12px;
+}
+
+.platform-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ transition: all var(--transition-fast);
+}
+
+.platform-card:hover {
+ border-color: var(--accent-color);
+ box-shadow: 0 4px 12px rgba(0, 122, 255, 0.1);
+}
+
+.platform-card.platform-disabled {
+ opacity: 0.6;
+}
+
+.platform-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.platform-checkbox {
+ position: relative;
+ display: flex;
+ cursor: pointer;
+}
+
+.platform-checkbox input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.platform-checkbox .checkmark {
+ width: 24px;
+ height: 24px;
+ background: var(--bg-tertiary);
+ border: 2px solid var(--border-color);
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--transition-fast);
+}
+
+.platform-checkbox input:checked + .checkmark {
+ background: var(--accent-color);
+ border-color: var(--accent-color);
+}
+
+.platform-checkbox input:checked + .checkmark::after {
+ content: '✓';
+ color: white;
+ font-size: 14px;
+ font-weight: bold;
+}
+
+.platform-info {
+ flex: 1;
+}
+
+.platform-name {
+ display: block;
+ font-weight: 600;
+ color: var(--text-primary);
+ font-size: 1rem;
+}
+
+.status-mini {
+ display: block;
+ font-size: 0.8rem;
+ color: var(--text-tertiary);
+ margin-top: 2px;
+}
+
+.status-mini.connected {
+ color: var(--success-color);
+}
+
+.platform-target {
+ width: 100%;
+ padding: 10px 14px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ font-size: 0.9rem;
+ color: var(--text-primary);
+}
+
+.platform-note {
+ font-size: 0.8rem;
+ color: var(--text-tertiary);
+ margin: 0;
+ padding: 8px 0 0 0;
+}
+
+/* ============ Gallery Header & Toolbar ============ */
+.gallery-header {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.gallery-header h2 {
+ margin: 0;
+}
+
+.gallery-toolbar {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.album-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 200px;
+}
+
+.toolbar-select {
+ flex: 1;
+ min-width: 150px;
+ max-width: 280px;
+}
+
+.btn-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ color: var(--text-primary);
+ font-size: 16px;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ flex-shrink: 0;
+}
+
+.btn-icon:hover {
+ background: var(--accent-light);
+ color: var(--accent-color);
+ transform: scale(1.05);
+}
+
+.btn-icon:active {
+ transform: scale(0.95);
+}
+
+.btn-icon.active {
+ background: var(--accent-color);
+ color: white;
+}
+
+.toolbar-search {
+ flex: 1;
+ min-width: 150px;
+ max-width: 250px;
+}
+
+.toolbar-search input {
+ width: 100%;
+ margin: 0;
+}
+
+/* ============ Gallery Views ============ */
+.gallery-view {
+ animation: fadeIn 0.3s ease;
+}
+
+.gallery-view.hidden {
+ display: none;
+}
+
+/* ============ Albums Grid ============ */
+.albums-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 16px;
+ padding: 8px 0;
+}
+
+@media (min-width: 768px) {
+ .albums-grid {
+ grid-template-columns: repeat(5, 1fr);
+ }
+}
+
+@media (min-width: 1024px) {
+ .albums-grid {
+ grid-template-columns: repeat(6, 1fr);
+ }
+}
+
+@media (min-width: 1400px) {
+ .albums-grid {
+ grid-template-columns: repeat(7, 1fr);
+ }
+}
+
+.album-card {
+ position: relative;
+ aspect-ratio: 1;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ cursor: pointer;
+ background: var(--bg-tertiary);
+ box-shadow: var(--glass-shadow);
+ transition: all var(--transition-normal);
+ user-select: none;
+}
+
+.album-card:hover {
+ transform: translateY(-4px) scale(1.02);
+ box-shadow: var(--glass-shadow-hover);
+}
+
+.album-card.dragging {
+ opacity: 0.6;
+ transform: scale(1.05) rotate(2deg);
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ z-index: 100;
+}
+
+.album-card.drag-over {
+ transform: scale(0.95);
+ border: 3px solid var(--accent-color);
+}
+
+.album-card-cover {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform var(--transition-normal);
+}
+
+.album-card:hover .album-card-cover {
+ transform: scale(1.1);
+}
+
+.album-card-overlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 12px;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
+ color: white;
+}
+
+.album-card-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-bottom: 2px;
+}
+
+.album-card-count {
+ font-size: 0.7rem;
+ opacity: 0.8;
+}
+
+.album-card-drag-handle {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 26px;
+ height: 26px;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(10px);
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 12px;
+ opacity: 0;
+ transition: opacity var(--transition-fast);
+ cursor: grab;
+}
+
+.album-card:hover .album-card-drag-handle {
+ opacity: 1;
+}
+
+.album-card-drag-handle:active {
+ cursor: grabbing;
+}
+
+/* Placeholder for empty cover */
+.album-card-placeholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-tertiary);
+ color: var(--text-tertiary);
+ font-size: 2rem;
+}
+
+/* Drag hint */
+.drag-hint {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ text-align: center;
+ margin: 8px 0 16px;
+ opacity: 0.7;
+}
+
+.drag-hint.hidden {
+ display: none;
+}
+
+/* ============ Breadcrumb Navigation ============ */
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.breadcrumb-separator {
+ color: var(--text-tertiary);
+}
+
+.breadcrumb-current {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.btn-text {
+ background: none;
+ border: none;
+ color: var(--accent-color);
+ font-size: 0.95rem;
+ cursor: pointer;
+ padding: 8px 12px;
+ border-radius: var(--radius-sm);
+ transition: all var(--transition-fast);
+}
+
+.btn-text:hover {
+ background: var(--accent-light);
+}
+
+.btn-icon-text {
+ margin-right: 6px;
+}
+
+.photos-count {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+/* ============ Loading Spinner ============ */
+.loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ gap: 16px;
+ color: var(--text-secondary);
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid var(--border-color);
+ border-top-color: var(--accent-color);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(-10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ============ Floating Action Bar ============ */
+.floating-action-bar {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 12px 20px;
+ background: var(--glass-bg-solid);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: 20px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
+ z-index: 1000;
+ animation: slideUp 0.3s ease;
+}
+
+.floating-action-bar.hidden {
+ display: none;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+}
+
+.action-bar-left {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ padding-right: 16px;
+ border-right: 1px solid var(--divider);
+}
+
+.selection-count {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--accent-color);
+}
+
+.selection-label {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+
+.action-bar-center,
+.action-bar-right {
+ display: flex;
+ gap: 8px;
+}
+
+.action-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 16px;
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ color: var(--text-primary);
+}
+
+.action-btn:hover {
+ background: var(--accent-light);
+}
+
+.action-btn:active {
+ transform: scale(0.95);
+}
+
+.action-icon {
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.action-text {
+ font-size: 0.7rem;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.action-btn.action-primary {
+ background: var(--accent-color);
+ color: white;
+ box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
+}
+
+.action-btn.action-primary:hover {
+ background: var(--accent-hover);
+}
+
+.action-btn.action-secondary {
+ background: var(--bg-tertiary);
+}
+
+.action-btn.action-secondary:hover {
+ background: var(--border-color);
+}
+
+/* Mobile floating bar */
+@media (max-width: 768px) {
+ .floating-action-bar {
+ left: 12px;
+ right: 12px;
+ transform: none;
+ bottom: 12px;
+ padding: 10px 16px;
+ gap: 8px;
+ }
+
+ .action-bar-left {
+ padding-right: 12px;
+ }
+
+ .selection-count {
+ font-size: 1.2rem;
+ }
+
+ .action-btn {
+ padding: 6px 10px;
+ }
+
+ .action-text {
+ display: none;
+ }
+
+ .action-icon {
+ font-size: 1.4rem;
+ }
+}
+
+/* ============ Scrollbar Styling ============ */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-tertiary);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--text-tertiary);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* ============ Selection ============ */
+::selection {
+ background: var(--accent-light);
+ color: var(--accent-color);
+}
+
+/* ============ Responsive ============ */
+@media (max-width: 768px) {
+ .app-container {
+ padding: 12px;
+ }
+
+ .app-header {
+ flex-direction: column;
+ text-align: center;
+ gap: 16px;
+ padding: 20px;
+ }
+
+ .main-nav {
+ padding: 6px;
+ }
+
+ .nav-btn {
+ min-width: auto;
+ padding: 10px 16px;
+ font-size: 0.9rem;
+ }
+
+ .panel {
+ padding: 20px;
+ }
+
+ .form-row {
+ flex-direction: column;
+ }
+
+ .form-row .form-group {
+ min-width: 100%;
+ }
+
+ .photo-gallery {
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 8px;
+ }
+
+ .selection-bar {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 10px;
+ }
+
+ .login-box {
+ padding: 32px 24px;
+ }
+}
+
+/* ============ Print Styles ============ */
+@media print {
+ .app-header,
+ .main-nav,
+ .btn,
+ .selection-bar,
+ .pagination {
+ display: none !important;
+ }
+
+ .panel {
+ box-shadow: none;
+ border: 1px solid #ccc;
+ }
+}
+
+/* ============ Photo Lightbox ============ */
+.lightbox {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.lightbox.hidden {
+ display: none;
+}
+
+.lightbox-backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.85);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+}
+
+.lightbox-content {
+ position: relative;
+ max-width: 90vw;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ z-index: 1;
+}
+
+.lightbox-close {
+ position: absolute;
+ top: -40px;
+ right: -40px;
+ width: 36px;
+ height: 36px;
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ border-radius: 50%;
+ color: white;
+ font-size: 24px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--transition-fast);
+ z-index: 10;
+}
+
+.lightbox-close:hover {
+ background: rgba(255, 255, 255, 0.3);
+ transform: scale(1.1);
+}
+
+.lightbox-image-container {
+ position: relative;
+ max-width: 85vw;
+ max-height: 70vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background: rgba(0, 0, 0, 0.5);
+}
+
+.lightbox-image {
+ max-width: 100%;
+ max-height: 70vh;
+ object-fit: contain;
+ transition: opacity 0.3s ease;
+}
+
+.lightbox-video-container {
+ width: 100%;
+ max-width: 85vw;
+ aspect-ratio: 16 / 9;
+ background: #000;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+.lightbox-video {
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
+/* Video Play Overlay in Lightbox */
+.video-play-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.5);
+ cursor: pointer;
+ transition: background 0.2s ease;
+ z-index: 10;
+}
+
+.video-play-overlay:hover {
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.video-play-overlay:hover .video-play-button {
+ transform: scale(1.1);
+ background: rgba(234, 67, 53, 1);
+}
+
+.video-play-button {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ background: rgba(234, 67, 53, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 32px;
+ color: white;
+ padding-left: 6px;
+ transition: all 0.2s ease;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+}
+
+.video-play-text {
+ margin-top: 16px;
+ color: white;
+ font-size: 14px;
+ font-weight: 500;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
+}
+
+.lightbox-loading {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.lightbox-loading.hidden {
+ display: none;
+}
+
+.lightbox-info {
+ margin-top: 16px;
+ text-align: center;
+}
+
+.lightbox-title {
+ color: white;
+ font-size: 1rem;
+ font-weight: 500;
+ margin: 0;
+ max-width: 60vw;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.lightbox-actions {
+ margin-top: 20px;
+ display: flex;
+ gap: 12px;
+}
+
+.lightbox-actions .btn {
+ min-width: 130px;
+}
+
+.lightbox-actions .btn-success {
+ background: var(--success-color);
+}
+
+.lightbox-nav {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 50px;
+ height: 50px;
+ background: rgba(255, 255, 255, 0.15);
+ border: none;
+ border-radius: 50%;
+ color: white;
+ font-size: 28px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--transition-fast);
+}
+
+.lightbox-nav:hover {
+ background: rgba(255, 255, 255, 0.3);
+ transform: translateY(-50%) scale(1.1);
+}
+
+.lightbox-prev {
+ left: -80px;
+}
+
+.lightbox-next {
+ right: -80px;
+}
+
+/* Mobile lightbox adjustments */
+@media (max-width: 768px) {
+ .lightbox-close {
+ top: 10px;
+ right: 10px;
+ }
+
+ .lightbox-nav {
+ width: 40px;
+ height: 40px;
+ font-size: 22px;
+ }
+
+ .lightbox-prev {
+ left: 10px;
+ }
+
+ .lightbox-next {
+ right: 10px;
+ }
+
+ .lightbox-actions {
+ flex-direction: column;
+ width: 100%;
+ padding: 0 20px;
+ }
+
+ .lightbox-actions .btn {
+ width: 100%;
+ }
+
+ .lightbox-title {
+ max-width: 90vw;
+ font-size: 0.9rem;
+ }
+}
+
+/* ============ Download Choice Dialog ============ */
+.download-dialog-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ animation: fadeIn 0.2s ease;
+}
+
+.download-dialog {
+ background: var(--glass-bg-solid);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border-radius: var(--radius-xl);
+ padding: 32px;
+ min-width: 320px;
+ max-width: 90vw;
+ box-shadow: var(--glass-shadow-hover);
+ border: 1px solid var(--glass-border);
+ position: relative;
+ text-align: center;
+ animation: slideUp 0.3s ease;
+}
+
+.download-dialog h3 {
+ margin: 0 0 8px 0;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.download-dialog p {
+ margin: 0 0 24px 0;
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+}
+
+.download-dialog-buttons {
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+}
+
+.download-dialog-buttons .btn {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 16px 24px;
+ min-width: 130px;
+}
+
+.download-dialog-buttons .btn-icon {
+ font-size: 1.5rem;
+}
+
+.download-dialog-close {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: var(--bg-tertiary);
+ border-radius: 50%;
+ font-size: 1.25rem;
+ color: var(--text-secondary);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: var(--transition-fast);
+}
+
+.download-dialog-close:hover {
+ background: var(--error-color);
+ color: white;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* ============ OAuth Banner ============ */
+.oauth-banner {
+ background: linear-gradient(135deg, #FF9500 0%, #FF6B00 100%);
+ border-radius: var(--radius-md);
+ padding: 12px 16px;
+ margin-bottom: 16px;
+ animation: slideDown 0.3s ease;
+}
+
+.oauth-banner.hidden {
+ display: none;
+}
+
+.oauth-banner-content {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.oauth-banner-icon {
+ font-size: 1.25rem;
+}
+
+.oauth-banner-text {
+ flex: 1;
+ color: white;
+ font-size: 0.9rem;
+}
+
+.oauth-banner-text strong {
+ display: block;
+ font-size: 1rem;
+}
+
+.oauth-banner .btn {
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: white;
+ white-space: nowrap;
+}
+
+.oauth-banner .btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* ============ Text Editor Toolbar ============ */
+.text-editor {
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ background: var(--bg-secondary);
+}
+
+.editor-toolbar {
+ display: flex;
+ gap: 4px;
+ padding: 8px;
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.toolbar-btn {
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: transparent;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 14px;
+ color: var(--text-primary);
+ transition: var(--transition-fast);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.toolbar-btn:hover {
+ background: var(--accent-light);
+ color: var(--accent-color);
+}
+
+.toolbar-btn.active {
+ background: var(--accent-color);
+ color: white;
+}
+
+.toolbar-separator {
+ width: 1px;
+ background: var(--border-color);
+ margin: 0 4px;
+}
+
+.text-editor textarea {
+ border: none;
+ border-radius: 0;
+ resize: vertical;
+}
+
+.text-editor textarea:focus {
+ box-shadow: none;
+}
+
+/* ============ Tags System ============ */
+.tags-container {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 8px;
+ min-height: 44px;
+}
+
+.tags-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+
+.tags-list:empty {
+ display: none;
+}
+
+.tags-list + .tags-input-wrapper {
+ margin-top: 0;
+}
+
+.tag-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ background: var(--accent-light);
+ color: var(--accent-color);
+ border-radius: 20px;
+ font-size: 0.85rem;
+ animation: tagAppear 0.2s ease;
+}
+
+@keyframes tagAppear {
+ from { opacity: 0; transform: scale(0.8); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+.tag-chip .tag-remove {
+ width: 16px;
+ height: 16px;
+ border: none;
+ background: transparent;
+ color: var(--accent-color);
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ padding: 0;
+ opacity: 0.6;
+ transition: var(--transition-fast);
+ border-radius: 50%;
+}
+
+.tag-chip .tag-remove:hover {
+ opacity: 1;
+ background: var(--accent-color);
+ color: white;
+}
+
+.tags-input-wrapper {
+ position: relative;
+}
+
+.tags-input {
+ border: none !important;
+ background: transparent !important;
+ padding: 4px 8px !important;
+ font-size: 0.9rem;
+ width: 100%;
+ min-width: 120px;
+}
+
+.tags-input:focus {
+ box-shadow: none !important;
+ outline: none;
+}
+
+.tags-suggestions {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ box-shadow: var(--glass-shadow);
+ z-index: 100;
+ max-height: 200px;
+ overflow-y: auto;
+ display: none;
+}
+
+.tags-suggestions.visible {
+ display: block;
+}
+
+.tag-suggestion {
+ padding: 8px 12px;
+ cursor: pointer;
+ transition: var(--transition-fast);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.tag-suggestion:hover,
+.tag-suggestion.selected {
+ background: var(--accent-light);
+}
+
+.tag-suggestion .tag-count {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.tags-presets {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+ flex-wrap: wrap;
+}
+
+.tags-presets-label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.tag-preset {
+ padding: 4px 10px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 20px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: var(--transition-fast);
+ color: var(--text-primary);
+}
+
+.tag-preset:hover {
+ background: var(--accent-light);
+ border-color: var(--accent-color);
+ color: var(--accent-color);
+}
+
+.presets-list {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.preset-add-btn,
+.preset-manage-btn {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 1px dashed var(--border-color);
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 16px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.preset-add-btn:hover,
+.preset-manage-btn:hover {
+ border-color: var(--accent-color);
+ color: var(--accent-color);
+ background: var(--accent-light);
+}
+
+/* ============ Modal Styles ============ */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+}
+
+.modal-content {
+ background: var(--bg-primary);
+ border-radius: 12px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ max-height: 90vh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.modal-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ color: var(--text-primary);
+}
+
+.modal-close {
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: transparent;
+ font-size: 24px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.modal-close:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.modal-body {
+ padding: 20px;
+ overflow-y: auto;
+}
+
+/* Preset Modal */
+.preset-modal {
+ max-width: 500px;
+ width: 90%;
+}
+
+.preset-manager-list {
+ max-height: 300px;
+ overflow-y: auto;
+ margin-bottom: 16px;
+}
+
+.preset-manager-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ margin-bottom: 8px;
+ background: var(--bg-secondary);
+}
+
+.preset-manager-item:hover {
+ border-color: var(--accent-color);
+}
+
+.preset-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.preset-info .preset-name {
+ display: block;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+}
+
+.preset-info .preset-tags-preview {
+ display: block;
+ font-size: 12px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.preset-actions {
+ display: flex;
+ gap: 8px;
+ margin-left: 12px;
+}
+
+.btn-icon {
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ transition: background 0.2s ease;
+}
+
+.btn-icon:hover {
+ background: var(--bg-hover);
+}
+
+.btn-icon.preset-delete:hover {
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.preset-form-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.btn-block {
+ width: 100%;
+}
+
+/* ============ Infinite Scroll Loading ============ */
+.albums-loading-more,
+.photos-loading-more {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 24px;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ grid-column: 1 / -1;
+ width: 100%;
+}
+
+.albums-loading-more .loading-spinner,
+.photos-loading-more .loading-spinner {
+ width: 24px;
+ height: 24px;
+}
+
+#albums-scroll-sentinel,
+#photos-scroll-sentinel {
+ visibility: hidden;
+}
+
+/* ============ Converter Grid Layout ============ */
+.converter-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ margin-bottom: 24px;
+}
+
+.converter-input-section,
+.converter-text-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.converter-output-section {
+ border-top: 1px solid var(--border-color);
+ padding-top: 20px;
+}
+
+.output-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-top: 8px;
+}
+
+.copy-status {
+ font-size: 0.85rem;
+ color: var(--success-color);
+ opacity: 0;
+ transition: opacity 0.3s;
+}
+
+.copy-status.visible {
+ opacity: 1;
+}
+
+@media (max-width: 768px) {
+ .converter-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .tags-presets {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
+/* ============ Widget Settings ============ */
+.widget-albums-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 12px;
+ max-height: 400px;
+ overflow-y: auto;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--bg-secondary);
+}
+
+.widget-album-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 12px;
+ border: 2px solid transparent;
+ border-radius: var(--radius-md);
+ background: var(--glass-bg);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-align: center;
+}
+
+.widget-album-item:hover {
+ background: var(--bg-hover);
+}
+
+.widget-album-item.selected {
+ border-color: var(--primary);
+ background: rgba(var(--primary-rgb), 0.1);
+}
+
+.widget-album-item input[type="checkbox"] {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.widget-album-thumb {
+ width: 80px;
+ height: 80px;
+ object-fit: cover;
+ border-radius: var(--radius-sm);
+ margin-bottom: 8px;
+}
+
+.widget-album-title {
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--text-primary);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ margin-bottom: 4px;
+}
+
+.widget-album-count {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.code-block {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ margin-bottom: 12px;
+}
+
+.code-block p {
+ margin: 0 0 8px 0;
+ color: var(--text-secondary);
+}
+
+.code-block code {
+ display: block;
+ background: var(--bg-tertiary);
+ padding: 8px 12px;
+ border-radius: var(--radius-sm);
+ font-family: 'SF Mono', monospace;
+ font-size: 0.85rem;
+ word-break: break-all;
+ color: var(--text-primary);
+}
+
+.save-status {
+ margin-left: 12px;
+ font-size: 0.9rem;
+}
+
+.save-status.success {
+ color: var(--success);
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+
+.checkbox-label input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+}
+
+/* ============ Photo Source Buttons ============ */
+.photo-source-buttons {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.photo-source-buttons .btn {
+ flex: 1;
+ min-width: 140px;
+}
+
+/* Combined preview for all photos */
+.combined-preview {
+ min-height: 80px;
+ border: 2px dashed transparent;
+ transition: all 0.2s ease;
+}
+
+.combined-preview.drag-over {
+ border-color: var(--accent-color);
+ background: var(--bg-tertiary);
+}
+
+.combined-preview .preview-thumb .source-badge {
+ position: absolute;
+ bottom: 2px;
+ left: 2px;
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ font-size: 8px;
+ padding: 1px 4px;
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+
+.combined-preview .preview-thumb.uploading {
+ opacity: 0.6;
+}
+
+.combined-preview .preview-thumb .upload-spinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--bg-primary);
+ border-top-color: var(--accent-color);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: translate(-50%, -50%) rotate(360deg); }
+}
+
+/* ============ File Upload Area ============ */
+.upload-area {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ border: 2px dashed var(--divider);
+ border-radius: var(--radius-md);
+ background: var(--bg-secondary);
+ transition: all 0.2s ease;
+}
+
+.upload-area.drag-over {
+ border-color: var(--accent-color);
+ background: var(--bg-tertiary);
+}
+
+.uploaded-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.uploaded-item {
+ position: relative;
+ width: 80px;
+ height: 80px;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ background: var(--bg-tertiary);
+}
+
+.uploaded-item .preview-thumb {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.uploaded-item .remove-uploaded {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 20px;
+ height: 20px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ font-size: 14px;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.uploaded-item .video-badge {
+ position: absolute;
+ bottom: 4px;
+ left: 4px;
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 10px;
+}
+
+.uploaded-item .file-name {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ font-size: 9px;
+ padding: 2px 4px;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.uploaded-item.uploading {
+ opacity: 0.7;
+}
+
+.uploaded-item .upload-spinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 24px;
+ height: 24px;
+ border: 3px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: translate(-50%, -50%) rotate(360deg); }
+}
+
+/* ============ Post Options Grid ============ */
+.post-options-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.checkbox-label.compact {
+ font-size: 0.9rem;
+}
+
+.schedule-options {
+ background: var(--bg-tertiary);
+ padding: 16px;
+ border-radius: var(--radius-md);
+ margin-bottom: 16px;
+ border: 2px solid var(--accent-color);
+}
+
+.schedule-options.hidden {
+ display: none;
+}
+
+.schedule-label {
+ display: block;
+ font-weight: 600;
+ margin-bottom: 12px;
+ color: var(--text-primary);
+}
+
+.schedule-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.schedule-presets .preset-btn {
+ padding: 8px 14px;
+ border: 1px solid var(--divider);
+ border-radius: var(--radius-md);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.schedule-presets .preset-btn:hover {
+ background: var(--accent-light);
+ border-color: var(--accent-color);
+}
+
+.schedule-presets .preset-btn.active {
+ background: var(--accent-color);
+ color: white;
+ border-color: var(--accent-color);
+}
+
+.schedule-custom {
+ background: var(--bg-secondary);
+ padding: 12px;
+ border-radius: var(--radius-sm);
+}
+
+.schedule-date-row {
+ display: flex;
+ gap: 12px;
+}
+
+.schedule-field {
+ flex: 1;
+}
+
+.schedule-field label {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: 4px;
+}
+
+.schedule-input {
+ width: 100%;
+ padding: 10px 12px;
+ font-size: 16px;
+ border: 1px solid var(--divider);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.schedule-input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+}
+
+.datetime-input {
+ width: 100%;
+ padding: 12px;
+ font-size: 1rem;
+ border: 1px solid var(--divider);
+ border-radius: var(--radius-md);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.post-actions {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.post-actions .btn {
+ flex: 1;
+}
+
+/* ============ Scheduled Posts Section ============ */
+.scheduled-section {
+ margin-top: 32px;
+ padding-top: 24px;
+ border-top: 1px solid var(--divider);
+}
+
+.scheduled-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.scheduled-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+}
+
+.badge {
+ background: var(--accent-color);
+ color: white;
+ padding: 2px 10px;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.scheduled-posts-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.scheduled-post-card {
+ background: var(--bg-secondary);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ border: 1px solid var(--divider);
+}
+
+.scheduled-post-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.scheduled-time {
+ font-weight: 600;
+ color: var(--accent-color);
+ font-size: 0.9rem;
+}
+
+.scheduled-platforms {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ background: var(--bg-tertiary);
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-weight: 600;
+}
+
+/* Photo previews in scheduled posts */
+.scheduled-photos-preview {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.scheduled-thumb {
+ width: 60px;
+ height: 60px;
+ object-fit: cover;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--divider);
+}
+
+.more-photos {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 60px;
+ height: 60px;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-sm);
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.scheduled-post-content {
+ margin-bottom: 10px;
+}
+
+.scheduled-text {
+ margin: 0 0 8px 0;
+ color: var(--text-primary);
+}
+
+.scheduled-photos,
+.scheduled-tags {
+ display: inline-block;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-right: 12px;
+}
+
+.scheduled-post-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.scheduled-platforms label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+/* Inline editor for scheduled posts */
+.scheduled-post-card.editing {
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb, 0,122,255), 0.15);
+}
+
+.inline-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.inline-editor-photos {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ flex-direction: row;
+}
+
+.inline-editor-photos .preview-thumb {
+ width: 80px;
+ height: 80px;
+ position: relative;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.inline-editor-photos .preview-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.inline-editor-photos .remove-btn {
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: rgba(220,40,40,0.85);
+ color: white;
+ border: none;
+ cursor: pointer;
+ font-size: 12px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.inline-editor-text {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid var(--divider);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: inherit;
+ font-size: 0.95rem;
+ resize: vertical;
+ box-sizing: border-box;
+}
+
+.inline-editor-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.inline-tags-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.inline-tags-input {
+ flex: 1;
+ min-width: 100px;
+ padding: 4px 8px;
+ border: 1px solid var(--divider);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 0.85rem;
+}
+
+.inline-editor-row {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.inline-editor-field {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.inline-editor-field label {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.inline-editor-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.btn-accent {
+ background: var(--success, #34c759);
+ color: white;
+}
+
+.btn-accent:hover {
+ opacity: 0.9;
+}
+
+.btn-danger {
+ background: var(--error);
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #c0392b;
+}
+
+/* Photo counter on posting page */
+.photo-counter {
+ font-weight: 600;
+ font-size: 0.9em;
+ padding: 2px 8px;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+}
+
+.photo-counter.at-limit {
+ background: var(--error);
+ color: white;
+}
+
+/* ============ Published Posts Archive ============ */
+.archive-section {
+ margin-top: 24px;
+ padding-top: 20px;
+ border-top: 1px solid var(--divider);
+}
+
+.archive-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+
+.archive-header h3 {
+ margin: 0;
+ font-size: 1rem;
+ color: var(--text-secondary);
+}
+
+.published-posts-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.archive-post-card {
+ background: var(--bg-secondary);
+ border-radius: var(--radius-sm);
+ padding: 10px 12px;
+ border: 1px solid var(--divider);
+ opacity: 0.85;
+}
+
+.archive-post-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.archive-time {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.archive-results {
+ font-size: 0.75rem;
+ display: flex;
+ gap: 8px;
+}
+
+.archive-results .result-success {
+ color: var(--success-color);
+}
+
+.archive-results .result-error {
+ color: var(--error-color);
+}
+
+.archive-photos-preview {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 6px;
+ align-items: center;
+}
+
+.archive-thumb {
+ width: 40px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: 4px;
+ border: 1px solid var(--divider);
+}
+
+.archive-text {
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--text-primary);
+ line-height: 1.3;
+}
+
+/* ============ Reduced Motion ============ */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* ============ Mobile Responsive Styles ============ */
+
+/* Tablet and smaller */
+@media (max-width: 1024px) {
+ .converter-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .platforms-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* Mobile devices */
+@media (max-width: 768px) {
+ .app-container {
+ padding: 8px;
+ }
+
+ /* Header - compact horizontal layout */
+ .app-header {
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 12px;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .app-header h1 {
+ font-size: 1.1rem;
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .user-menu {
+ gap: 6px;
+ flex-shrink: 0;
+ }
+
+ .user-menu .username {
+ display: none;
+ }
+
+ .user-menu .btn {
+ padding: 6px 10px;
+ font-size: 0.8rem;
+ }
+
+ .theme-toggle {
+ width: 36px;
+ height: 20px;
+ }
+
+ .theme-toggle::before {
+ width: 16px;
+ height: 16px;
+ }
+
+ .theme-toggle.dark::before {
+ transform: translateX(16px);
+ }
+
+ /* Navigation - grid layout for mobile */
+ .main-nav {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 4px;
+ padding: 6px;
+ margin-bottom: 12px;
+ }
+
+ .nav-btn {
+ padding: 10px 6px;
+ font-size: 0.75rem;
+ text-align: center;
+ min-width: 0;
+ flex: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ /* Make settings button span 2 columns for 5 items grid */
+ .nav-btn:nth-child(4) {
+ grid-column: 1 / 2;
+ }
+ .nav-btn:nth-child(5) {
+ grid-column: 2 / 4;
+ }
+
+ .panel {
+ padding: 12px;
+ border-radius: var(--radius-md);
+ }
+
+ .panel h2 {
+ font-size: 1.1rem;
+ margin-bottom: 12px;
+ }
+
+ .panel h3 {
+ font-size: 1rem;
+ margin: 16px 0 10px;
+ }
+
+ .help-text {
+ font-size: 0.85rem;
+ margin-bottom: 12px;
+ }
+
+ /* Post options grid - horizontal on mobile */
+ .post-options-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .post-options-grid .form-group {
+ margin-bottom: 0;
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ .post-options-grid .form-group:first-child {
+ flex: 1 1 100%;
+ }
+
+ .checkbox-label.compact {
+ padding: 10px 12px;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.85rem;
+ white-space: nowrap;
+ }
+
+ /* Platforms grid - single column on mobile */
+ .platforms-grid {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+
+ .platform-card {
+ padding: 10px;
+ }
+
+ .platform-header {
+ margin-bottom: 8px;
+ }
+
+ .platform-name {
+ font-size: 0.9rem;
+ }
+
+ .platform-target {
+ padding: 8px 10px;
+ font-size: 0.85rem;
+ }
+
+ /* Schedule options */
+ .schedule-options {
+ padding: 10px;
+ margin-bottom: 12px;
+ }
+
+ .schedule-label {
+ font-size: 0.9rem;
+ margin-bottom: 8px;
+ }
+
+ .schedule-presets {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+ margin-bottom: 12px;
+ }
+
+ .schedule-presets .preset-btn {
+ padding: 10px 8px;
+ font-size: 0.75rem;
+ text-align: center;
+ }
+
+ .schedule-date-row {
+ flex-direction: row;
+ gap: 8px;
+ }
+
+ .schedule-field {
+ flex: 1;
+ }
+
+ .schedule-field label {
+ font-size: 0.75rem;
+ }
+
+ .schedule-input {
+ font-size: 16px;
+ padding: 8px 10px;
+ }
+
+ .schedule-custom {
+ padding: 10px;
+ }
+
+ /* Scheduled posts */
+ .scheduled-section {
+ margin-top: 20px;
+ padding-top: 16px;
+ }
+
+ .scheduled-header {
+ margin-bottom: 10px;
+ }
+
+ .scheduled-header h3 {
+ font-size: 0.95rem;
+ margin: 0;
+ }
+
+ .badge {
+ font-size: 0.75rem;
+ padding: 2px 8px;
+ }
+
+ .scheduled-post-card {
+ padding: 10px;
+ }
+
+ .scheduled-post-header {
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+ }
+
+ .scheduled-time {
+ font-size: 0.8rem;
+ }
+
+ .scheduled-platforms {
+ font-size: 0.65rem;
+ padding: 2px 6px;
+ }
+
+ .scheduled-photos-preview {
+ gap: 4px;
+ margin-bottom: 8px;
+ }
+
+ .scheduled-thumb,
+ .more-photos {
+ width: 44px;
+ height: 44px;
+ }
+
+ .more-photos {
+ font-size: 0.75rem;
+ }
+
+ .scheduled-text {
+ font-size: 0.85rem;
+ margin-bottom: 6px;
+ }
+
+ .scheduled-photos,
+ .scheduled-tags {
+ font-size: 0.75rem;
+ }
+
+ .scheduled-post-actions {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 6px;
+ }
+
+ .scheduled-post-actions .btn {
+ padding: 8px 4px;
+ font-size: 0.75rem;
+ justify-content: center;
+ }
+
+ /* Archive section */
+ .archive-section {
+ margin-top: 16px;
+ padding-top: 14px;
+ }
+
+ .archive-header h3 {
+ font-size: 0.9rem;
+ }
+
+ .archive-post-card {
+ padding: 8px 10px;
+ }
+
+ .archive-thumb {
+ width: 32px;
+ height: 32px;
+ }
+
+ .archive-text {
+ font-size: 0.8rem;
+ }
+
+ /* Photos preview */
+ .photos-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 12px;
+ min-height: 80px;
+ }
+
+ .preview-thumb {
+ width: 56px;
+ height: 56px;
+ }
+
+ /* Upload area */
+ .upload-area {
+ flex-direction: column;
+ padding: 12px;
+ gap: 8px;
+ text-align: center;
+ }
+
+ .upload-area .hint {
+ font-size: 0.75rem;
+ }
+
+ .uploaded-preview {
+ margin-top: 6px;
+ }
+
+ .uploaded-item {
+ width: 60px;
+ height: 60px;
+ }
+
+ /* Text editor */
+ .text-editor textarea {
+ min-height: 80px;
+ padding: 10px;
+ font-size: 16px;
+ }
+
+ .editor-toolbar {
+ padding: 6px;
+ gap: 2px;
+ }
+
+ .toolbar-btn {
+ width: 28px;
+ height: 28px;
+ font-size: 12px;
+ }
+
+ .toolbar-separator {
+ margin: 0 2px;
+ }
+
+ /* Tags */
+ .tags-container {
+ padding: 6px;
+ }
+
+ .tag-chip {
+ font-size: 0.75rem;
+ padding: 3px 6px;
+ }
+
+ .tags-input {
+ font-size: 16px !important;
+ padding: 6px !important;
+ }
+
+ .tags-presets {
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+ }
+
+ .tags-presets-label {
+ font-size: 0.7rem;
+ width: 100%;
+ }
+
+ .tag-preset {
+ padding: 4px 8px;
+ font-size: 0.7rem;
+ }
+
+ .preset-add-btn,
+ .preset-manage-btn {
+ width: 24px;
+ height: 24px;
+ font-size: 14px;
+ }
+
+ /* Form elements */
+ .form-group {
+ margin-bottom: 12px;
+ }
+
+ .form-group label {
+ font-size: 0.85rem;
+ margin-bottom: 4px;
+ }
+
+ input[type="text"],
+ input[type="password"],
+ textarea,
+ select {
+ font-size: 16px;
+ padding: 10px 12px;
+ }
+
+ .hint {
+ font-size: 0.75rem;
+ margin-top: 4px;
+ }
+
+ /* Buttons */
+ .btn {
+ padding: 10px 16px;
+ font-size: 0.9rem;
+ }
+
+ .btn-small {
+ padding: 6px 10px;
+ font-size: 0.8rem;
+ }
+
+ .btn-large {
+ padding: 14px 20px;
+ font-size: 0.95rem;
+ }
+
+ .post-actions {
+ gap: 8px;
+ margin-bottom: 16px;
+ }
+
+ .post-actions .btn {
+ flex: 1;
+ }
+
+ /* Gallery */
+ .gallery-header {
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 12px;
+ }
+
+ .gallery-header h2 {
+ font-size: 1.1rem;
+ margin: 0;
+ }
+
+ .gallery-toolbar {
+ width: 100%;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .toolbar-search {
+ flex: 1;
+ min-width: 120px;
+ }
+
+ .toolbar-search input {
+ padding: 8px 10px;
+ }
+
+ #btn-load-albums {
+ padding: 8px 12px;
+ font-size: 0.85rem;
+ }
+
+ .albums-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+ }
+
+ .album-card-overlay {
+ padding: 8px;
+ }
+
+ .album-card-title {
+ font-size: 0.75rem;
+ }
+
+ .album-card-count {
+ font-size: 0.6rem;
+ }
+
+ .photo-gallery {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 4px;
+ }
+
+ .photo-item .checkbox {
+ width: 22px;
+ height: 22px;
+ top: 4px;
+ left: 4px;
+ }
+
+ .photo-item .title {
+ padding: 6px 8px;
+ font-size: 0.65rem;
+ }
+
+ /* Breadcrumb */
+ .breadcrumb {
+ flex-wrap: wrap;
+ gap: 4px;
+ }
+
+ #btn-back-to-albums {
+ padding: 6px 10px;
+ font-size: 0.85rem;
+ }
+
+ .breadcrumb-current {
+ font-size: 0.85rem;
+ }
+
+ .photos-count {
+ font-size: 0.75rem;
+ }
+
+ #btn-download-album {
+ font-size: 0.75rem;
+ padding: 6px 10px;
+ }
+
+ /* Pagination */
+ .pagination {
+ gap: 8px;
+ margin-top: 12px;
+ }
+
+ #page-info {
+ font-size: 0.85rem;
+ }
+
+ /* Floating action bar - compact horizontal */
+ .floating-action-bar {
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 12px;
+ bottom: 8px;
+ left: 8px;
+ right: 8px;
+ border-radius: 16px;
+ }
+
+ .action-bar-left {
+ padding-right: 10px;
+ border-right: 1px solid var(--divider);
+ flex-shrink: 0;
+ }
+
+ .selection-count {
+ font-size: 1.1rem;
+ }
+
+ .selection-label {
+ font-size: 0.7rem;
+ }
+
+ .action-bar-center,
+ .action-bar-right {
+ display: flex;
+ gap: 4px;
+ }
+
+ .action-btn {
+ padding: 8px;
+ flex-direction: row;
+ gap: 0;
+ }
+
+ .action-icon {
+ font-size: 1.2rem;
+ }
+
+ .action-text {
+ display: none;
+ }
+
+ /* Settings sections */
+ .settings-section {
+ padding-bottom: 16px;
+ margin-bottom: 16px;
+ }
+
+ .settings-section h3 {
+ font-size: 0.95rem;
+ }
+
+ /* Status badges */
+ .status {
+ padding: 4px 10px;
+ font-size: 0.75rem;
+ }
+
+ /* Modal */
+ .modal-content {
+ margin: 8px;
+ max-height: calc(100vh - 16px);
+ width: calc(100% - 16px);
+ }
+
+ .modal-header {
+ padding: 12px 16px;
+ }
+
+ .modal-header h3 {
+ font-size: 1rem;
+ }
+
+ .modal-body {
+ padding: 16px;
+ }
+
+ /* Drag hint */
+ .drag-hint {
+ font-size: 0.75rem;
+ margin: 6px 0 12px;
+ }
+
+ /* Lightbox mobile */
+ .lightbox-image-container {
+ max-width: 95vw;
+ max-height: 60vh;
+ }
+
+ .lightbox-image {
+ max-height: 60vh;
+ }
+
+ /* Converter */
+ .converter-input-section,
+ .converter-text-section {
+ gap: 10px;
+ }
+
+ .output-actions {
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ .output-actions .btn {
+ flex: 1 1 auto;
+ min-width: 100px;
+ }
+}
+
+/* Small phones */
+@media (max-width: 480px) {
+ .app-container {
+ padding: 6px;
+ }
+
+ .app-header {
+ padding: 8px 10px;
+ }
+
+ .app-header h1 {
+ font-size: 1rem;
+ }
+
+ .main-nav {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 4px;
+ }
+
+ /* Adjust nav buttons for 2-column grid */
+ .nav-btn:nth-child(5) {
+ grid-column: 1 / 3;
+ }
+
+ .nav-btn {
+ padding: 10px 4px;
+ font-size: 0.7rem;
+ }
+
+ .panel {
+ padding: 10px;
+ }
+
+ .panel h2 {
+ font-size: 1rem;
+ }
+
+ .albums-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+ }
+
+ .photo-gallery {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 4px;
+ }
+
+ .scheduled-thumb,
+ .more-photos {
+ width: 40px;
+ height: 40px;
+ }
+
+ .scheduled-post-actions {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .scheduled-post-actions .btn:last-child {
+ grid-column: 1 / 3;
+ }
+
+ .floating-action-bar {
+ padding: 8px 10px;
+ }
+
+ .action-bar-left {
+ padding-right: 8px;
+ }
+
+ .selection-count {
+ font-size: 1rem;
+ }
+
+ .action-icon {
+ font-size: 1.1rem;
+ }
+
+ .action-btn {
+ padding: 6px;
+ }
+}
+
+/* Extra small phones */
+@media (max-width: 360px) {
+ .app-header h1 {
+ font-size: 0.9rem;
+ }
+
+ .nav-btn {
+ font-size: 0.65rem;
+ padding: 8px 2px;
+ }
+
+ .panel {
+ padding: 8px;
+ }
+
+ .btn {
+ padding: 8px 12px;
+ font-size: 0.8rem;
+ }
+
+ .photo-gallery {
+ gap: 3px;
+ }
+}
+
+/* Touch devices optimization */
+@media (hover: none) and (pointer: coarse) {
+ .btn,
+ .nav-btn,
+ .action-btn,
+ .toolbar-btn {
+ min-height: 44px;
+ }
+
+ .album-card:hover,
+ .photo-item:hover {
+ transform: none;
+ }
+
+ /* Remove hover shadows on touch */
+ .album-card:hover,
+ .platform-card:hover {
+ box-shadow: var(--glass-shadow);
+ }
+
+ /* Better touch targets */
+ .photo-item .checkbox {
+ width: 28px;
+ height: 28px;
+ }
+
+ .platform-checkbox .checkmark {
+ width: 28px;
+ height: 28px;
+ }
+
+ /* Visible preview button on touch */
+ .photo-preview-btn {
+ opacity: 0.8;
+ }
+}
+
+/* Landscape phones */
+@media (max-width: 768px) and (orientation: landscape) {
+ .main-nav {
+ grid-template-columns: repeat(5, 1fr);
+ }
+
+ .nav-btn:nth-child(4),
+ .nav-btn:nth-child(5) {
+ grid-column: auto;
+ }
+
+ .floating-action-bar {
+ bottom: 6px;
+ padding: 8px 16px;
+ }
+
+ .photo-gallery {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .albums-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .lightbox-image-container {
+ max-height: 80vh;
+ }
+
+ .lightbox-image {
+ max-height: 75vh;
+ }
+}
diff --git a/data/.htaccess b/data/.htaccess
new file mode 100644
index 0000000..3a42882
--- /dev/null
+++ b/data/.htaccess
@@ -0,0 +1 @@
+Deny from all
diff --git a/debug.php b/debug.php
new file mode 100644
index 0000000..9605356
--- /dev/null
+++ b/debug.php
@@ -0,0 +1,579 @@
+
+ Order Allow,Deny
+ Deny from all
+
+
+# Block direct access to class files
+RewriteEngine On
+RewriteRule ^classes/ - [F,L]
+
+# Security headers (if mod_headers available)
+
Click buttons to automatically fix common issues:
+ + + + + + + + + +PHP Version: " . phpversion() . "
"; + +if (version_compare(PHP_VERSION, '7.2.0', '<')) { + echo "WARNING: PHP 7.2+ required!
"; +} else { + echo "OK: PHP version is compatible
"; +} + +// Required extensions +echo "OK: {$ext}
"; + } else { + echo "MISSING: {$ext}
"; + } +} + +// BCMath (for base58 decoding) +echo ""; +if (function_exists('bcmul')) { + echo "OK: bcmath"; +} else { + echo "WARNING: bcmath not available (short URLs won't work)"; +} +echo "
"; + +// Password algorithms +echo "OK: Argon2ID available
"; +} else { + echo "INFO: Argon2ID not available, using bcrypt (OK)
"; +} + +// Config file +echo "OK: config.php exists
"; + + try { + $config = require __DIR__ . '/config.php'; + echo "OK: config.php is valid PHP
"; + + if (!empty($config['flickr']['api_key'])) { + echo "OK: Flickr API key set
"; + } else { + echo "INFO: Flickr API key not set
"; + } + + if (!empty($config['telegram']['bot_token'])) { + echo "OK: Telegram bot token set
"; + } else { + echo "INFO: Telegram bot token not set
"; + } + } catch (Throwable $e) { + echo "ERROR in config.php: " . htmlspecialchars($e->getMessage()) . "
"; + } +} else { + echo "MISSING: config.php — use Quick Fix above!
"; +} + +// Writable directories +echo "OK: Root directory is writable
"; +} else { + echo "ERROR: Root directory is not writable (needed for auth_config.php)
"; +} + +// Test class loading +echo "OK: {$class}
"; + } else { + echo "ERROR: {$class} - file loaded but class not found
"; + } + } catch (Throwable $e) { + echo "ERROR loading {$class}: " . htmlspecialchars($e->getMessage()) . "
"; + echo "" . htmlspecialchars($e->getTraceAsString()) . ""; + } + } else { + echo "
MISSING: {$file}
"; + } +} + +// Test Auth instantiation +echo "OK: Auth class instantiated
"; + + if ($auth->hasUsers()) { + echo "OK: Users exist, login page should work
"; + } else { + echo "INFO: No users yet, setup.php should appear
"; + } +} catch (Throwable $e) { + echo "ERROR: " . htmlspecialchars($e->getMessage()) . "
"; + echo "" . htmlspecialchars($e->getTraceAsString()) . ""; +} + +// Security checks +echo "
OK: HTTPS enabled
"; +} else { + echo "WARNING: HTTPS not detected (recommended for production)
"; +} + +// .htaccess exists +if (file_exists(__DIR__ . '/.htaccess')) { + echo "OK: .htaccess exists
"; +} else { + echo "WARNING: .htaccess missing — use Quick Fix above!
"; +} + +// ========== LEAK DETECTION ========== +echo "OK: {$file} is protected (HTTP {$httpCode})
"; + } elseif ($httpCode == 200) { + // Check if content contains sensitive data + $hasSensitiveData = false; + if (strpos($content, 'api_key') !== false || + strpos($content, 'api_secret') !== false || + strpos($content, 'bot_token') !== false || + strpos($content, 'password_hash') !== false || + strpos($content, 'password') !== false) { + $hasSensitiveData = true; + } + + if ($hasSensitiveData) { + echo "CRITICAL LEAK: {$file} is ACCESSIBLE and contains sensitive data! ({$desc})
"; + $leaksFound++; + } else { + echo "WARNING: {$file} is accessible (HTTP 200) - {$desc}
"; + } + } else { + echo "INFO: {$file} returned HTTP {$httpCode}
"; + } +} + +// Check for common info disclosure files +echo "WARNING: {$file} exists - consider removing ({$desc})
"; + } +} + +// Check if secrets are exposed in JS files +echo "DANGER: {$file} may contain: " . implode(', ', $foundSecrets) . "
"; + $leaksFound++; + } else { + echo "OK: {$file} - no secrets found
"; + } + } +} + +// Check config.php for exposed secrets (verify it's not outputting) +if (file_exists(__DIR__ . '/config.php')) { + $configContent = file_get_contents(__DIR__ . '/config.php'); + + // Check if config has echo/print statements + if (preg_match('/(echo|print|var_dump|print_r)\s*\(/i', $configContent)) { + echo "DANGER: config.php contains output statements!
"; + $leaksFound++; + } else { + echo "OK: config.php has no output statements
"; + } + + // Check if config returns array (proper format) + if (strpos($configContent, 'return') !== false) { + echo "OK: config.php uses return statement (good)
"; + } else { + echo "WARNING: config.php may not return array properly
"; + } +} + +// Summary +if ($leaksFound > 0) { + echo "Use Quick Fix buttons above or manually fix the issues.
"; + echo "No critical leaks detected.
"; +} + +// File permissions +echo "WARNING: {$file} is world-readable ({$perms}), recommended: {$recommended}
"; + } else { + echo "OK: {$file} permissions: {$perms}
"; + } + } +} + +// PHP security settings +echo "These are hosting settings - may not be changeable on shared hosting
"; + +$securitySettings = array( + 'expose_php' => array('recommended' => '0', 'desc' => 'Hide PHP version'), + 'display_errors' => array('recommended' => '0', 'desc' => 'Hide errors in production'), + 'allow_url_include' => array('recommended' => '0', 'desc' => 'Prevent remote file inclusion'), + 'session.cookie_httponly' => array('recommended' => '1', 'desc' => 'Protect session cookie'), + 'session.cookie_secure' => array('recommended' => '1', 'desc' => 'HTTPS-only cookies'), + 'session.use_strict_mode' => array('recommended' => '1', 'desc' => 'Strict session mode'), +); + +foreach ($securitySettings as $setting => $info) { + $value = ini_get($setting); + $valueStr = ($value === '' || $value === '0' || $value === false) ? '0' : '1'; + + if ($valueStr === $info['recommended']) { + echo "OK: {$setting} = {$valueStr} ({$info['desc']})
"; + } else { + echo "INFO: {$setting} = {$valueStr}, recommended: {$info['recommended']} ({$info['desc']})
"; + } +} + +// Cryptographic Functions +echo "OK: random_bytes() works
"; + } catch (Exception $e) { + echo "ERROR: random_bytes() failed
"; + } +} else { + echo "WARNING: random_bytes() not available
"; +} + +if (function_exists('openssl_random_pseudo_bytes')) { + echo "OK: openssl_random_pseudo_bytes() available
"; +} else { + echo "WARNING: openssl_random_pseudo_bytes() not available
"; +} + +if (function_exists('password_hash')) { + echo "OK: password_hash() available
"; +} else { + echo "ERROR: password_hash() not available!
"; +} + +// Server info (be careful not to expose too much) +echo "Server software: " . htmlspecialchars(isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : 'Unknown') . "
Document root: " . htmlspecialchars(isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : 'Unknown') . "
Script path: " . htmlspecialchars(__DIR__) . "
If all green: Use 'Delete debug.php & Go to Site' button above
"; +echo "If errors: Use Quick Fix buttons, then refresh this page
"; +echo "IMPORTANT: Delete this file after debugging!
"; +?> + + + diff --git a/debug_urls.php b/debug_urls.php new file mode 100644 index 0000000..2ba2349 --- /dev/null +++ b/debug_urls.php @@ -0,0 +1,111 @@ + $url, + CURLOPT_NOBODY => true, // HEAD request + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_USERAGENT => 'Mozilla/5.0', + CURLOPT_HTTPHEADER => ['Referer: https://www.flickr.com/'], + ]); + curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $size = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); + curl_close($ch); + + $sizeStr = $size > 0 ? formatSize($size) : '?'; + return ['status' => $httpCode, 'size' => $sizeStr, 'bytes' => $size]; +} + +function formatSize($bytes) { + if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB'; + if ($bytes >= 1024) return round($bytes / 1024) . ' KB'; + return $bytes . ' B'; +} + +$flickr = new FlickrAPI( + $config['flickr']['api_key'], + $config['flickr']['api_secret'] ?? '', + $config['flickr_user_id'] ?? '' +); + +echo "=== Testing Flickr URL Formats ===\n\n"; + +// Get one album +$albums = $flickr->getPhotosets(1, 1); +if (empty($albums)) { + die("No albums found\n"); +} + +$albumId = $albums[0]['id']; +$albumTitle = $albums[0]['title']['_content'] ?? 'Unknown'; +echo "Album: $albumTitle (ID: $albumId)\n\n"; + +// Get photos from album +$result = $flickr->getPhotosetPhotos($albumId, 1, 3); + +if (empty($result['photos'])) { + die("No photos in album\n"); +} + +foreach ($result['photos'] as $i => $photo) { + echo "--- Photo " . ($i + 1) . " ---\n"; + echo "ID: " . $photo['id'] . "\n"; + echo "Title: " . $photo['title'] . "\n"; + echo "Server: " . $photo['server'] . "\n"; + echo "Secret: " . $photo['secret'] . "\n"; + echo "Original Secret: " . $photo['original_secret'] . "\n"; + echo "Original Format: " . $photo['original_format'] . "\n"; + + echo "\nURLs (with sizes):\n"; + + // Test each URL size + $sizes = ['medium640', 'large', 'large2048', 'original']; + foreach ($sizes as $size) { + $url = $photo['urls'][$size] ?? null; + if ($url) { + $info = checkUrl($url); + $status = $info['status'] == 200 ? '✓' : '✗ ' . $info['status']; + echo " $size: $status - {$info['size']}\n"; + echo " $url\n"; + } else { + echo " $size: (not available)\n"; + } + } + + // If original secret differs from secret, note it + if ($photo['original_secret'] !== $photo['secret']) { + echo "\n *** Original secret is DIFFERENT - originals should be accessible ***\n"; + } else { + echo "\n Note: Original secret = Secret (originals may not be enabled in Flickr settings)\n"; + } + + echo "\n"; +} + +echo "=== Summary ===\n"; +echo "If 'original' shows ✗ 403 or ✗ 404:\n"; +echo " -> Enable original downloads in Flickr: Settings > Privacy > Allow downloads\n"; +echo "If 'original' shows ✓ with large size:\n"; +echo " -> Downloads should work at full quality!\n"; diff --git a/image.png b/image.png new file mode 100644 index 0000000..4c5e42f Binary files /dev/null and b/image.png differ diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..2a0a4b8 --- /dev/null +++ b/js/app.js @@ -0,0 +1,4210 @@ +/** + * VH Posting System - Frontend JavaScript + * Apple Style UI | Multi-platform posting + */ + +document.addEventListener('DOMContentLoaded', function() { + // ============ THEME MANAGEMENT ============ + + function initTheme() { + const savedTheme = localStorage.getItem('theme') || 'light'; + applyTheme(savedTheme); + } + + function applyTheme(theme) { + if (theme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + document.getElementById('theme-toggle')?.classList.add('dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + document.getElementById('theme-toggle')?.classList.remove('dark'); + } + localStorage.setItem('theme', theme); + } + + function toggleTheme() { + const currentTheme = localStorage.getItem('theme') || 'light'; + const newTheme = currentTheme === 'light' ? 'dark' : 'light'; + applyTheme(newTheme); + } + + document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme); + document.getElementById('btn-toggle-theme')?.addEventListener('click', toggleTheme); + initTheme(); + + // ============ CROSS-PROMO SETTINGS ============ + + const CROSS_PROMO_KEY = 'vh_cross_promo'; + + function getCrossPromoSettings() { + try { + const data = localStorage.getItem(CROSS_PROMO_KEY); + return data ? JSON.parse(data) : { + telegramLink: '', + vkLink: '', + textForTg: 'Мой канал ВКонтакте', + textForVk: 'Мой канал в Telegram' + }; + } catch (e) { + return { telegramLink: '', vkLink: '', textForTg: 'Мой канал ВКонтакте', textForVk: 'Мой канал в Telegram' }; + } + } + + function saveCrossPromoSettings(settings) { + localStorage.setItem(CROSS_PROMO_KEY, JSON.stringify(settings)); + } + + async function initCrossPromoSettings() { + // Try to load from server first, fallback to localStorage + try { + const formData = new FormData(); + formData.append('action', 'get_cross_promo'); + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + if (data.settings) { + saveCrossPromoSettings(data.settings); + } + } catch (e) { + console.log('Using local cross-promo settings'); + } + + const settings = getCrossPromoSettings(); + const tgInput = document.getElementById('cross-promo-telegram'); + const vkInput = document.getElementById('cross-promo-vk'); + const textTgInput = document.getElementById('cross-promo-text-tg'); + const textVkInput = document.getElementById('cross-promo-text-vk'); + + if (tgInput) tgInput.value = settings.telegramLink || ''; + if (vkInput) vkInput.value = settings.vkLink || ''; + if (textTgInput) textTgInput.value = settings.textForTg || 'Мой канал ВКонтакте'; + if (textVkInput) textVkInput.value = settings.textForVk || 'Мой канал в Telegram'; + } + + document.getElementById('btn-save-cross-promo')?.addEventListener('click', async () => { + const settings = { + telegramLink: document.getElementById('cross-promo-telegram')?.value.trim() || '', + vkLink: document.getElementById('cross-promo-vk')?.value.trim() || '', + textForTg: document.getElementById('cross-promo-text-tg')?.value.trim() || 'Мой канал ВКонтакте', + textForVk: document.getElementById('cross-promo-text-vk')?.value.trim() || 'Мой канал в Telegram' + }; + + // Save to localStorage + saveCrossPromoSettings(settings); + + // Save to server for cron + try { + const formData = new FormData(); + formData.append('action', 'save_cross_promo'); + formData.append('telegramLink', settings.telegramLink); + formData.append('vkLink', settings.vkLink); + formData.append('textForTg', settings.textForTg); + formData.append('textForVk', settings.textForVk); + await fetch('api.php', { method: 'POST', body: formData }); + } catch (e) { + console.warn('Failed to save cross-promo to server:', e); + } + + const status = document.getElementById('cross-promo-save-status'); + if (status) { + status.textContent = '✓ Сохранено'; + status.className = 'save-status success'; + setTimeout(() => { status.textContent = ''; }, 2000); + } + showNotification('Настройки кросс-промо сохранены', 'success'); + }); + + // Initialize cross-promo on load + initCrossPromoSettings(); + + // ============ AUTO-SAVE POST DRAFT (SERVER-BASED) ============ + + let serverDraft = null; + let draftSaveTimer = null; + + // Save draft to server (debounced - for text input) + function savePostDraft() { + clearTimeout(draftSaveTimer); + draftSaveTimer = setTimeout(savePostDraftToServer, 1500); + } + + // Save draft immediately (for tags, photos) + function savePostDraftNow() { + clearTimeout(draftSaveTimer); + savePostDraftToServer(); + } + + // Actually save to server + async function savePostDraftToServer() { + const postText = document.getElementById('post-text')?.value || ''; + const tags = typeof tagContexts !== 'undefined' ? tagContexts.post : []; + const photos = state?.selectedPhotos?.map(p => ({ + id: p.id, + title: p.title, + urls: p.urls, + page_url: p.page_url, + is_video: p.is_video + })) || []; + const uploadedFiles = state?.uploadedFiles?.filter(f => !f.uploading && f.url).map(f => ({ + id: f.id, + name: f.name, + type: f.type, + url: f.url + })) || []; + + const formData = new FormData(); + formData.append('action', 'save_draft'); + formData.append('text', postText); + formData.append('tags', JSON.stringify(tags)); + formData.append('photos', JSON.stringify(photos)); + formData.append('uploaded_files', JSON.stringify(uploadedFiles)); + + try { + await fetch('api.php', { method: 'POST', body: formData }); + } catch (e) { + console.warn('Could not save draft to server:', e); + } + } + + // Load draft from server + async function loadPostDraftFromServer() { + try { + const response = await fetch('api.php?action=get_draft'); + const data = await response.json(); + if (data.success && data.draft) { + return data.draft; + } + } catch (e) { + console.error('Error loading draft from server:', e); + } + return null; + } + + // Clear draft on server + async function clearPostDraftOnServer() { + try { + const formData = new FormData(); + formData.append('action', 'clear_draft'); + await fetch('api.php', { method: 'POST', body: formData }); + } catch (e) { + console.warn('Could not clear draft on server:', e); + } + } + + function clearPostDraft() { + clearPostDraftOnServer(); + } + + // Save photos and uploaded files immediately + function saveSelectedPhotos() { + savePostDraftNow(); + } + + function saveUploadedFiles() { + savePostDraftNow(); + } + + // Auto-save on text input (debounced) + document.getElementById('post-text')?.addEventListener('input', () => { + savePostDraft(); + }); + + // ============ STATE ============ + + const MAX_PHOTOS = 9; + + function getTotalPhotosCount() { + return state.selectedPhotos.length + state.uploadedFiles.length; + } + + function canAddPhotos(count = 1) { + if (getTotalPhotosCount() + count > MAX_PHOTOS) { + showNotification(`Максимум ${MAX_PHOTOS} фото/видео`, 'error'); + return false; + } + return true; + } + + const state = { + selectedPhotos: [], + uploadedFiles: [], + currentPage: 1, + totalPages: 1, + currentAlbum: '', + isLoadingPhotos: false, + isLoadingAlbums: false, + photoRequestId: 0, // For request deduplication + albumRequestId: 0, + // Infinite scroll state for albums + albumsPage: 1, + albumsTotalPages: 1, + albumsTotal: 0, + isLoadingMoreAlbums: false, + allAlbums: [], + // Infinite scroll state for photos + isLoadingMorePhotos: false, + allPhotos: [] + }; + + // Load draft from server on page load + let pendingDraft = null; + loadPostDraftFromServer().then(draft => { + if (!draft) return; + serverDraft = draft; + pendingDraft = draft; + + // Restore text + const postText = document.getElementById('post-text'); + if (postText && draft.text) { + postText.value = draft.text; + } + + // Restore photos + if (draft.photos && draft.photos.length > 0) { + state.selectedPhotos = draft.photos; + } + + // Restore uploaded files (without dataUrl, just URL) + if (draft.uploaded_files && draft.uploaded_files.length > 0) { + state.uploadedFiles = draft.uploaded_files.map(f => ({ + ...f, + dataUrl: f.url, // Use URL as dataUrl for preview + uploading: false + })); + } + + // Update preview + updatePostingPreview(); + + // Try to restore tags (will work if tagContexts is already initialized) + restoreDraftTags(); + }); + + // Function to restore tags after tagContexts is available + let tagsRestored = false; + function restoreDraftTags() { + if (tagsRestored) return; + if (!pendingDraft || !pendingDraft.tags || pendingDraft.tags.length === 0) return; + if (typeof tagContexts === 'undefined') return; + + tagsRestored = true; + tagContexts.post = pendingDraft.tags; + const tagsList = document.getElementById('post-tags-list'); + if (tagsList) { + tagsList.innerHTML = pendingDraft.tags.map(tag => ` + + #${escapeHtml(tag)} + + + `).join(''); + // Re-attach remove handlers + tagsList.querySelectorAll('.tag-remove').forEach(btn => { + btn.addEventListener('click', () => { + const tag = btn.dataset.tag; + tagContexts.post = tagContexts.post.filter(t => t !== tag); + btn.parentElement.remove(); + savePostDraftNow(); + }); + }); + } + showNotification('Черновик восстановлен', 'info'); + } + + // ============ PHOTO SOURCE BUTTONS ============ + + // Select from Flickr - go to gallery tab + document.getElementById('btn-select-from-flickr')?.addEventListener('click', () => { + document.querySelector('.nav-btn[data-tab="gallery"]')?.click(); + }); + + // Upload from device + document.getElementById('btn-upload-files')?.addEventListener('click', () => { + document.getElementById('file-upload')?.click(); + }); + + document.getElementById('file-upload')?.addEventListener('change', async (e) => { + const files = Array.from(e.target.files); + if (!files.length) return; + + for (const file of files) { + // Check photo limit + if (getTotalPhotosCount() >= MAX_PHOTOS) { + showNotification(`Максимум ${MAX_PHOTOS} фото/видео`, 'error'); + break; + } + + // Check file size (max 50MB) + if (file.size > 50 * 1024 * 1024) { + showNotification(`Файл ${file.name} слишком большой (макс 50MB)`, 'error'); + continue; + } + + // Create preview immediately + const reader = new FileReader(); + reader.onload = async (event) => { + const fileId = 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + const fileData = { + id: fileId, + name: file.name, + type: file.type, + size: file.size, + dataUrl: event.target.result, + uploading: true, + url: null + }; + state.uploadedFiles.push(fileData); + renderUploadedFiles(); + + // Upload to server + try { + const formData = new FormData(); + formData.append('action', 'upload_file'); + formData.append('file', file); + + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + // Find and update the file in state + const idx = state.uploadedFiles.findIndex(f => f.id === fileId); + if (idx !== -1) { + if (data.error) { + showNotification(`Ошибка загрузки ${file.name}: ${data.error}`, 'error'); + state.uploadedFiles.splice(idx, 1); + } else { + state.uploadedFiles[idx].url = data.url; + state.uploadedFiles[idx].uploading = false; + saveUploadedFiles(); // Save to localStorage after successful upload + } + renderUploadedFiles(); + } + } catch (error) { + showNotification(`Ошибка загрузки ${file.name}`, 'error'); + const idx = state.uploadedFiles.findIndex(f => f.id === fileId); + if (idx !== -1) { + state.uploadedFiles.splice(idx, 1); + renderUploadedFiles(); + } + } + }; + reader.readAsDataURL(file); + } + + // Clear input for re-upload of same files + e.target.value = ''; + }); + + function renderUploadedFiles() { + // Now uses the combined preview + updatePostingPreview(); + } + + // Drag and drop support for combined preview + const combinedPreview = document.getElementById('post-photos-preview'); + if (combinedPreview) { + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + combinedPreview.addEventListener(eventName, (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + }); + + ['dragenter', 'dragover'].forEach(eventName => { + combinedPreview.addEventListener(eventName, () => { + combinedPreview.classList.add('drag-over'); + }); + }); + + ['dragleave', 'drop'].forEach(eventName => { + combinedPreview.addEventListener(eventName, () => { + combinedPreview.classList.remove('drag-over'); + }); + }); + + combinedPreview.addEventListener('drop', (e) => { + const files = e.dataTransfer.files; + if (files.length) { + const fileInput = document.getElementById('file-upload'); + if (fileInput) { + fileInput.files = files; + fileInput.dispatchEvent(new Event('change')); + } + } + }); + } + + // AbortController for cancelling in-flight requests + let photoAbortController = null; + let albumAbortController = null; + + // ============ ALBUM PREFERENCES (localStorage) ============ + + const ALBUM_CACHE_KEY = 'vh_album_cache'; + const ALBUM_PREFS_KEY = 'vh_album_prefs'; + const CACHE_TTL = 60 * 60 * 1000; // 1 hour + + function getAlbumCache() { + try { + const cached = localStorage.getItem(ALBUM_CACHE_KEY); + if (!cached) return null; + const data = JSON.parse(cached); + if (Date.now() - data.timestamp > CACHE_TTL) { + localStorage.removeItem(ALBUM_CACHE_KEY); + return null; + } + // Handle both old format (array) and new format (object with albums property) + const albums = data.albums; + if (Array.isArray(albums)) { + return albums; + } else if (albums && Array.isArray(albums.albums)) { + return albums.albums; + } + return null; + } catch (e) { + return null; + } + } + + function setAlbumCache(albumsOrData) { + try { + // Accept either array or object with albums property + const albums = Array.isArray(albumsOrData) ? albumsOrData : (albumsOrData.albums || []); + localStorage.setItem(ALBUM_CACHE_KEY, JSON.stringify({ + albums: albums, + timestamp: Date.now() + })); + } catch (e) { + console.warn('Failed to cache albums:', e); + } + } + + function getAlbumPrefs() { + try { + const prefs = localStorage.getItem(ALBUM_PREFS_KEY); + return prefs ? JSON.parse(prefs) : { favorites: [], order: [] }; + } catch (e) { + return { favorites: [], order: [] }; + } + } + + function saveAlbumPrefs(prefs) { + try { + localStorage.setItem(ALBUM_PREFS_KEY, JSON.stringify(prefs)); + } catch (e) { + console.warn('Failed to save album prefs:', e); + } + } + + function toggleAlbumFavorite(albumId) { + const prefs = getAlbumPrefs(); + const index = prefs.favorites.indexOf(albumId); + if (index === -1) { + prefs.favorites.push(albumId); + } else { + prefs.favorites.splice(index, 1); + } + saveAlbumPrefs(prefs); + renderAlbumDropdown(window._cachedAlbums || []); + } + + function sortAlbumsByPreference(albums) { + const prefs = getAlbumPrefs(); + const favorites = new Set(prefs.favorites); + + // Sort: favorites first, then rest + return [...albums].sort((a, b) => { + const aFav = favorites.has(a.id); + const bFav = favorites.has(b.id); + if (aFav && !bFav) return -1; + if (!aFav && bFav) return 1; + return 0; // Keep original order within groups + }); + } + + // ============ DOM ELEMENTS ============ + + const selectionBar = document.getElementById('selection-bar'); + const selectedCountEl = document.getElementById('selected-count'); + const photoGallery = document.getElementById('photo-gallery'); + const postPhotosPreview = document.getElementById('post-photos-preview'); + + // ============ UTILITY FUNCTIONS ============ + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; + } + + function showNotification(message, type = 'info') { + const notification = document.createElement('div'); + notification.textContent = message; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 16px 24px; + border-radius: 12px; + font-weight: 500; + z-index: 10000; + animation: slideIn 0.3s ease; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + color: white; + background: ${type === 'success' ? 'rgba(52, 199, 89, 0.9)' : + type === 'error' ? 'rgba(255, 59, 48, 0.9)' : + 'rgba(0, 122, 255, 0.9)'}; + `; + document.body.appendChild(notification); + setTimeout(() => { + notification.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => notification.remove(), 300); + }, 3000); + } + + // Add animation styles + const style = document.createElement('style'); + style.textContent = ` + @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } + `; + document.head.appendChild(style); + + // ============ SELECTION MANAGEMENT ============ + + function updateSelectionUI() { + const count = state.selectedPhotos.length; + const total = getTotalPhotosCount(); + + // Update counter with limit info (gallery floating bar) + if (selectedCountEl) { + selectedCountEl.textContent = `${total}/${MAX_PHOTOS}`; + } + + // Update counter on posting page + const photoCounter = document.getElementById('photo-counter'); + if (photoCounter) { + photoCounter.textContent = `${total}/${MAX_PHOTOS}`; + photoCounter.classList.toggle('at-limit', total >= MAX_PHOTOS); + } + + // Show/hide floating action bar + if (selectionBar) { + if (count > 0) { + selectionBar.classList.remove('hidden'); + } else { + selectionBar.classList.add('hidden'); + } + } + } + + function updatePostingPreview() { + // Always update photo counter + const total = getTotalPhotosCount(); + const photoCounter = document.getElementById('photo-counter'); + if (photoCounter) { + photoCounter.textContent = `${total}/${MAX_PHOTOS}`; + photoCounter.classList.toggle('at-limit', total >= MAX_PHOTOS); + } + + if (!postPhotosPreview) return; + + const hasFlickrPhotos = state.selectedPhotos.length > 0; + const hasUploadedFiles = state.uploadedFiles.length > 0; + + if (!hasFlickrPhotos && !hasUploadedFiles) { + postPhotosPreview.innerHTML = 'Нажмите кнопку выше чтобы добавить фото
'; + return; + } + + postPhotosPreview.innerHTML = ''; + + // Render Flickr photos + state.selectedPhotos.forEach((photo, index) => { + const div = document.createElement('div'); + div.className = 'preview-thumb'; + div.innerHTML = ` +Альбомы не найдены
'; + if (dragHint) dragHint.classList.add('hidden'); + return; + } + + if (dragHint) dragHint.classList.remove('hidden'); + + albumsGrid.innerHTML = ''; + orderedAlbums.forEach(album => { + const card = createAlbumCard(album); + albumsGrid.appendChild(card); + }); + } + + function createAlbumCard(album) { + const card = document.createElement('div'); + card.className = 'album-card'; + card.dataset.albumId = album.id; + card.draggable = true; + + const title = album.title?._content || album.title || 'Без названия'; + const count = album.photos || 0; + const coverUrl = album.primary_photo_extras?.url_m || + album.primary_photo_extras?.url_s || + album.primary_photo_extras?.url_sq || + null; + + card.innerHTML = ` + ${coverUrl + ? `Альбомы не найдены. Проверьте настройки Flickr API.
'; + showNotification('Альбомы не найдены', 'error'); + } + } catch (error) { + if (error.name === 'AbortError') { + console.log('Request aborted'); + albumsGrid.innerHTML = ` + + `; + showNotification('Таймаут: сервер не отвечает', 'error'); + return; + } + console.error('Ошибка загрузки альбомов:', error); + albumsGrid.innerHTML = ` + + `; + showNotification('Ошибка: ' + error.message, 'error'); + } finally { + state.isLoadingAlbums = false; + if (btnLoadAlbums) { + btnLoadAlbums.disabled = false; + btnLoadAlbums.innerHTML = ' Загрузить альбомы'; + } + } + } + + // Load more albums (infinite scroll) + async function loadMoreAlbums() { + if (state.isLoadingMoreAlbums || state.albumsPage >= state.albumsTotalPages) { + return; + } + + state.isLoadingMoreAlbums = true; + const nextPage = state.albumsPage + 1; + + // Show loading indicator at bottom + const loadingEl = document.createElement('div'); + loadingEl.className = 'albums-loading-more'; + loadingEl.innerHTML = ` + + Загрузка альбомов... + `; + albumsGrid.appendChild(loadingEl); + + try { + const response = await fetch(`api.php?action=flickr_albums&page=${nextPage}&per_page=50`); + const data = await response.json(); + + // Remove loading indicator + loadingEl.remove(); + + if (data.error) { + throw new Error(data.error); + } + + if (data.albums && data.albums.length > 0) { + state.albumsPage = data.page; + state.allAlbums = [...state.allAlbums, ...data.albums]; + window._cachedAlbums = state.allAlbums; + + // Update cache + setAlbumCache({ + albums: state.allAlbums, + page: state.albumsPage, + pages: state.albumsTotalPages, + total: state.albumsTotal + }); + + // Append new albums to grid + appendAlbumsToGrid(data.albums); + + console.log(`Loaded page ${nextPage}/${state.albumsTotalPages}, total albums: ${state.allAlbums.length}`); + } + } catch (error) { + loadingEl.remove(); + console.error('Error loading more albums:', error); + showNotification('Ошибка загрузки альбомов: ' + error.message, 'error'); + } finally { + state.isLoadingMoreAlbums = false; + } + } + + // Append albums to existing grid + function appendAlbumsToGrid(albums) { + albums.forEach(album => { + const card = createAlbumCard(album); + albumsGrid.appendChild(card); + }); + + if (dragHint && state.allAlbums.length > 1) { + dragHint.classList.remove('hidden'); + } + } + + // Setup infinite scroll observer for albums + let albumsScrollObserver = null; + + function setupAlbumsInfiniteScroll() { + // Remove existing observer + if (albumsScrollObserver) { + albumsScrollObserver.disconnect(); + } + + // Create sentinel element + let sentinel = document.getElementById('albums-scroll-sentinel'); + if (!sentinel) { + sentinel = document.createElement('div'); + sentinel.id = 'albums-scroll-sentinel'; + sentinel.style.cssText = 'height: 20px; grid-column: 1/-1;'; + } + + // Append sentinel after grid + if (albumsGrid && albumsGrid.parentNode) { + // Insert after albumsGrid + albumsGrid.parentNode.insertBefore(sentinel, albumsGrid.nextSibling); + } + + // Create intersection observer + albumsScrollObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && !state.isLoadingMoreAlbums && state.albumsPage < state.albumsTotalPages) { + console.log('Albums sentinel visible, loading more...'); + loadMoreAlbums(); + } + }); + }, { + root: null, + rootMargin: '200px', + threshold: 0 + }); + + albumsScrollObserver.observe(sentinel); + } + + // ============ LOAD PHOTOS (with infinite scroll) ============ + + async function loadPhotos() { + if (!photoGallery) return; + + if (photoAbortController) { + photoAbortController.abort(); + } + photoAbortController = new AbortController(); + + // Reset state for new load + state.currentPage = 1; + state.totalPages = 1; + state.allPhotos = []; + state.isLoadingPhotos = true; + state.photoRequestId++; + const thisRequestId = state.photoRequestId; + + photoGallery.innerHTML = ` +Ошибка: ${data.error}
`; + return; + } + + state.totalPages = data.pagination?.pages || 1; + state.currentPage = data.pagination?.page || 1; + const totalPhotos = data.pagination?.total || 0; + updatePagination(); + + if (photosCountEl) { + photosCountEl.textContent = `${totalPhotos} фото`; + } + + if (!data.photos || data.photos.length === 0) { + photoGallery.innerHTML = 'Фотографии не найдены
'; + return; + } + + state.allPhotos = data.photos; + photoGallery.innerHTML = ''; + renderPhotos(data.photos); + + // Setup infinite scroll if there are more pages + if (state.currentPage < state.totalPages) { + setupPhotosInfiniteScroll(); + } + + } catch (error) { + if (error.name === 'AbortError') return; + photoGallery.innerHTML = `Ошибка: ${error.message}
`; + showNotification('Ошибка загрузки фотографий', 'error'); + } finally { + state.isLoadingPhotos = false; + } + } + + // Render photos to gallery + function renderPhotos(photos, append = false) { + if (!append) { + photoGallery.innerHTML = ''; + } + + photos.forEach(photo => { + const div = document.createElement('div'); + div.className = 'photo-item'; + if (photo.is_video) { + div.classList.add('is-video'); + } + div.dataset.photoId = photo.id; + div.dataset.photoData = JSON.stringify(photo); + + if (state.selectedPhotos.find(p => p.id === photo.id)) { + div.classList.add('selected'); + } + + const videoBadge = photo.is_video ? 'Выберите формат загрузки:
+ + +Нет пресетов. Добавьте первый!
'; + return; + } + + container.innerHTML = presets.map(preset => ` +'; after = ''; break;
+ case 'link':
+ const url = prompt('Введите URL:', 'https://');
+ if (url) {
+ before = '';
+ after = '';
+ if (!selected) newText = url;
+ }
+ break;
+ }
+ } else if (parseMode === 'Markdown') {
+ switch (format) {
+ case 'bold': before = '**'; after = '**'; break;
+ case 'italic': before = '_'; after = '_'; break;
+ case 'underline': before = '__'; after = '__'; break;
+ case 'strike': before = '~~'; after = '~~'; break;
+ case 'code': before = '`'; after = '`'; break;
+ case 'link':
+ const url = prompt('Введите URL:', 'https://');
+ if (url) {
+ before = '[';
+ after = '](' + url + ')';
+ if (!selected) newText = 'ссылка';
+ }
+ break;
+ }
+ } else {
+ // Plain text - just insert without formatting
+ if (format === 'link') {
+ const url = prompt('Введите URL:', 'https://');
+ if (url) {
+ newText = url;
+ }
+ }
+ before = '';
+ after = '';
+ }
+
+ if (before || after || newText !== selected) {
+ const replacement = before + newText + after;
+ textarea.value = text.substring(0, start) + replacement + text.substring(end);
+ textarea.focus();
+ textarea.setSelectionRange(start + before.length, start + before.length + newText.length);
+ }
+ });
+ });
+ }
+
+ initTextEditorToolbar();
+
+ // ============ SCHEDULED POSTS ============
+
+ const scheduledState = {
+ uploadedFiles: [],
+ editingPostId: null
+ };
+
+ // Schedule toggle handler
+ const scheduleCheckbox = document.getElementById('chk-schedule');
+ const scheduleOptions = document.getElementById('schedule-options');
+ const btnSendPostMain = document.getElementById('btn-send-post');
+
+ function updateScheduleUI() {
+ const isScheduled = scheduleCheckbox?.checked;
+ if (scheduleOptions) {
+ scheduleOptions.classList.toggle('hidden', !isScheduled);
+ }
+ if (btnSendPostMain) {
+ btnSendPostMain.innerHTML = isScheduled ? '📅 Запланировать' : '🚀 Опубликовать';
+ }
+ }
+
+ scheduleCheckbox?.addEventListener('change', updateScheduleUI);
+
+ // Schedule date/time picker
+ const scheduleDate = document.getElementById('schedule-date');
+ const scheduleTime = document.getElementById('schedule-time');
+ const scheduledDatetime = document.getElementById('scheduled-datetime');
+
+ // Set default to 1 hour from now
+ function setDefaultScheduleTime() {
+ const oneHourLater = new Date();
+ oneHourLater.setHours(oneHourLater.getHours() + 1);
+ oneHourLater.setMinutes(0, 0, 0); // Round to nearest hour
+ setScheduleDateTime(oneHourLater);
+ }
+
+ function setScheduleDateTime(date) {
+ if (scheduleDate) {
+ // Use local date components to avoid timezone issues
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ scheduleDate.value = `${year}-${month}-${day}`;
+ }
+ if (scheduleTime) {
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ scheduleTime.value = `${hours}:${minutes}`;
+ }
+ syncScheduledDatetime();
+ }
+
+ function syncScheduledDatetime() {
+ if (scheduleDate && scheduleTime && scheduledDatetime) {
+ scheduledDatetime.value = `${scheduleDate.value}T${scheduleTime.value}`;
+ }
+ }
+
+ // Sync hidden field when date/time changes
+ scheduleDate?.addEventListener('change', syncScheduledDatetime);
+ scheduleTime?.addEventListener('change', syncScheduledDatetime);
+
+ // Set min date to today (local date)
+ if (scheduleDate) {
+ const today = new Date();
+ const year = today.getFullYear();
+ const month = String(today.getMonth() + 1).padStart(2, '0');
+ const day = String(today.getDate()).padStart(2, '0');
+ scheduleDate.min = `${year}-${month}-${day}`;
+ }
+
+ // Handle preset buttons
+ document.querySelectorAll('.schedule-presets .preset-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const preset = btn.dataset.preset;
+ const now = new Date();
+ let targetDate = new Date();
+
+ switch (preset) {
+ case '1h':
+ targetDate.setHours(now.getHours() + 1);
+ break;
+ case '3h':
+ targetDate.setHours(now.getHours() + 3);
+ break;
+ case 'tomorrow-10':
+ targetDate.setDate(now.getDate() + 1);
+ targetDate.setHours(10, 0, 0, 0);
+ break;
+ case 'tomorrow-18':
+ targetDate.setDate(now.getDate() + 1);
+ targetDate.setHours(18, 0, 0, 0);
+ break;
+ }
+
+ setScheduleDateTime(targetDate);
+
+ // Highlight active preset
+ document.querySelectorAll('.schedule-presets .preset-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ });
+ });
+
+ // Initialize with default time
+ setDefaultScheduleTime();
+
+ // Load scheduled and published posts on posting tab switch
+ document.querySelectorAll('.nav-btn[data-tab="posting"]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ loadScheduledPosts();
+ loadPublishedPosts();
+ updateScheduledCount();
+ });
+ });
+
+ async function loadScheduledPosts() {
+ const list = document.getElementById('scheduled-posts-list');
+ if (!list) return;
+
+ try {
+ const formData = new FormData();
+ formData.append('action', 'get_scheduled_posts');
+ const response = await fetch('api.php', { method: 'POST', body: formData });
+ const data = await response.json();
+
+ if (data.posts && data.posts.length > 0) {
+ const pendingPosts = data.posts.filter(p => p.status === 'pending');
+ if (pendingPosts.length === 0) {
+ list.innerHTML = 'Нет запланированных постов
'; + return; + } + + list.innerHTML = pendingPosts.map(post => { + const dateStr = new Date(post.scheduled_time).toLocaleString('ru-RU', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }); + const platforms = (post.platforms || []).map(p => { + const type = p.type || p; + return type === 'telegram' ? 'TG' : type === 'vk' ? 'VK' : type; + }).join(' · '); + + // Collect all photo URLs for preview + const allPhotos = [ + ...(post.photos || []), + ...(post.uploaded_files || []).map(f => f.url || f) + ]; + const photosCount = allPhotos.length; + + // Generate photo preview HTML (show up to 4 photos) + let photosPreviewHtml = ''; + if (photosCount > 0) { + const previewPhotos = allPhotos.slice(0, 4); + const moreCount = photosCount > 4 ? photosCount - 4 : 0; + photosPreviewHtml = ` +${escapeHtml(post.text.substring(0, 150))}${post.text.length > 150 ? '...' : ''}
` : ''} + ${post.tags?.length ? `` : ''} +Нет запланированных постов
'; + updateScheduledCount(0); + } + } catch (error) { + list.innerHTML = 'Ошибка загрузки
'; + } + } + + // Update scheduled posts count badge + async function updateScheduledCount(count = null) { + const badge = document.getElementById('scheduled-count'); + if (!badge) return; + + if (count !== null) { + badge.textContent = count; + badge.style.display = count > 0 ? 'inline-block' : 'none'; + return; + } + + try { + const formData = new FormData(); + formData.append('action', 'get_scheduled_posts'); + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.posts) { + const pendingCount = data.posts.filter(p => p.status === 'pending').length; + badge.textContent = pendingCount; + badge.style.display = pendingCount > 0 ? 'inline-block' : 'none'; + } + } catch (error) { + console.error('Failed to update scheduled count:', error); + } + } + + async function deleteScheduledPost(postId) { + if (!confirm('Удалить этот запланированный пост?')) return; + + try { + const formData = new FormData(); + formData.append('action', 'delete_scheduled_post'); + formData.append('id', postId); + + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.error) { + showNotification('Ошибка: ' + data.error, 'error'); + } else { + showNotification('Пост удалён', 'success'); + loadScheduledPosts(); + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + } + } + + function editScheduledPost(postId, posts) { + const post = posts.find(p => p.id === postId); + if (!post) return; + + const card = document.querySelector(`.scheduled-post-card[data-id="${postId}"]`); + if (!card) return; + + // Collect all photo URLs + const allPhotos = [ + ...(post.photos || []), + ...(post.uploaded_files || []).map(f => f.url || f) + ]; + + const hasTg = (post.platforms || []).some(p => (p.type || p) === 'telegram'); + const hasVk = (post.platforms || []).some(p => (p.type || p) === 'vk'); + const tags = post.tags || []; + + // Parse scheduled_time into date and time inputs + let dateVal = '', timeVal = ''; + if (post.scheduled_time) { + const dt = new Date(post.scheduled_time.replace(' ', 'T')); + dateVal = `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`; + timeVal = `${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`; + } + + // Save original HTML for cancel + const originalHtml = card.innerHTML; + + // Render inline editor + card.classList.add('editing'); + card.innerHTML = ` +Нет фото
'} +Нет фото
'; + } + }); + }); + } else { + photosContainer.innerHTML = 'Нет фото
'; + } + }); + }); + + // Tag remove handlers + card.querySelectorAll('.tag-remove').forEach(btn => { + btn.addEventListener('click', () => { + editTags = editTags.filter(t => t !== btn.dataset.tag); + btn.parentElement.remove(); + }); + }); + + // Tag add on Enter + const tagInput = card.querySelector('.inline-tags-input'); + tagInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const tag = tagInput.value.trim().replace(/^#/, '').replace(/[,\s]+/g, ''); + if (tag && !editTags.includes(tag)) { + editTags.push(tag); + const chip = document.createElement('span'); + chip.className = 'tag-chip'; + chip.innerHTML = `#${escapeHtml(tag)} `; + chip.querySelector('.tag-remove').addEventListener('click', () => { + editTags = editTags.filter(t => t !== tag); + chip.remove(); + }); + card.querySelector('.inline-tags-list').appendChild(chip); + } + tagInput.value = ''; + } + }); + + // Cancel + card.querySelector('.inline-cancel').addEventListener('click', () => { + card.classList.remove('editing'); + card.innerHTML = originalHtml; + // Re-attach original handlers + card.querySelector('.btn-edit-scheduled')?.addEventListener('click', () => editScheduledPost(postId, posts)); + card.querySelector('.btn-delete-scheduled')?.addEventListener('click', () => deleteScheduledPost(postId)); + }); + + // Save + card.querySelector('.inline-save').addEventListener('click', async () => { + const text = card.querySelector('.inline-editor-text').value; + const dateInput = card.querySelector('.inline-date').value; + const timeInput = card.querySelector('.inline-time').value; + const tgChecked = card.querySelector('.inline-chk-tg').checked; + const vkChecked = card.querySelector('.inline-chk-vk').checked; + + if (!dateInput || !timeInput) { + showNotification('Укажите дату и время', 'error'); + return; + } + + const scheduledTime = `${dateInput} ${timeInput}:00`; + + const platforms = []; + if (tgChecked) platforms.push({ type: 'telegram', target: document.getElementById('tg-channel')?.value || '' }); + if (vkChecked) platforms.push({ type: 'vk', target: document.getElementById('vk-group')?.value || '' }); + + if (platforms.length === 0) { + showNotification('Выберите платформу', 'error'); + return; + } + + // Separate photos and uploaded files based on original data + const origPhotos = post.photos || []; + const origUploaded = (post.uploaded_files || []).map(f => f.url || f); + const newPhotos = editPhotos.filter(url => origPhotos.includes(url)); + const newUploaded = editPhotos.filter(url => origUploaded.includes(url)).map(url => { + const orig = (post.uploaded_files || []).find(f => (f.url || f) === url); + return orig && typeof orig === 'object' ? orig : { url }; + }); + // Photos not in either original list go to photos + editPhotos.forEach(url => { + if (!newPhotos.includes(url) && !newUploaded.some(f => (f.url || f) === url)) { + newPhotos.push(url); + } + }); + + const saveBtn = card.querySelector('.inline-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Сохранение...'; + + try { + const formData = new FormData(); + formData.append('action', 'update_scheduled_post'); + formData.append('id', postId); + formData.append('text', text); + formData.append('tags', JSON.stringify(editTags)); + formData.append('photos', JSON.stringify(newPhotos)); + formData.append('uploaded_files', JSON.stringify(newUploaded)); + formData.append('platforms', JSON.stringify(platforms)); + formData.append('scheduled_time', scheduledTime); + + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.error) { + showNotification('Ошибка: ' + data.error, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = '💾 Сохранить'; + } else { + showNotification('Пост обновлён!', 'success'); + loadScheduledPosts(); + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = '💾 Сохранить'; + } + }); + + // Publish now + card.querySelector('.inline-publish-now').addEventListener('click', async () => { + const text = card.querySelector('.inline-editor-text').value; + const tgChecked = card.querySelector('.inline-chk-tg').checked; + const vkChecked = card.querySelector('.inline-chk-vk').checked; + + const platforms = []; + const tgChannel = document.getElementById('tg-channel')?.value; + const vkGroup = document.getElementById('vk-group')?.value; + if (tgChecked && tgChannel) platforms.push({ type: 'telegram', target: tgChannel }); + if (vkChecked && vkGroup) platforms.push({ type: 'vk', target: vkGroup }); + + if (platforms.length === 0) { + showNotification('Выберите платформу и канал/группу', 'error'); + return; + } + + if (editPhotos.length === 0 && !text.trim()) { + showNotification('Добавьте фото или текст', 'error'); + return; + } + + if (!confirm('Опубликовать этот пост сейчас?')) return; + + const pubBtn = card.querySelector('.inline-publish-now'); + pubBtn.disabled = true; + pubBtn.textContent = 'Публикация...'; + + try { + // Add tags to text + const tagsStr = editTags.map(t => '#' + t).join(' '); + let fullText = text.trim(); + if (tagsStr) { + fullText = fullText ? fullText + '\n\n' + tagsStr : tagsStr; + } + + // Add cross-promo + const crossPromoEnabled = post.cross_promo; + if (crossPromoEnabled) { + const storedSettings = getCrossPromoSettings(); + if (tgChecked && storedSettings.vkLink) { + // Will be handled per-platform below + } + } + + // Publish to each platform + const results = {}; + for (const platform of platforms) { + let platformText = fullText; + + // Cross-promo + if (crossPromoEnabled) { + const s = getCrossPromoSettings(); + if (platform.type === 'telegram' && s.vkLink) { + const parseMode = 'HTML'; + platformText += `\n\n${s.textForTg || 'Мой канал ВКонтакте'}`; + } + if (platform.type === 'vk' && s.telegramLink) { + platformText += `\n\n${s.textForVk || 'Мой канал в Telegram'}: ${s.telegramLink}`; + } + } + + const formData = new FormData(); + formData.append('action', 'multi_post'); + formData.append('platforms', JSON.stringify([platform])); + formData.append('text', platformText); + formData.append('photos', JSON.stringify(editPhotos)); + formData.append('uploaded_files', JSON.stringify([])); + formData.append('parse_mode', 'HTML'); + + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + if (data.results) Object.assign(results, data.results); + } + + // Check results + const allSuccess = Object.values(results).every(r => r.success); + const anySuccess = Object.values(results).some(r => r.success); + + if (anySuccess) { + // Delete the scheduled post + const delForm = new FormData(); + delForm.append('action', 'delete_scheduled_post'); + delForm.append('id', postId); + await fetch('api.php', { method: 'POST', body: delForm }); + + showNotification(allSuccess ? 'Опубликовано!' : 'Частично опубликовано', allSuccess ? 'success' : 'warning'); + loadScheduledPosts(); + } else { + const errors = Object.entries(results).map(([p, r]) => `${p}: ${r.error}`).join(', '); + showNotification('Ошибка: ' + errors, 'error'); + pubBtn.disabled = false; + pubBtn.textContent = '🚀 Опубликовать сейчас'; + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + pubBtn.disabled = false; + pubBtn.textContent = '🚀 Опубликовать сейчас'; + } + }); + + // Scroll to the card + card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + // ============ PUBLISHED POSTS ARCHIVE ============ + + async function loadPublishedPosts() { + const list = document.getElementById('published-posts-list'); + if (!list) return; + + try { + const formData = new FormData(); + formData.append('action', 'get_published_posts'); + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.posts && data.posts.length > 0) { + list.innerHTML = data.posts.map(post => { + const publishedDate = post.published_at || post.scheduled_time; + const dateStr = new Date(publishedDate).toLocaleString('ru-RU', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }); + + const platforms = (post.platforms || []).map(p => { + const type = p.type || p; + return type === 'telegram' ? 'TG' : type === 'vk' ? 'VK' : type; + }).join(' · '); + + // Check results for success/error + const results = post.results || {}; + let statusIcons = ''; + Object.entries(results).forEach(([platform, result]) => { + const icon = result.success ? '✓' : '✗'; + const platformName = platform === 'telegram' ? 'TG' : platform === 'vk' ? 'VK' : platform; + statusIcons += `${platformName} ${icon} `; + }); + + // Collect all photo URLs for preview + const allPhotos = [ + ...(post.photos || []), + ...(post.uploaded_files || []).map(f => f.url || f) + ]; + + // Generate photo preview HTML (show up to 3 photos) + let photosPreviewHtml = ''; + if (allPhotos.length > 0) { + const previewPhotos = allPhotos.slice(0, 3); + photosPreviewHtml = ` +${escapeHtml(post.text.substring(0, 100))}${post.text.length > 100 ? '...' : ''}
` : ''} +Нет опубликованных постов
'; + } + } catch (error) { + list.innerHTML = 'Ошибка загрузки
'; + } + } + + // Refresh archive button + document.getElementById('btn-refresh-archive')?.addEventListener('click', loadPublishedPosts); + + // Load scheduled and published posts on initial page load + loadScheduledPosts(); + loadPublishedPosts(); + + // Create/Update scheduled post + document.getElementById('btn-create-scheduled')?.addEventListener('click', async () => { + const text = document.getElementById('scheduled-text')?.value || ''; + const datetime = document.getElementById('scheduled-datetime')?.value || ''; + const tgChecked = document.getElementById('scheduled-chk-telegram')?.checked; + const vkChecked = document.getElementById('scheduled-chk-vk')?.checked; + + if (!datetime) { + showNotification('Укажите дату и время публикации', 'error'); + return; + } + + const platforms = []; + if (tgChecked) platforms.push({ type: 'telegram' }); + if (vkChecked) platforms.push({ type: 'vk' }); + + if (platforms.length === 0) { + showNotification('Выберите хотя бы одну платформу', 'error'); + return; + } + + // Get photos from gallery selection + const photoUrls = state.selectedPhotos.map(p => p.urls.large || p.urls.original || p.urls.medium640); + + const formData = new FormData(); + formData.append('action', scheduledState.editingPostId ? 'update_scheduled_post' : 'create_scheduled_post'); + if (scheduledState.editingPostId) { + formData.append('id', scheduledState.editingPostId); + } + formData.append('text', text); + formData.append('tags', JSON.stringify([])); // TODO: add tags support + formData.append('photos', JSON.stringify(photoUrls)); + formData.append('uploaded_files', JSON.stringify(scheduledState.uploadedFiles.filter(f => f.url))); + formData.append('platforms', JSON.stringify(platforms)); + formData.append('scheduled_time', datetime.replace('T', ' ')); + + try { + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.error) { + showNotification('Ошибка: ' + data.error, 'error'); + } else { + showNotification(scheduledState.editingPostId ? 'Пост обновлён' : 'Пост запланирован!', 'success'); + + // Reset form + document.getElementById('scheduled-text').value = ''; + document.getElementById('scheduled-datetime').value = ''; + scheduledState.editingPostId = null; + scheduledState.uploadedFiles = []; + document.getElementById('scheduled-uploaded-preview').innerHTML = ''; + + const btn = document.getElementById('btn-create-scheduled'); + if (btn) btn.textContent = '📅 Запланировать публикацию'; + + loadScheduledPosts(); + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + } + }); + + // File upload for scheduled posts + document.getElementById('btn-scheduled-upload')?.addEventListener('click', () => { + document.getElementById('scheduled-file-upload')?.click(); + }); + + document.getElementById('scheduled-file-upload')?.addEventListener('change', async (e) => { + const files = Array.from(e.target.files); + if (!files.length) return; + + for (const file of files) { + if (file.size > 50 * 1024 * 1024) { + showNotification(`Файл ${file.name} слишком большой`, 'error'); + continue; + } + + const formData = new FormData(); + formData.append('action', 'upload_file'); + formData.append('file', file); + + try { + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.error) { + showNotification(`Ошибка: ${data.error}`, 'error'); + } else { + scheduledState.uploadedFiles.push({ + url: data.url, + type: data.type, + name: file.name + }); + renderScheduledUploads(); + } + } catch (error) { + showNotification('Ошибка загрузки', 'error'); + } + } + e.target.value = ''; + }); + + function renderScheduledUploads() { + const preview = document.getElementById('scheduled-uploaded-preview'); + if (!preview) return; + + preview.innerHTML = scheduledState.uploadedFiles.map((file, idx) => ` +Сначала выберите фото в Галерее, затем вернитесь сюда
'; + } else { + preview.innerHTML = state.selectedPhotos.map(photo => ` +Загрузка альбомов...
'; + + try { + const response = await fetch('widget_api.php?action=get_albums'); + const data = await response.json(); + + if (data.success && data.albums) { + renderWidgetAlbums(data.albums); + } else { + widgetAlbumsList.innerHTML = 'Ошибка загрузки альбомов
'; + } + } catch (error) { + widgetAlbumsList.innerHTML = 'Ошибка: ' + error.message + '
'; + } + } + + // Render widget albums selection + function renderWidgetAlbums(albums) { + if (!widgetAlbumsList) return; + + if (albums.length === 0) { + widgetAlbumsList.innerHTML = 'Нет доступных альбомов
'; + return; + } + + widgetAlbumsList.innerHTML = albums.map(album => { + const isSelected = widgetSelectedAlbums.includes(album.id); + const title = album.title?._content || album.title || 'Без названия'; + const count = album.photos || 0; + const thumb = album.primary_photo_extras?.url_m || + album.primary_photo_extras?.url_s || + album.primary_photo_extras?.url_sq || ''; + return ` + + `; + }).join(''); + + // Add event listeners + widgetAlbumsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + checkbox.addEventListener('change', function() { + const albumId = this.value; + const label = this.closest('.widget-album-item'); + if (this.checked) { + if (!widgetSelectedAlbums.includes(albumId)) { + widgetSelectedAlbums.push(albumId); + } + label.classList.add('selected'); + } else { + widgetSelectedAlbums = widgetSelectedAlbums.filter(id => id !== albumId); + label.classList.remove('selected'); + } + }); + }); + } + + // Save widget settings + async function saveWidgetSettings() { + const settings = { + enabled: widgetEnabled ? widgetEnabled.checked : true, + albums: widgetSelectedAlbums, + max_photos: widgetMaxPhotos ? parseInt(widgetMaxPhotos.value) : 30, + cache_time: widgetCacheTime ? parseInt(widgetCacheTime.value) : 3600 + }; + + try { + const response = await fetch('widget_api.php?action=save_settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + + const data = await response.json(); + if (data.success) { + if (widgetSaveStatus) { + widgetSaveStatus.textContent = '✓ Сохранено'; + widgetSaveStatus.className = 'save-status success'; + setTimeout(() => { + widgetSaveStatus.textContent = ''; + }, 3000); + } + showNotification('Настройки виджета сохранены', 'success'); + } else { + showNotification(data.error || 'Ошибка сохранения', 'error'); + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + } + } + + // Widget event listeners + if (btnLoadWidgetAlbums) { + btnLoadWidgetAlbums.addEventListener('click', loadWidgetAlbums); + } + + if (btnSaveWidgetSettings) { + btnSaveWidgetSettings.addEventListener('click', saveWidgetSettings); + } + + // Load widget settings on tab switch + document.querySelectorAll('.nav-btn[data-tab="widget"]').forEach(btn => { + btn.addEventListener('click', () => { + loadWidgetSettings(); + }); + }); + + // Initial load + loadTelegramStatus(); + loadVKStatus(); + loadFlickrOAuthStatus(); + updateScheduledCount(); // Load scheduled posts count badge + + // Restore selected photos and uploaded files UI if any were saved + if (state.selectedPhotos.length > 0 || state.uploadedFiles.length > 0) { + updateSelectionUI(); + updatePostingPreview(); + if (state.selectedPhotos.length > 0) { + console.log('Restored ' + state.selectedPhotos.length + ' Flickr photos from previous session'); + } + if (state.uploadedFiles.length > 0) { + console.log('Restored ' + state.uploadedFiles.length + ' uploaded files from previous session'); + } + } + + // Auto-load albums on page init + if (albumsGrid) { + const cachedAlbums = getAlbumCache(); + if (cachedAlbums && cachedAlbums.length > 0) { + // Use cache first for instant display + console.log('Loading cached albums:', cachedAlbums.length); + window._cachedAlbums = cachedAlbums; + renderAlbumsGrid(cachedAlbums); + // Silently refresh in background + setTimeout(() => refreshAlbumsSilently(), 2000); + } else { + // No cache or empty - load from API + console.log('No cache, loading albums from API...'); + loadAlbums(false); + } + } +}); diff --git a/setup.php b/setup.php new file mode 100644 index 0000000..06c668b --- /dev/null +++ b/setup.php @@ -0,0 +1,113 @@ +hasUsers()) { + header('Location: login.php'); + exit; +} + +$error = ''; +$success = false; + +// Handle setup form submission +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + $confirmPassword = $_POST['confirm_password'] ?? ''; + + // Validation + if (empty($username)) { + $error = 'Имя пользователя обязательно'; + } elseif (strlen($username) < 3) { + $error = 'Имя пользователя должно быть не менее 3 символов'; + } elseif (strlen($password) < 8) { + $error = 'Пароль должен быть не менее 8 символов'; + } elseif ($password !== $confirmPassword) { + $error = 'Пароли не совпадают'; + } else { + // Create user + if ($auth->createUser($username, $password)) { + $success = true; + } else { + $error = 'Не удалось создать пользователя'; + } + } +} + +// CSRF token +$csrfToken = bin2hex(random_bytes(32)); +$_SESSION['csrf_token'] = $csrfToken; +?> + + + + + +Создайте учётную запись администратора
+ + +HTTP Status: $httpCode
"; +echo "Content-Type: $contentType
"; +echo "Content Size: " . strlen($content) . " bytes
"; + +if ($error) { + echo "cURL Error: $error
"; +} + +// Check magic bytes +$magicBytes = substr($content, 0, 16); +echo "First 16 bytes (hex): " . bin2hex($magicBytes) . "
Is JPEG: " . ($isJpeg ? 'YES' : 'NO') . "
"; +echo "Is PNG: " . ($isPng ? 'YES' : 'NO') . "
"; + +if ($httpCode === 200 && ($isJpeg || $isPng)) { + echo "Image verified: {$imgInfo[0]}x{$imgInfo[1]} - {$imgInfo['mime']}
"; + echo "Test file saved to: $testFile
"; + } else { + echo "getimagesize() failed on saved file!
"; + } + + // Show preview + echo "Response content:
" . htmlspecialchars($content) . ""; + } +} + +// Handle download action +if (($_GET['action'] ?? '') === 'download') { + $url = $_GET['url'] ?? ''; + if (!$url) die('No URL'); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => 'Mozilla/5.0', + CURLOPT_HTTPHEADER => ['Accept: image/*', 'Referer: https://www.flickr.com/'], + ]); + $content = curl_exec($ch); + curl_close($ch); + + // Clear ALL output + while (ob_get_level()) ob_end_clean(); + + header('Content-Type: image/jpeg'); + header('Content-Length: ' . strlen($content)); + header('Content-Disposition: attachment; filename="test_photo.jpg"'); + echo $content; + exit; +} diff --git a/vh-flickr-mosaic.zip b/vh-flickr-mosaic.zip new file mode 100644 index 0000000..97c9f55 Binary files /dev/null and b/vh-flickr-mosaic.zip differ diff --git a/widget_api.php b/widget_api.php new file mode 100644 index 0000000..27c30f1 --- /dev/null +++ b/widget_api.php @@ -0,0 +1,246 @@ + 'Configuration not found']); + exit; +} +$config = require $configFile; + +// Autoload classes +spl_autoload_register(function ($class) { + $file = __DIR__ . '/classes/' . $class . '.php'; + if (file_exists($file)) { + require_once $file; + } +}); + +// Widget settings file +$widgetSettingsFile = __DIR__ . '/data/widget_settings.json'; + +/** + * Get widget settings + */ +function getWidgetSettings($file) { + if (file_exists($file)) { + return json_decode(file_get_contents($file), true) ?: []; + } + return [ + 'enabled' => true, + 'albums' => [], + 'max_photos' => 30, + 'cache_time' => 3600, // 1 hour + ]; +} + +/** + * Create FlickrAPI instance + */ +function createFlickrAPI($config) { + $flickr = new FlickrAPI( + $config['flickr']['api_key'], + $config['flickr']['api_secret'] ?? '', + $config['flickr_user_id'] ?? '' + ); + return $flickr; +} + +$action = $_GET['action'] ?? ''; + +try { + switch ($action) { + + case 'get_photos': + // Public endpoint - returns photos for widget + if (empty($config['flickr']['api_key'])) { + echo json_encode(['error' => 'Flickr not configured']); + exit; + } + + $settings = getWidgetSettings($widgetSettingsFile); + + if (!$settings['enabled']) { + echo json_encode(['error' => 'Widget disabled']); + exit; + } + + // Check cache + $cacheFile = __DIR__ . '/data/widget_cache.json'; + if (file_exists($cacheFile)) { + $cacheData = json_decode(file_get_contents($cacheFile), true); + if ($cacheData && isset($cacheData['timestamp'])) { + $cacheAge = time() - $cacheData['timestamp']; + if ($cacheAge < ($settings['cache_time'] ?? 3600)) { + echo json_encode([ + 'success' => true, + 'photos' => $cacheData['photos'], + 'cached' => true, + ]); + exit; + } + } + } + + $flickr = createFlickrAPI($config); + $allPhotos = []; + $maxPhotos = $settings['max_photos'] ?? 30; + $selectedAlbums = $settings['albums'] ?? []; + + if (empty($selectedAlbums)) { + // If no albums selected, get recent photos + $result = $flickr->getPhotos(1, $maxPhotos); + $allPhotos = $result['photos'] ?? []; + } else { + // Get photos from selected albums + $photosPerAlbum = max(5, ceil($maxPhotos / count($selectedAlbums))); + + foreach ($selectedAlbums as $albumId) { + try { + $result = $flickr->getPhotosetPhotos($albumId, 1, $photosPerAlbum); + if (!empty($result['photos'])) { + $allPhotos = array_merge($allPhotos, $result['photos']); + } + } catch (Exception $e) { + // Skip failed album + continue; + } + } + + // Shuffle and limit + shuffle($allPhotos); + $allPhotos = array_slice($allPhotos, 0, $maxPhotos); + } + + // Simplify photo data for widget + $widgetPhotos = array_map(function($photo) { + $urls = $photo['urls'] ?? []; + return [ + 'id' => $photo['id'], + 'title' => $photo['title'] ?? '', + 'thumb' => $urls['small'] ?? $urls['thumbnail'] ?? $urls['square'] ?? '', + 'medium' => $urls['medium'] ?? $urls['medium640'] ?? $urls['small'] ?? '', + 'large' => $urls['large'] ?? $urls['large2048'] ?? $urls['medium'] ?? '', + 'page_url' => $photo['page_url'] ?? '', + ]; + }, $allPhotos); + + // Save to cache + $dataDir = __DIR__ . '/data'; + if (!is_dir($dataDir)) { + mkdir($dataDir, 0755, true); + } + file_put_contents($cacheFile, json_encode([ + 'timestamp' => time(), + 'photos' => $widgetPhotos, + ])); + + echo json_encode([ + 'success' => true, + 'photos' => $widgetPhotos, + 'cached' => false, + ]); + break; + + case 'get_albums': + // For admin - list available albums + session_start(); + $auth = new Auth(); + if (!$auth->isAuthenticated()) { + http_response_code(401); + echo json_encode(['error' => 'Not authenticated']); + exit; + } + + if (empty($config['flickr']['api_key'])) { + echo json_encode(['error' => 'Flickr not configured']); + exit; + } + + $flickr = createFlickrAPI($config); + $result = $flickr->getPhotosets(1, 100); + + echo json_encode([ + 'success' => true, + 'albums' => $result['albums'], + ]); + break; + + case 'get_settings': + // For admin - get widget settings + session_start(); + $auth = new Auth(); + if (!$auth->isAuthenticated()) { + http_response_code(401); + echo json_encode(['error' => 'Not authenticated']); + exit; + } + + $settings = getWidgetSettings($widgetSettingsFile); + echo json_encode([ + 'success' => true, + 'settings' => $settings, + ]); + break; + + case 'save_settings': + // For admin - save widget settings + session_start(); + $auth = new Auth(); + if (!$auth->isAuthenticated()) { + http_response_code(401); + echo json_encode(['error' => 'Not authenticated']); + exit; + } + + $input = json_decode(file_get_contents('php://input'), true); + + $settings = [ + 'enabled' => $input['enabled'] ?? true, + 'albums' => $input['albums'] ?? [], + 'max_photos' => (int)($input['max_photos'] ?? 30), + 'cache_time' => (int)($input['cache_time'] ?? 3600), + ]; + + $dataDir = __DIR__ . '/data'; + if (!is_dir($dataDir)) { + mkdir($dataDir, 0755, true); + } + + // Clear cache when settings change + $cacheFile = __DIR__ . '/data/widget_cache.json'; + if (file_exists($cacheFile)) { + unlink($cacheFile); + } + + if (file_put_contents($widgetSettingsFile, json_encode($settings, JSON_PRETTY_PRINT))) { + echo json_encode(['success' => true, 'message' => 'Settings saved']); + } else { + echo json_encode(['error' => 'Failed to save settings']); + } + break; + + default: + echo json_encode(['error' => 'Unknown action', 'available' => ['get_photos', 'get_albums', 'get_settings', 'save_settings']]); + } + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} diff --git a/wordpress-plugin/vh-flickr-mosaic/assets/css/mosaic.css b/wordpress-plugin/vh-flickr-mosaic/assets/css/mosaic.css new file mode 100644 index 0000000..d8ffcad --- /dev/null +++ b/wordpress-plugin/vh-flickr-mosaic/assets/css/mosaic.css @@ -0,0 +1,165 @@ +/** + * VH Flickr Mosaic - Styles + * Beautiful photo mosaic with fade animations + */ + +.vh-flickr-mosaic { + width: 100%; + overflow: hidden; + background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.03) 100%); + padding: 20px 0; + position: relative; +} + +.vh-mosaic-container { + display: grid; + gap: 8px; + padding: 0 20px; + justify-content: center; + max-width: 100%; + margin: 0 auto; +} + +.vh-mosaic-item { + position: relative; + overflow: hidden; + border-radius: 8px; + background: #f0f0f0; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.vh-mosaic-item:hover { + transform: scale(1.05); + box-shadow: 0 8px 25px rgba(0,0,0,0.15); + z-index: 10; +} + +.vh-mosaic-item a { + display: block; + width: 100%; + height: 100%; +} + +.vh-mosaic-item img { + width: 100%; + height: 100%; + object-fit: cover; + transition: opacity 0.8s ease-in-out; + display: block; +} + +/* Fade animation for image swap */ +.vh-mosaic-item img.vh-fading-out { + opacity: 0; +} + +.vh-mosaic-item img.vh-fading-in { + position: absolute; + top: 0; + left: 0; + opacity: 0; + animation: vhFadeIn 0.8s ease-in-out forwards; +} + +@keyframes vhFadeIn { + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +/* Loading state */ +.vh-mosaic-item.vh-loading { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: vhShimmer 1.5s infinite; +} + +@keyframes vhShimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Title overlay on hover */ +.vh-mosaic-item .vh-photo-title { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 8px 10px; + background: linear-gradient(transparent, rgba(0,0,0,0.7)); + color: white; + font-size: 12px; + opacity: 0; + transition: opacity 0.3s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.vh-mosaic-item:hover .vh-photo-title { + opacity: 1; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .vh-flickr-mosaic { + padding: 15px 0; + } + + .vh-mosaic-container { + gap: 6px; + padding: 0 10px; + } + + .vh-mosaic-item { + border-radius: 6px; + } +} + +@media (max-width: 480px) { + .vh-mosaic-container { + gap: 4px; + padding: 0 5px; + } + + .vh-mosaic-item { + border-radius: 4px; + } + + .vh-mosaic-item .vh-photo-title { + font-size: 10px; + padding: 5px 8px; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .vh-flickr-mosaic { + background: linear-gradient(180deg, transparent 0%, rgba(255,255,255,0.03) 100%); + } + + .vh-mosaic-item { + background: #2a2a2a; + } + + .vh-mosaic-item.vh-loading { + background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%); + background-size: 200% 100%; + } +} + +/* Reduce motion for accessibility */ +@media (prefers-reduced-motion: reduce) { + .vh-mosaic-item, + .vh-mosaic-item img { + transition: none; + } + + .vh-mosaic-item img.vh-fading-in { + animation: none; + opacity: 1; + } + + .vh-mosaic-item.vh-loading { + animation: none; + } +} diff --git a/wordpress-plugin/vh-flickr-mosaic/assets/js/mosaic.js b/wordpress-plugin/vh-flickr-mosaic/assets/js/mosaic.js new file mode 100644 index 0000000..e5761ac --- /dev/null +++ b/wordpress-plugin/vh-flickr-mosaic/assets/js/mosaic.js @@ -0,0 +1,235 @@ +/** + * VH Flickr Mosaic - JavaScript + * Beautiful photo mosaic with fade animations + */ + +(function() { + 'use strict'; + + class VHFlickrMosaic { + constructor(container) { + this.container = container; + this.mosaicEl = container.querySelector('.vh-mosaic-container'); + this.photos = []; + this.displayedPhotos = []; + this.rows = parseInt(container.dataset.rows) || vhMosaicConfig.rows || 2; + this.photoSize = parseInt(container.dataset.size) || vhMosaicConfig.photoSize || 150; + this.animationSpeed = parseFloat(container.dataset.speed) || vhMosaicConfig.animationSpeed || 5; + this.apiUrl = vhMosaicConfig.apiUrl; + this.animationInterval = null; + this.isVisible = false; + + this.init(); + } + + async init() { + // Set up intersection observer for lazy loading + this.setupVisibilityObserver(); + + // Load photos + await this.loadPhotos(); + + // Initial render + this.render(); + + // Start animation when visible + if (this.isVisible) { + this.startAnimation(); + } + } + + setupVisibilityObserver() { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + this.isVisible = entry.isIntersecting; + if (this.isVisible && this.photos.length > 0) { + this.startAnimation(); + } else { + this.stopAnimation(); + } + }); + }, { threshold: 0.1 }); + + observer.observe(this.container); + } + + async loadPhotos() { + if (!this.apiUrl) { + console.error('VH Flickr Mosaic: API URL not configured'); + return; + } + + try { + const response = await fetch(this.apiUrl); + const data = await response.json(); + + if (data.success && data.photos) { + this.photos = data.photos; + } else { + console.error('VH Flickr Mosaic: Failed to load photos', data.error); + } + } catch (error) { + console.error('VH Flickr Mosaic: API error', error); + } + } + + calculateGrid() { + const containerWidth = this.mosaicEl.offsetWidth || window.innerWidth; + const cols = Math.floor(containerWidth / (this.photoSize + 8)); // 8px gap + return { + cols: Math.max(cols, 3), + total: Math.max(cols, 3) * this.rows + }; + } + + render() { + if (this.photos.length === 0) { + this.mosaicEl.innerHTML = ''; + return; + } + + const { cols, total } = this.calculateGrid(); + + // Set grid columns + this.mosaicEl.style.gridTemplateColumns = `repeat(${cols}, ${this.photoSize}px)`; + + // Select random photos for display + this.displayedPhotos = this.getRandomPhotos(total); + + // Create HTML + this.mosaicEl.innerHTML = this.displayedPhotos.map((photo, index) => ` + + `).join(''); + } + + getRandomPhotos(count) { + const shuffled = [...this.photos].sort(() => Math.random() - 0.5); + return shuffled.slice(0, Math.min(count, shuffled.length)); + } + + startAnimation() { + if (this.animationInterval) return; + if (this.photos.length <= this.displayedPhotos.length) return; + + this.animationInterval = setInterval(() => { + this.swapRandomPhoto(); + }, this.animationSpeed * 1000); + } + + stopAnimation() { + if (this.animationInterval) { + clearInterval(this.animationInterval); + this.animationInterval = null; + } + } + + swapRandomPhoto() { + if (!this.isVisible || this.photos.length === 0) return; + + const items = this.mosaicEl.querySelectorAll('.vh-mosaic-item'); + if (items.length === 0) return; + + // Pick random item to swap + const randomIndex = Math.floor(Math.random() * items.length); + const item = items[randomIndex]; + + // Find a photo not currently displayed + const currentIds = this.displayedPhotos.map(p => p.id); + const availablePhotos = this.photos.filter(p => !currentIds.includes(p.id)); + + if (availablePhotos.length === 0) return; + + const newPhoto = availablePhotos[Math.floor(Math.random() * availablePhotos.length)]; + + // Animate the swap + this.animatePhotoSwap(item, newPhoto, randomIndex); + } + + animatePhotoSwap(item, newPhoto, index) { + const oldImg = item.querySelector('img'); + const link = item.querySelector('a'); + + if (!oldImg || !link) return; + + // Create new image + const newImg = document.createElement('img'); + newImg.src = newPhoto.medium || newPhoto.thumb; + newImg.alt = newPhoto.title || ''; + newImg.className = 'vh-fading-in'; + newImg.loading = 'lazy'; + + // Start fade out of old image + oldImg.classList.add('vh-fading-out'); + + // Add new image + link.appendChild(newImg); + + // Update link href + link.href = newPhoto.page_url || '#'; + + // Update title + let titleEl = item.querySelector('.vh-photo-title'); + if (newPhoto.title) { + if (titleEl) { + titleEl.textContent = newPhoto.title; + } else { + titleEl = document.createElement('span'); + titleEl.className = 'vh-photo-title'; + titleEl.textContent = newPhoto.title; + link.appendChild(titleEl); + } + } else if (titleEl) { + titleEl.remove(); + } + + // After animation, clean up + setTimeout(() => { + oldImg.remove(); + newImg.classList.remove('vh-fading-in'); + }, 800); + + // Update displayed photos array + this.displayedPhotos[index] = newPhoto; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + } + + // Initialize all mosaics on page + function initMosaics() { + document.querySelectorAll('.vh-flickr-mosaic').forEach(container => { + new VHFlickrMosaic(container); + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initMosaics); + } else { + initMosaics(); + } + + // Handle window resize + let resizeTimeout; + window.addEventListener('resize', () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + document.querySelectorAll('.vh-flickr-mosaic').forEach(container => { + const mosaic = container._vhMosaic; + if (mosaic) { + mosaic.render(); + } + }); + }, 250); + }); + +})(); diff --git a/wordpress-plugin/vh-flickr-mosaic/vh-flickr-mosaic.php b/wordpress-plugin/vh-flickr-mosaic/vh-flickr-mosaic.php new file mode 100644 index 0000000..8e9da4f --- /dev/null +++ b/wordpress-plugin/vh-flickr-mosaic/vh-flickr-mosaic.php @@ -0,0 +1,304 @@ +options = get_option('vh_flickr_mosaic_options', [ + 'api_url' => '', + 'position' => 'footer', + 'rows' => 2, + 'photo_size' => 150, + 'animation_speed' => 5, + 'enabled' => true, + ]); + + add_action('admin_menu', [$this, 'add_admin_menu']); + add_action('admin_init', [$this, 'settings_init']); + add_action('wp_enqueue_scripts', [$this, 'enqueue_scripts']); + add_action('wp_footer', [$this, 'render_mosaic']); + add_shortcode('flickr_mosaic', [$this, 'shortcode_mosaic']); + } + + public function add_admin_menu() { + add_options_page( + 'VH Flickr Mosaic', + 'VH Flickr Mosaic', + 'manage_options', + 'vh-flickr-mosaic', + [$this, 'options_page'] + ); + } + + public function settings_init() { + register_setting('vh_flickr_mosaic', 'vh_flickr_mosaic_options'); + + add_settings_section( + 'vh_flickr_mosaic_section', + __('Настройки мозаики', 'vh-flickr-mosaic'), + null, + 'vh-flickr-mosaic' + ); + + add_settings_field( + 'api_url', + __('API URL', 'vh-flickr-mosaic'), + [$this, 'api_url_render'], + 'vh-flickr-mosaic', + 'vh_flickr_mosaic_section' + ); + + add_settings_field( + 'enabled', + __('Включить', 'vh-flickr-mosaic'), + [$this, 'enabled_render'], + 'vh-flickr-mosaic', + 'vh_flickr_mosaic_section' + ); + + add_settings_field( + 'position', + __('Позиция', 'vh-flickr-mosaic'), + [$this, 'position_render'], + 'vh-flickr-mosaic', + 'vh_flickr_mosaic_section' + ); + + add_settings_field( + 'rows', + __('Количество рядов', 'vh-flickr-mosaic'), + [$this, 'rows_render'], + 'vh-flickr-mosaic', + 'vh_flickr_mosaic_section' + ); + + add_settings_field( + 'photo_size', + __('Размер фото (px)', 'vh-flickr-mosaic'), + [$this, 'photo_size_render'], + 'vh-flickr-mosaic', + 'vh_flickr_mosaic_section' + ); + + add_settings_field( + 'animation_speed', + __('Скорость анимации (сек)', 'vh-flickr-mosaic'), + [$this, 'animation_speed_render'], + 'vh-flickr-mosaic', + 'vh_flickr_mosaic_section' + ); + } + + public function api_url_render() { + ?> + ' + class='regular-text' placeholder='https://your-site.com/vh/widget_api.php?action=get_photos'> + + + + + + + ' + min='1' max='5' style='width: 60px;'> + + ' + min='80' max='300' style='width: 80px;'> + + ' + min='2' max='15' step='0.5' style='width: 80px;'> + + +
[flickr_mosaic]
+
+ [flickr_mosaic rows="3" size="120" speed="4"]
+
+