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 | `{title}` | Веб-сайты | +| `html_linked` | HTML (clickable) | `` | Кликабельные изображения | +| `html_figure` | HTML Figure | `
...
` | Семантическая разметка | +| `markdown` | Markdown | `![{title}]({url})` | GitHub, Reddit | +| `markdown_linked` | Markdown (clickable) | `[![...](...)]({original})` | Кликабельные в Markdown | +| `url_only` | URL only | `{url}` | Просто ссылка | +| `url_list` | URL list (comma) | `{url}, {url}...` | Списки через запятую | + +**Размеры изображений:** + +| Размер | Суффикс | Размер (px) | +|--------|---------|-------------| +| Square | `_s` | 75×75 | +| LargeSquare | `_q` | 150×150 | +| Thumbnail | `_t` | 100 | +| Small | `_m` | 240 | +| Small320 | `_n` | 320 | +| Medium | (нет) | 500 | +| Medium640 | `_z` | 640 | +| Medium800 | `_c` | 800 | +| Large | `_b` | 1024 | +| Large1600 | `_h` | 1600 | +| Large2048 | `_k` | 2048 | +| Original | `_o` | Оригинал | + +--- + +### 2. Flickr Gallery (Галерея Flickr) + +**Назначение:** Просмотр и выбор фотографий из аккаунта Flickr. + +**Возможности:** +- Просмотр всех фотографий пользователя +- Просмотр по альбомам (photosets) +- Поиск по названию/описанию +- Множественный выбор галочками +- Пагинация (50 фото на страницу) +- Превью в виде сетки + +**Требует:** Flickr API Key + User ID + +--- + +### 3. Telegram Post (Публикация в Telegram) + +**Назначение:** Отправка фотографий и текста в Telegram-каналы. + +**Возможности:** +- Отправка одной фотографии с подписью +- Отправка альбома (2-10 фото) +- Автоматическое разбиение больших альбомов (>10 фото) +- Выбор формата текста (HTML, Markdown, plain) +- Публикация в несколько каналов + +**Ограничения Telegram:** +- Максимум 10 фото в одном альбоме +- Подпись только у первого фото в альбоме +- Бот должен быть администратором канала + +--- + +### 4. Settings (Настройки) + +**Возможности:** +- Проверка статуса подключения к Flickr API +- Проверка статуса Telegram бота +- Управление списком каналов +- Смена пароля пользователя + +--- + +## Классы PHP + +### Auth.php — Система авторизации + +```php +class Auth { + // Создание пользователя + createUser($username, $password): bool + + // Смена пароля + changePassword($username, $oldPassword, $newPassword): bool + + // Вход в систему + login($username, $password, $ip): array + + // Запуск сессии + startSession($username, $token): void + + // Проверка авторизации + isAuthenticated($timeout = 3600): bool + + // Выход + logout(): void + + // Текущий пользователь + getCurrentUser(): ?string + + // Есть ли пользователи + hasUsers(): bool + + // IP клиента + static getClientIP(): string +} +``` + +**Безопасность:** +- Хеширование: Argon2ID (fallback на bcrypt) +- Защита от брутфорса: 5 попыток, блокировка 15 минут +- Таймаут сессии: 1 час +- Регенерация session ID при входе + +--- + +### FlickrAPI.php — Клиент Flickr API + +```php +class FlickrAPI { + __construct($apiKey, $apiSecret, $userId = '') + + // Установить User ID + setUserId($userId): void + + // Получить фотографии пользователя + getPhotos($page = 1, $perPage = 50): array + + // Получить альбомы + getPhotosets($page = 1, $perPage = 50): array + + // Фотографии из альбома + getPhotosetPhotos($photosetId, $page, $perPage): array + + // Информация о фото + getPhotoInfo($photoId): array + + // Доступные размеры фото + getPhotoSizes($photoId): array + + // Поиск фотографий + searchPhotos($query, $page, $perPage): array + + // Найти User ID по имени + findUserByUsername($username): string +} +``` + +--- + +### FlickrParser.php — Парсер ссылок + +```php +class FlickrParser { + // Парсинг одной ссылки + parse($url): ?array + + // Парсинг нескольких ссылок + parseMultiple($input): array + + // Построение URL изображения + buildImageUrl($photoInfo, $size = 'Large'): string + + // Доступные размеры + getAvailableSizes(): array +} +``` + +--- + +### FormatGenerator.php — Генератор форматов + +```php +class FormatGenerator { + // Добавить кастомный формат + addFormat($key, $name, $template, $separator, $description): void + + // Получить все форматы + getFormats(): array + + // Сгенерировать для одного изображения + generate($formatKey, $imageData): string + + // Сгенерировать для нескольких + generateMultiple($formatKey, $images): string + + // Сгенерировать все форматы сразу + generateAll($images): array +} +``` + +**Плейсхолдеры в шаблонах:** +- `{url}` — URL изображения выбранного размера +- `{original}` — URL оригинала +- `{title}` — Название фото +- `{description}` — Описание +- `{photo_id}` — ID фото +- `{width}`, `{height}` — Размеры + +--- + +### TelegramBot.php — Клиент Telegram + +```php +class TelegramBot { + __construct($botToken) + + // Отправить текст + sendMessage($chatId, $text, $parseMode, $disablePreview): array + + // Отправить фото + sendPhoto($chatId, $photo, $caption, $parseMode): array + + // Отправить альбом + sendMediaGroup($chatId, $photos, $caption, $parseMode): array + + // Умная публикация (авто-выбор метода) + post($chatId, $photos, $text, $parseMode): array + + // Публикация в несколько каналов + postToMultiple($chatIds, $photos, $text, $parseMode): array + + // Информация о боте + getMe(): array + + // Информация о чате + getChat($chatId): array + + // Проверка доступа к каналу + validateChannel($chatId): array + + // Экранирование HTML + static escapeHtml($text): string + + // Экранирование Markdown + static escapeMarkdown($text): string +} +``` + +--- + +## API Endpoints (api.php) + +| Action | Method | Параметры | Описание | +|--------|--------|-----------|----------| +| `convert` | POST | urls, size, format | Конвертация ссылок | +| `flickr_albums` | GET | — | Список альбомов | +| `flickr_photos` | GET | page, per_page, album_id, search | Список фото | +| `flickr_photo_sizes` | GET | photo_id | Размеры фото | +| `telegram_status` | GET | — | Статус бота | +| `telegram_channels` | GET | — | Список каналов | +| `telegram_post` | POST | channel_id, text, photos, parse_mode | Публикация | +| `change_password` | POST | current_password, new_password | Смена пароля | + +--- + +## Конфигурация (config.php) + +```php +return [ + 'flickr' => [ + 'api_key' => '', // API ключ Flickr + 'api_secret' => '', // API секрет + ], + 'flickr_user_id' => '', // ID пользователя (12345678@N00) + + 'telegram' => [ + 'bot_token' => '', // Токен бота от @BotFather + 'channels' => [ // Список каналов + ['id' => '@channel', 'name' => 'Название'], + ], + ], + + 'default_size' => 'Large', + + 'custom_formats' => [ // Кастомные форматы + // 'key' => ['name' => '', 'template' => ''], + ], +]; +``` + +--- + +## Безопасность + +### Защита файлов (.htaccess) +- Запрет листинга директорий +- Блокировка доступа к config.php, auth_config.php +- Блокировка прямого доступа к /classes/ + +### Защита авторизации +- Argon2ID/bcrypt хеширование +- Rate limiting (5 попыток / 15 минут блокировки) +- Session timeout (1 час) +- CSRF токены на формах +- Регенерация session ID + +### Входные данные +- htmlspecialchars() для вывода +- Валидация URL при парсинге +- JSON-encode для API ответов + +--- + +## Требования + +### Сервер +- PHP 7.2+ (рекомендуется 7.4+) +- Расширения: curl, json, bcmath (для base58) +- Apache с mod_rewrite + +### API ключи +- **Flickr:** https://www.flickr.com/services/apps/create/ +- **Telegram:** @BotFather → /newbot + +--- + +## Установка + +1. Загрузить файлы на хостинг +2. Скопировать `config.example.php` → `config.php` +3. Заполнить API ключи в config.php +4. Открыть сайт → setup.php → создать админа +5. Войти через login.php + +--- + +## Планируемые функции + +- [ ] Drag & drop загрузка изображений +- [ ] История конвертаций +- [ ] Избранные форматы +- [ ] Темная тема +- [ ] Экспорт/импорт настроек +- [ ] Поддержка других фотохостингов (Imgur, etc.) + +--- + +## Changelog + +### v1.0 (2026-01-07) +- Начальная реализация +- Конвертер ссылок Flickr +- Интеграция с Flickr API +- Интеграция с Telegram Bot API +- Система авторизации +- Адаптация под PHP 7.2 (reg.ru) diff --git a/api.php b/api.php new file mode 100644 index 0000000..e50cd0c --- /dev/null +++ b/api.php @@ -0,0 +1,949 @@ + '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; + } +}); + +/** + * Create FlickrAPI instance with OAuth if available + */ +function createFlickrAPI($config) { + $flickr = new FlickrAPI( + $config['flickr']['api_key'], + $config['flickr']['api_secret'] ?? '', + $config['flickr_user_id'] ?? '' + ); + + // Add OAuth if tokens exist + if (!empty($config['flickr']['api_secret'])) { + $oauth = new FlickrOAuth( + $config['flickr']['api_key'], + $config['flickr']['api_secret'] + ); + if ($oauth->isAuthorized()) { + $flickr->setOAuth($oauth); + } + } + + return $flickr; +} + +// Check authentication +$auth = new Auth(); +if (!$auth->isAuthenticated()) { + http_response_code(401); + echo json_encode(['error' => 'Not authenticated']); + exit; +} + +// Get action +$action = $_GET['action'] ?? $_POST['action'] ?? ''; + +try { + switch ($action) { + + // ============ CONVERTER ============ + + case 'convert': + $urls = $_POST['urls'] ?? ''; + $size = $_POST['size'] ?? 'Large'; + $format = $_POST['format'] ?? 'bbcode'; + + $parser = new FlickrParser(); + $generator = new FormatGenerator(); + + $parsed = $parser->parseMultiple($urls); + + if (empty($parsed)) { + echo json_encode(['error' => 'No valid Flickr URLs found']); + exit; + } + + $images = []; + $flickr = null; + + // Check if we need API to get full URLs + $needApi = false; + foreach ($parsed as $item) { + if ($item['type'] === 'page' || $item['type'] === 'short') { + $needApi = true; + break; + } + } + + if ($needApi && !empty($config['flickr']['api_key'])) { + $flickr = createFlickrAPI($config); + } + + foreach ($parsed as $item) { + $imageData = [ + 'photo_id' => $item['photo_id'], + 'title' => 'Image', + ]; + + if ($item['type'] === 'direct') { + // Direct URL - modify size suffix + $imageData['url'] = $parser->buildImageUrl($item, $size); + $imageData['original'] = $parser->buildImageUrl($item, 'Original'); + } elseif ($flickr) { + // Need to fetch from API + try { + $info = $flickr->getPhotoInfo($item['photo_id']); + $sizes = $flickr->getPhotoSizes($item['photo_id']); + + $imageData['title'] = $info['title']['_content'] ?? 'Image'; + + // Get requested size + $sizeMap = [ + 'Large' => 'Large', + 'Large1600' => 'Large 1600', + 'Large2048' => 'Large 2048', + 'Original' => 'Original', + 'Medium640' => 'Medium 640', + 'Medium' => 'Medium', + ]; + + $sizeName = $sizeMap[$size] ?? 'Large'; + $imageData['url'] = $sizes[$sizeName]['url'] ?? $sizes['Large']['url'] ?? ''; + $imageData['original'] = $sizes['Original']['url'] ?? $sizes['Large']['url'] ?? ''; + + } catch (Exception $e) { + $imageData['url'] = $item['original_url']; + $imageData['original'] = $item['original_url']; + } + } else { + // No API - use original URL + $imageData['url'] = $item['original_url']; + $imageData['original'] = $item['original_url']; + } + + $images[] = $imageData; + } + + $output = $generator->generateMultiple($format, $images); + + echo json_encode([ + 'success' => true, + 'output' => $output, + 'count' => count($images), + ]); + break; + + // ============ FLICKR GALLERY ============ + + case 'flickr_albums': + if (empty($config['flickr']['api_key'])) { + echo json_encode(['error' => 'Flickr API not configured']); + exit; + } + + $flickr = createFlickrAPI($config); + $page = (int)($_GET['page'] ?? 1); + $perPage = (int)($_GET['per_page'] ?? 50); + $result = $flickr->getPhotosets($page, $perPage); + + echo json_encode([ + 'success' => true, + 'albums' => $result['albums'], + 'page' => $result['page'], + 'pages' => $result['pages'], + 'total' => $result['total'], + ]); + break; + + case 'flickr_photos': + if (empty($config['flickr']['api_key'])) { + echo json_encode(['error' => 'Flickr API not configured']); + exit; + } + + $flickr = createFlickrAPI($config); + $page = (int)($_GET['page'] ?? 1); + $perPage = (int)($_GET['per_page'] ?? 50); + $albumId = $_GET['album_id'] ?? ''; + $search = $_GET['search'] ?? ''; + + if ($search) { + $result = $flickr->searchPhotos($search, $page, $perPage); + } elseif ($albumId) { + $result = $flickr->getPhotosetPhotos($albumId, $page, $perPage); + } else { + $result = $flickr->getPhotos($page, $perPage); + } + + echo json_encode([ + 'success' => true, + 'photos' => $result['photos'], + 'pagination' => [ + 'page' => $result['page'], + 'pages' => $result['pages'], + 'total' => $result['total'], + ], + ]); + break; + + case 'flickr_photo_sizes': + if (empty($config['flickr']['api_key'])) { + echo json_encode(['error' => 'Flickr API not configured']); + exit; + } + + $photoId = $_GET['photo_id'] ?? ''; + if (!$photoId) { + echo json_encode(['error' => 'Photo ID required']); + exit; + } + + $flickr = createFlickrAPI($config); + $sizes = $flickr->getPhotoSizes($photoId); + + echo json_encode([ + 'success' => true, + 'sizes' => $sizes, + ]); + break; + + case 'flickr_oauth_status': + if (empty($config['flickr']['api_secret'])) { + echo json_encode([ + 'success' => true, + 'authorized' => false, + 'message' => 'API secret not configured', + ]); + exit; + } + + $oauth = new FlickrOAuth( + $config['flickr']['api_key'], + $config['flickr']['api_secret'] + ); + + echo json_encode([ + 'success' => true, + 'authorized' => $oauth->isAuthorized(), + 'auth_url' => 'flickr_auth.php', + ]); + break; + + case 'flickr_original_url': + if (empty($config['flickr']['api_key'])) { + echo json_encode(['error' => 'Flickr API not configured']); + exit; + } + + $photoId = $_GET['photo_id'] ?? ''; + if (!$photoId) { + echo json_encode(['error' => 'Photo ID required']); + exit; + } + + $flickr = createFlickrAPI($config); + $originalUrl = $flickr->getOriginalUrl($photoId); + + echo json_encode([ + 'success' => true, + 'original_url' => $originalUrl, + 'has_oauth' => $flickr->hasOAuth(), + ]); + break; + + // ============ TELEGRAM ============ + + case 'telegram_status': + if (empty($config['telegram']['bot_token'])) { + echo json_encode([ + 'success' => true, + 'connected' => false, + 'message' => 'Bot token not configured', + ]); + exit; + } + + $telegram = new TelegramBot($config['telegram']['bot_token']); + + try { + $me = $telegram->getMe(); + echo json_encode([ + 'success' => true, + 'connected' => true, + 'bot_name' => $me['first_name'] ?? '', + 'bot_username' => $me['username'] ?? '', + ]); + } catch (Exception $e) { + echo json_encode([ + 'success' => true, + 'connected' => false, + 'message' => $e->getMessage(), + ]); + } + break; + + case 'telegram_channels': + echo json_encode([ + 'success' => true, + 'channels' => $config['telegram']['channels'] ?? [], + ]); + break; + + case 'telegram_post': + if (empty($config['telegram']['bot_token'])) { + echo json_encode(['error' => 'Telegram bot not configured']); + exit; + } + + $channelId = $_POST['channel_id'] ?? ''; + $text = $_POST['text'] ?? ''; + $photos = json_decode($_POST['photos'] ?? '[]', true); + $parseMode = $_POST['parse_mode'] ?? 'HTML'; + + if (!$channelId) { + echo json_encode(['error' => 'Channel ID required']); + exit; + } + + $telegram = new TelegramBot($config['telegram']['bot_token']); + + $result = $telegram->post($channelId, $photos, $text, $parseMode); + + echo json_encode([ + 'success' => true, + 'result' => $result, + ]); + break; + + // ============ VK ============ + + case 'vk_status': + if (empty($config['vk']['access_token'])) { + echo json_encode([ + 'success' => true, + 'connected' => false, + 'message' => 'VK access token not configured', + ]); + exit; + } + + $vk = new VKAPI($config['vk']['access_token']); + + try { + $validation = $vk->validateToken(); + if ($validation['valid']) { + echo json_encode([ + 'success' => true, + 'connected' => true, + 'user_name' => $validation['user_name'] ?? '', + 'user_id' => $validation['user_id'] ?? '', + 'type' => $validation['type'] ?? 'user', + 'screen_name' => $validation['screen_name'] ?? '', + ]); + } else { + echo json_encode([ + 'success' => true, + 'connected' => false, + 'message' => $validation['error'] ?? 'Invalid token', + ]); + } + } catch (Exception $e) { + echo json_encode([ + 'success' => true, + 'connected' => false, + 'message' => $e->getMessage(), + ]); + } + break; + + case 'vk_groups': + if (empty($config['vk']['access_token'])) { + echo json_encode([ + 'success' => true, + 'groups' => [], + ]); + exit; + } + + $vk = new VKAPI($config['vk']['access_token']); + + // First check if this is a community token + try { + $validation = $vk->validateToken(); + if ($validation['valid'] && ($validation['type'] ?? '') === 'community') { + // Community token - return the community itself + echo json_encode([ + 'success' => true, + 'groups' => [[ + 'id' => $validation['user_id'], + 'name' => $validation['user_name'] ?? 'Сообщество', + 'screen_name' => $validation['screen_name'] ?? '', + ]], + 'type' => 'community', + ]); + exit; + } + } catch (Exception $e) { + // Continue to try groups.get + } + + try { + $groups = $vk->getGroups(); + echo json_encode([ + 'success' => true, + 'groups' => $groups, + ]); + } catch (Exception $e) { + echo json_encode([ + 'success' => true, + 'groups' => [], + 'error' => $e->getMessage(), + ]); + } + break; + + case 'vk_post': + if (empty($config['vk']['access_token'])) { + echo json_encode(['error' => 'VK not configured']); + exit; + } + + $groupId = $_POST['group_id'] ?? ''; + $text = $_POST['text'] ?? ''; + $photos = json_decode($_POST['photos'] ?? '[]', true); + + if (!$groupId) { + echo json_encode(['error' => 'Group ID required']); + exit; + } + + $vk = new VKAPI($config['vk']['access_token']); + + $result = $vk->post($groupId, $photos, $text); + + echo json_encode([ + 'success' => true, + 'result' => $result, + ]); + break; + + // ============ FILE UPLOAD ============ + + case 'upload_file': + if (empty($_FILES['file'])) { + echo json_encode(['error' => 'No file uploaded']); + exit; + } + + $file = $_FILES['file']; + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm']; + + if (!in_array($file['type'], $allowedTypes)) { + echo json_encode(['error' => 'Неподдерживаемый тип файла: ' . $file['type']]); + exit; + } + + if ($file['size'] > 50 * 1024 * 1024) { + echo json_encode(['error' => 'Файл слишком большой (макс 50MB)']); + exit; + } + + // Create uploads directory + $uploadsDir = __DIR__ . '/uploads'; + if (!is_dir($uploadsDir)) { + mkdir($uploadsDir, 0755, true); + } + + // Generate unique filename + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $filename = uniqid('upload_') . '_' . time() . '.' . $ext; + $filepath = $uploadsDir . '/' . $filename; + + if (move_uploaded_file($file['tmp_name'], $filepath)) { + // Get the URL + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $path = dirname($_SERVER['REQUEST_URI']); + $url = $protocol . '://' . $host . $path . '/uploads/' . $filename; + + echo json_encode([ + 'success' => true, + 'url' => $url, + 'filename' => $filename, + 'type' => $file['type'], + 'size' => $file['size'] + ]); + } else { + echo json_encode(['error' => 'Не удалось сохранить файл']); + } + break; + + // ============ MULTI-PLATFORM POSTING ============ + + case 'multi_post': + $platforms = json_decode($_POST['platforms'] ?? '[]', true); + $text = $_POST['text'] ?? ''; + $photos = json_decode($_POST['photos'] ?? '[]', true); + $uploadedFiles = json_decode($_POST['uploaded_files'] ?? '[]', true); + $parseMode = $_POST['parse_mode'] ?? 'HTML'; + + // Merge Flickr photos and uploaded files + if (!empty($uploadedFiles)) { + foreach ($uploadedFiles as $uploadedFile) { + if (!empty($uploadedFile['url'])) { + $photos[] = $uploadedFile['url']; + } + } + } + + if (empty($platforms)) { + echo json_encode(['error' => 'No platforms selected']); + exit; + } + + $results = []; + + foreach ($platforms as $platform) { + $type = $platform['type'] ?? ''; + $target = $platform['target'] ?? ''; + + switch ($type) { + case 'telegram': + if (empty($config['telegram']['bot_token'])) { + $results['telegram'] = [ + 'success' => false, + 'error' => 'Telegram not configured', + ]; + break; + } + + try { + $telegram = new TelegramBot($config['telegram']['bot_token']); + $result = $telegram->post($target, $photos, $text, $parseMode); + $results['telegram'] = [ + 'success' => true, + 'result' => $result, + ]; + } catch (Exception $e) { + $results['telegram'] = [ + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + break; + + case 'vk': + if (empty($config['vk']['access_token'])) { + $results['vk'] = [ + 'success' => false, + 'error' => 'VK not configured', + ]; + break; + } + + try { + $vk = new VKAPI($config['vk']['access_token']); + // VK uses plain text, remove HTML/Markdown formatting + $vkText = strip_tags($text); + $result = $vk->post($target, $photos, $vkText); + $vkResult = [ + 'success' => true, + 'result' => $result, + ]; + // Include warning if photos were posted as links + if (isset($result['warning'])) { + $vkResult['warning'] = $result['warning']; + } + $results['vk'] = $vkResult; + } catch (Exception $e) { + $results['vk'] = [ + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + break; + + case 'instagram': + // Instagram requires Facebook Business API + $results['instagram'] = [ + 'success' => false, + 'error' => 'Instagram posting requires Facebook Business API setup', + ]; + break; + + default: + $results[$type] = [ + 'success' => false, + 'error' => 'Unknown platform', + ]; + } + } + + echo json_encode([ + 'success' => true, + 'results' => $results, + ]); + break; + + // ============ TAG PRESETS ============ + + case 'get_presets': + $presetsFile = __DIR__ . '/data/tag_presets.json'; + if (file_exists($presetsFile)) { + $presets = json_decode(file_get_contents($presetsFile), true); + echo json_encode(['success' => true, 'presets' => $presets ?: []]); + } else { + // Return default presets + $defaultPresets = [ + ['id' => 1, 'name' => 'BJD', 'tags' => ['bjd', 'doll', 'куклы']], + ['id' => 2, 'name' => 'Фото', 'tags' => ['фото', 'photo', 'photography']], + ['id' => 3, 'name' => 'Арт', 'tags' => ['art', 'artwork', 'творчество']], + ['id' => 4, 'name' => 'Handmade', 'tags' => ['handmade', 'ручнаяработа']], + ['id' => 5, 'name' => 'Faceup', 'tags' => ['faceup', 'мейк']], + ['id' => 6, 'name' => 'Outfit', 'tags' => ['outfit', 'одежда']], + ]; + echo json_encode(['success' => true, 'presets' => $defaultPresets]); + } + break; + + case 'save_presets': + $presetsFile = __DIR__ . '/data/tag_presets.json'; + $input = json_decode(file_get_contents('php://input'), true); + $presets = $input['presets'] ?? []; + + // Validate presets structure + if (!is_array($presets)) { + echo json_encode(['error' => 'Invalid presets format']); + exit; + } + + // Ensure data directory exists + $dataDir = __DIR__ . '/data'; + if (!is_dir($dataDir)) { + mkdir($dataDir, 0755, true); + } + + // Save presets + if (file_put_contents($presetsFile, json_encode($presets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) { + echo json_encode(['success' => true, 'message' => 'Presets saved']); + } else { + echo json_encode(['error' => 'Failed to save presets']); + } + break; + + // ============ SCHEDULED POSTS ============ + + case 'get_scheduled_posts': + $scheduledFile = __DIR__ . '/data/scheduled_posts.json'; + if (file_exists($scheduledFile)) { + $posts = json_decode(file_get_contents($scheduledFile), true) ?: []; + // Sort by scheduled time + usort($posts, function($a, $b) { + return strtotime($a['scheduled_time']) - strtotime($b['scheduled_time']); + }); + echo json_encode(['success' => true, 'posts' => $posts]); + } else { + echo json_encode(['success' => true, 'posts' => []]); + } + break; + + case 'create_scheduled_post': + $scheduledFile = __DIR__ . '/data/scheduled_posts.json'; + $dataDir = __DIR__ . '/data'; + + if (!is_dir($dataDir)) { + mkdir($dataDir, 0755, true); + } + + $posts = file_exists($scheduledFile) ? json_decode(file_get_contents($scheduledFile), true) ?: [] : []; + + $newPost = [ + 'id' => uniqid('sched_'), + 'text' => $_POST['text'] ?? '', + 'tags' => json_decode($_POST['tags'] ?? '[]', true), + 'photos' => json_decode($_POST['photos'] ?? '[]', true), + 'uploaded_files' => json_decode($_POST['uploaded_files'] ?? '[]', true), + 'platforms' => json_decode($_POST['platforms'] ?? '[]', true), + 'scheduled_time' => $_POST['scheduled_time'] ?? '', + 'cross_promo' => ($_POST['cross_promo'] ?? '0') === '1', + 'created_at' => date('Y-m-d H:i:s'), + 'status' => 'pending' + ]; + + if (empty($newPost['scheduled_time'])) { + echo json_encode(['error' => 'Укажите дату и время публикации']); + exit; + } + + if (strtotime($newPost['scheduled_time']) < time()) { + echo json_encode(['error' => 'Нельзя запланировать на прошедшее время']); + exit; + } + + $posts[] = $newPost; + + if (file_put_contents($scheduledFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) { + echo json_encode(['success' => true, 'post' => $newPost]); + } else { + echo json_encode(['error' => 'Не удалось сохранить']); + } + break; + + case 'update_scheduled_post': + $scheduledFile = __DIR__ . '/data/scheduled_posts.json'; + $postId = $_POST['id'] ?? ''; + + if (!$postId) { + echo json_encode(['error' => 'ID поста не указан']); + exit; + } + + $posts = file_exists($scheduledFile) ? json_decode(file_get_contents($scheduledFile), true) ?: [] : []; + + $found = false; + foreach ($posts as &$post) { + if ($post['id'] === $postId && $post['status'] === 'pending') { + $post['text'] = $_POST['text'] ?? $post['text']; + $post['tags'] = isset($_POST['tags']) ? json_decode($_POST['tags'], true) : $post['tags']; + $post['photos'] = isset($_POST['photos']) ? json_decode($_POST['photos'], true) : $post['photos']; + $post['uploaded_files'] = isset($_POST['uploaded_files']) ? json_decode($_POST['uploaded_files'], true) : $post['uploaded_files']; + $post['platforms'] = isset($_POST['platforms']) ? json_decode($_POST['platforms'], true) : $post['platforms']; + $post['scheduled_time'] = $_POST['scheduled_time'] ?? $post['scheduled_time']; + $post['cross_promo'] = isset($_POST['cross_promo']) ? ($_POST['cross_promo'] === '1') : ($post['cross_promo'] ?? false); + $post['updated_at'] = date('Y-m-d H:i:s'); + $found = true; + break; + } + } + + if (!$found) { + echo json_encode(['error' => 'Пост не найден или уже опубликован']); + exit; + } + + if (file_put_contents($scheduledFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) { + echo json_encode(['success' => true, 'message' => 'Пост обновлён']); + } else { + echo json_encode(['error' => 'Не удалось сохранить']); + } + break; + + case 'delete_scheduled_post': + $scheduledFile = __DIR__ . '/data/scheduled_posts.json'; + $postId = $_POST['id'] ?? ''; + + if (!$postId) { + echo json_encode(['error' => 'ID поста не указан']); + exit; + } + + $posts = file_exists($scheduledFile) ? json_decode(file_get_contents($scheduledFile), true) ?: [] : []; + $initialCount = count($posts); + + $posts = array_filter($posts, function($post) use ($postId) { + return $post['id'] !== $postId || $post['status'] !== 'pending'; + }); + + if (count($posts) === $initialCount) { + echo json_encode(['error' => 'Пост не найден или уже опубликован']); + exit; + } + + $posts = array_values($posts); // Re-index + + if (file_put_contents($scheduledFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) { + echo json_encode(['success' => true, 'message' => 'Пост удалён']); + } else { + echo json_encode(['error' => 'Не удалось сохранить']); + } + break; + + case 'get_published_posts': + $scheduledFile = __DIR__ . '/data/scheduled_posts.json'; + if (file_exists($scheduledFile)) { + $posts = json_decode(file_get_contents($scheduledFile), true) ?: []; + // Filter only published posts + $published = array_filter($posts, function($p) { + return $p['status'] === 'published'; + }); + // Sort by published_at descending (newest first) + usort($published, function($a, $b) { + return strtotime($b['published_at'] ?? $b['scheduled_time']) - strtotime($a['published_at'] ?? $a['scheduled_time']); + }); + // Return last 10 + $published = array_slice($published, 0, 10); + echo json_encode(['success' => true, 'posts' => array_values($published)]); + } else { + echo json_encode(['success' => true, 'posts' => []]); + } + break; + + // ============ CROSS-PROMO SETTINGS ============ + + case 'save_cross_promo': + $settingsFile = __DIR__ . '/data/cross_promo.json'; + $dataDir = __DIR__ . '/data'; + + if (!is_dir($dataDir)) { + mkdir($dataDir, 0755, true); + } + + $settings = [ + 'telegramLink' => trim($_POST['telegramLink'] ?? ''), + 'vkLink' => trim($_POST['vkLink'] ?? ''), + 'textForTg' => trim($_POST['textForTg'] ?? 'Мой канал ВКонтакте'), + 'textForVk' => trim($_POST['textForVk'] ?? 'Мой канал в Telegram') + ]; + + if (file_put_contents($settingsFile, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['error' => 'Не удалось сохранить']); + } + break; + + case 'get_cross_promo': + $settingsFile = __DIR__ . '/data/cross_promo.json'; + if (file_exists($settingsFile)) { + $settings = json_decode(file_get_contents($settingsFile), true) ?: []; + echo json_encode(['success' => true, 'settings' => $settings]); + } else { + echo json_encode(['success' => true, 'settings' => [ + 'telegramLink' => '', + 'vkLink' => '', + 'textForTg' => 'Мой канал ВКонтакте', + 'textForVk' => 'Мой канал в Telegram' + ]]); + } + break; + + // ============ DRAFTS ============ + + case 'save_draft': + $draftFile = __DIR__ . '/data/draft.json'; + $dataDir = __DIR__ . '/data'; + + if (!is_dir($dataDir)) { + mkdir($dataDir, 0755, true); + } + + $draft = [ + 'text' => $_POST['text'] ?? '', + 'tags' => json_decode($_POST['tags'] ?? '[]', true), + 'photos' => json_decode($_POST['photos'] ?? '[]', true), + 'uploaded_files' => json_decode($_POST['uploaded_files'] ?? '[]', true), + 'updated_at' => date('Y-m-d H:i:s') + ]; + + if (file_put_contents($draftFile, json_encode($draft, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['error' => 'Не удалось сохранить черновик']); + } + break; + + case 'get_draft': + $draftFile = __DIR__ . '/data/draft.json'; + if (file_exists($draftFile)) { + $draft = json_decode(file_get_contents($draftFile), true) ?: []; + echo json_encode(['success' => true, 'draft' => $draft]); + } else { + echo json_encode(['success' => true, 'draft' => null]); + } + break; + + case 'clear_draft': + $draftFile = __DIR__ . '/data/draft.json'; + if (file_exists($draftFile)) { + unlink($draftFile); + } + echo json_encode(['success' => true]); + break; + + // ============ SETTINGS ============ + + case 'save_vk_token': + $token = trim($_POST['token'] ?? ''); + + if (empty($token)) { + echo json_encode(['error' => 'Токен не может быть пустым']); + exit; + } + + // Update config.php with new VK token + $configFile = __DIR__ . '/config.php'; + $configContent = file_get_contents($configFile); + + // Check if vk section exists + if (strpos($configContent, "'vk'") !== false) { + // Update existing vk access_token + $configContent = preg_replace( + "/('vk'\s*=>\s*\[\s*'access_token'\s*=>\s*')[^']*(')/s", + "$1" . addslashes($token) . "$2", + $configContent + ); + } else { + // Add vk section before the closing ]; + $vkSection = "\n 'vk' => [\n 'access_token' => '" . addslashes($token) . "',\n ],\n"; + $configContent = preg_replace("/(\];)\s*$/", $vkSection . "$1", $configContent); + } + + if (file_put_contents($configFile, $configContent)) { + // Validate the new token + require_once __DIR__ . '/classes/VKAPI.php'; + $vk = new VKAPI($token); + $validation = $vk->validateToken(); + + echo json_encode([ + 'success' => true, + 'message' => 'Токен сохранён', + 'validation' => $validation + ]); + } else { + echo json_encode(['error' => 'Не удалось сохранить config.php']); + } + break; + + case 'change_password': + $currentPassword = $_POST['current_password'] ?? ''; + $newPassword = $_POST['new_password'] ?? ''; + + if (strlen($newPassword) < 8) { + echo json_encode(['error' => 'New password must be at least 8 characters']); + exit; + } + + $username = $auth->getCurrentUser(); + if ($auth->changePassword($username, $currentPassword, $newPassword)) { + echo json_encode(['success' => true, 'message' => 'Password changed successfully']); + } else { + echo json_encode(['error' => 'Current password is incorrect']); + } + break; + + default: + echo json_encode(['error' => 'Unknown action']); + } + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} diff --git a/classes/Auth.php b/classes/Auth.php new file mode 100644 index 0000000..64a4462 --- /dev/null +++ b/classes/Auth.php @@ -0,0 +1,258 @@ +configFile = __DIR__ . '/../auth_config.php'; + } else { + $this->configFile = $configFile; + } + + // Use Argon2ID if available, fallback to bcrypt + if (defined('PASSWORD_ARGON2ID')) { + $this->passwordAlgo = PASSWORD_ARGON2ID; + } else { + $this->passwordAlgo = PASSWORD_BCRYPT; + } + + $this->loadConfig(); + } + + private function loadConfig() + { + if (file_exists($this->configFile)) { + $this->config = require $this->configFile; + } else { + $this->config = array( + 'users' => array(), + 'failed_attempts' => array(), + ); + } + } + + private function saveConfig() + { + $content = "config, true) . ";\n"; + file_put_contents($this->configFile, $content); + @chmod($this->configFile, 0600); + } + + public function createUser($username, $password) + { + if (isset($this->config['users'][$username])) { + return false; + } + + $this->config['users'][$username] = array( + 'password_hash' => $this->hashPassword($password), + 'created_at' => time(), + ); + + $this->saveConfig(); + return true; + } + + public function changePassword($username, $oldPassword, $newPassword) + { + if (!$this->verifyPassword($username, $oldPassword)) { + return false; + } + + $this->config['users'][$username]['password_hash'] = $this->hashPassword($newPassword); + $this->saveConfig(); + return true; + } + + private function hashPassword($password) + { + if ($this->passwordAlgo === PASSWORD_ARGON2ID) { + return password_hash($password, PASSWORD_ARGON2ID, array( + 'memory_cost' => 65536, + 'time_cost' => 4, + 'threads' => 1, + )); + } + return password_hash($password, PASSWORD_BCRYPT, array('cost' => 12)); + } + + private function verifyPassword($username, $password) + { + if (!isset($this->config['users'][$username])) { + $this->hashPassword($password); + return false; + } + return password_verify($password, $this->config['users'][$username]['password_hash']); + } + + private function isLockedOut($ip) + { + if (!isset($this->config['failed_attempts'][$ip])) { + return false; + } + + $attempts = $this->config['failed_attempts'][$ip]; + $lockoutTime = $this->lockoutTime; + + $filtered = array(); + foreach ($attempts as $time) { + if ($time > time() - $lockoutTime) { + $filtered[] = $time; + } + } + $this->config['failed_attempts'][$ip] = $filtered; + + return count($filtered) >= $this->maxAttempts; + } + + private function recordFailedAttempt($ip) + { + if (!isset($this->config['failed_attempts'][$ip])) { + $this->config['failed_attempts'][$ip] = array(); + } + $this->config['failed_attempts'][$ip][] = time(); + $this->saveConfig(); + } + + private function clearFailedAttempts($ip) + { + unset($this->config['failed_attempts'][$ip]); + $this->saveConfig(); + } + + public function login($username, $password, $ip) + { + if ($this->isLockedOut($ip)) { + return array( + 'success' => false, + 'message' => 'Too many failed attempts. Please try again later.', + 'locked' => true, + ); + } + + if ($this->verifyPassword($username, $password)) { + $this->clearFailedAttempts($ip); + $token = $this->generateSessionToken(); + + return array( + 'success' => true, + 'message' => 'Login successful', + 'token' => $token, + 'username' => $username, + ); + } + + $this->recordFailedAttempt($ip); + + $attempts = isset($this->config['failed_attempts'][$ip]) ? $this->config['failed_attempts'][$ip] : array(); + $remaining = $this->maxAttempts - count($attempts); + + return array( + 'success' => false, + 'message' => "Invalid username or password. {$remaining} attempts remaining.", + 'locked' => false, + ); + } + + private function generateSessionToken() + { + if (function_exists('random_bytes')) { + return bin2hex(random_bytes(32)); + } + return bin2hex(openssl_random_pseudo_bytes(32)); + } + + public function startSession($username, $token) + { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + session_regenerate_id(true); + + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $username; + $_SESSION['token'] = $token; + $_SESSION['login_time'] = time(); + $_SESSION['last_activity'] = time(); + } + + public function isAuthenticated($timeout = 3600) + { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + if (empty($_SESSION['authenticated'])) { + return false; + } + + $lastActivity = isset($_SESSION['last_activity']) ? $_SESSION['last_activity'] : 0; + if (time() - $lastActivity > $timeout) { + $this->logout(); + return false; + } + + $_SESSION['last_activity'] = time(); + return true; + } + + public function logout() + { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + $_SESSION = array(); + + if (isset($_COOKIE[session_name()])) { + setcookie(session_name(), '', time() - 3600, '/'); + } + + session_destroy(); + } + + public function getCurrentUser() + { + if (!$this->isAuthenticated()) { + return null; + } + return isset($_SESSION['username']) ? $_SESSION['username'] : null; + } + + public function hasUsers() + { + return !empty($this->config['users']); + } + + public static function getClientIP() + { + $headers = array('HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'); + + foreach ($headers as $header) { + if (!empty($_SERVER[$header])) { + $ip = $_SERVER[$header]; + if (strpos($ip, ',') !== false) { + $parts = explode(',', $ip); + $ip = trim($parts[0]); + } + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + } + + return '0.0.0.0'; + } +} diff --git a/classes/FlickrAPI.php b/classes/FlickrAPI.php new file mode 100644 index 0000000..69f472b --- /dev/null +++ b/classes/FlickrAPI.php @@ -0,0 +1,395 @@ +apiKey = $apiKey; + $this->apiSecret = $apiSecret; + $this->userId = $userId; + } + + /** + * Set OAuth handler for authenticated requests + */ + public function setOAuth($oauth) + { + $this->oauth = $oauth; + } + + /** + * Check if OAuth is available + */ + public function hasOAuth() + { + return $this->oauth !== null && $this->oauth->isAuthorized(); + } + + /** + * Set user ID + */ + public function setUserId($userId) + { + $this->userId = $userId; + } + + /** + * Make API request (with OAuth if available) + * + * @param string $method Flickr API method + * @param array $params Additional parameters + * @param bool $useOAuth Force OAuth for this request + * @return array Response data + */ + private function request($method, $params = [], $useOAuth = false) + { + $params = array_merge([ + 'method' => $method, + 'api_key' => $this->apiKey, + 'format' => 'json', + 'nojsoncallback' => 1, + ], $params); + + // Use OAuth if available and requested + if (($useOAuth || $this->hasOAuth()) && $this->oauth !== null) { + return $this->requestWithOAuth($method, $params); + } + + $url = $this->baseUrl . '?' . http_build_query($params); + + $context = stream_context_create([ + 'http' => [ + 'timeout' => 30, + 'user_agent' => 'VH_Posting_System/1.0', + ], + ]); + + $response = @file_get_contents($url, false, $context); + + if ($response === false) { + throw new RuntimeException('Failed to connect to Flickr API'); + } + + $data = json_decode($response, true); + + if ($data === null) { + throw new RuntimeException('Invalid JSON response from Flickr API'); + } + + if (isset($data['stat']) && $data['stat'] === 'fail') { + throw new RuntimeException('Flickr API error: ' . (isset($data['message']) ? $data['message'] : 'Unknown error')); + } + + return $data; + } + + /** + * Make OAuth-signed API request + */ + private function requestWithOAuth($method, $params) + { + $params['method'] = $method; + $params['format'] = 'json'; + $params['nojsoncallback'] = 1; + + $oauthParams = $this->oauth->signRequest('GET', $this->baseUrl, $params); + $allParams = array_merge($params, $oauthParams); + + $url = $this->baseUrl . '?' . http_build_query($allParams); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode !== 200) { + throw new RuntimeException('Failed to connect to Flickr API (OAuth)'); + } + + $data = json_decode($response, true); + + if ($data === null) { + throw new RuntimeException('Invalid JSON response from Flickr API'); + } + + if (isset($data['stat']) && $data['stat'] === 'fail') { + throw new RuntimeException('Flickr API error: ' . (isset($data['message']) ? $data['message'] : 'Unknown error')); + } + + return $data; + } + + /** + * Get user's photostream + * + * @param int $page Page number + * @param int $perPage Photos per page (max 500) + * @return array Photos data + */ + public function getPhotos($page = 1, $perPage = 50) + { + $response = $this->request('flickr.people.getPhotos', [ + 'user_id' => $this->userId ? $this->userId : 'me', + 'page' => $page, + 'per_page' => $perPage, + 'extras' => 'url_sq,url_t,url_s,url_m,url_z,url_l,url_k,url_o,description,date_upload,date_taken,owner_name,original_format', + ]); + + return $this->normalizePhotosResponse(isset($response['photos']) ? $response['photos'] : []); + } + + /** + * Get user's photosets (albums) + * + * @param int $page Page number + * @param int $perPage Albums per page + * @return array Photosets data with pagination info + */ + public function getPhotosets($page = 1, $perPage = 50) + { + $response = $this->request('flickr.photosets.getList', [ + 'user_id' => $this->userId, + 'page' => $page, + 'per_page' => $perPage, + 'primary_photo_extras' => 'url_sq,url_t,url_s,url_m', + ]); + + $photosets = isset($response['photosets']) ? $response['photosets'] : []; + + return [ + 'albums' => isset($photosets['photoset']) ? $photosets['photoset'] : [], + 'page' => (int)($photosets['page'] ?? $page), + 'pages' => (int)($photosets['pages'] ?? 1), + 'perpage' => (int)($photosets['perpage'] ?? $perPage), + 'total' => (int)($photosets['total'] ?? 0), + ]; + } + + /** + * Get photos from a specific photoset (album) + * + * @param string $photosetId Photoset ID + * @param int $page Page number + * @param int $perPage Photos per page + * @return array Photos data + */ + public function getPhotosetPhotos($photosetId, $page = 1, $perPage = 50) + { + $response = $this->request('flickr.photosets.getPhotos', [ + 'photoset_id' => $photosetId, + 'user_id' => $this->userId, + 'page' => $page, + 'per_page' => $perPage, + 'extras' => 'url_sq,url_t,url_s,url_m,url_z,url_l,url_k,url_o,description,date_upload,date_taken,original_format,media,path_alias,owner_name', + ]); + + // Get owner info from photoset response + $ownername = isset($response['photoset']['ownername']) ? $response['photoset']['ownername'] : ''; + $owner = isset($response['photoset']['owner']) ? $response['photoset']['owner'] : $this->userId; + + return $this->normalizePhotosResponse(isset($response['photoset']) ? $response['photoset'] : [], $ownername, $owner); + } + + /** + * Get info about a specific photo + * + * @param string $photoId Photo ID + * @return array Photo info + */ + public function getPhotoInfo($photoId) + { + $response = $this->request('flickr.photos.getInfo', [ + 'photo_id' => $photoId, + ]); + + return isset($response['photo']) ? $response['photo'] : []; + } + + /** + * Get available sizes for a photo (uses OAuth if available) + * + * @param string $photoId Photo ID + * @return array Available sizes + */ + public function getPhotoSizes($photoId) + { + // getSizes requires OAuth to return Original for private/restricted photos + $response = $this->request('flickr.photos.getSizes', [ + 'photo_id' => $photoId, + ], true); // Force OAuth if available + + $sizes = []; + $sizeList = isset($response['sizes']['size']) ? $response['sizes']['size'] : []; + foreach ($sizeList as $size) { + $sizes[$size['label']] = [ + 'url' => $size['source'], + 'width' => (int)$size['width'], + 'height' => (int)$size['height'], + ]; + } + + return $sizes; + } + + /** + * Get original URL for a photo (requires OAuth) + * + * @param string $photoId Photo ID + * @return string|null Original URL or null if not available + */ + public function getOriginalUrl($photoId) + { + try { + $sizes = $this->getPhotoSizes($photoId); + + // Try Original first, then fall back to largest available + if (isset($sizes['Original'])) { + return $sizes['Original']['url']; + } + if (isset($sizes['Large 2048'])) { + return $sizes['Large 2048']['url']; + } + if (isset($sizes['Large 1600'])) { + return $sizes['Large 1600']['url']; + } + if (isset($sizes['Large'])) { + return $sizes['Large']['url']; + } + + return null; + } catch (Exception $e) { + return null; + } + } + + /** + * Search user's photos + * + * @param string $query Search query + * @param int $page Page number + * @param int $perPage Photos per page + * @return array Photos data + */ + public function searchPhotos($query, $page = 1, $perPage = 50) + { + $response = $this->request('flickr.photos.search', [ + 'user_id' => $this->userId ? $this->userId : 'me', + 'text' => $query, + 'page' => $page, + 'per_page' => $perPage, + 'extras' => 'url_sq,url_t,url_s,url_m,url_z,url_l,url_k,url_o,description,date_upload,date_taken,original_format', + ]); + + return $this->normalizePhotosResponse(isset($response['photos']) ? $response['photos'] : []); + } + + /** + * Find user ID by username + * + * @param string $username Flickr username + * @return string User ID + */ + public function findUserByUsername($username) + { + $response = $this->request('flickr.people.findByUsername', [ + 'username' => $username, + ]); + + return isset($response['user']['nsid']) ? $response['user']['nsid'] : ''; + } + + /** + * Normalize photos response to consistent format + */ + private function normalizePhotosResponse($response, $defaultOwnerName = '', $defaultOwner = '') + { + $photos = []; + $photoList = isset($response['photo']) ? $response['photo'] : []; + + foreach ($photoList as $photo) { + $farm = isset($photo['farm']) ? $photo['farm'] : ''; + $server = $photo['server']; + $id = $photo['id']; + $originalSecret = isset($photo['originalsecret']) ? $photo['originalsecret'] : $photo['secret']; + $originalFormat = isset($photo['originalformat']) ? $photo['originalformat'] : 'jpg'; + + // Get original URL - ONLY use if API returns it + // If url_o is not returned, originals are blocked by Flickr privacy settings + $originalUrl = isset($photo['url_o']) ? $photo['url_o'] : null; + + // Get large 2048 URL (url_k) - from API or construct it + // This is the best quality available when originals are blocked + $large2048Url = isset($photo['url_k']) ? $photo['url_k'] : null; + + // Construct large2048 URL if not provided (usually works) + if (!$large2048Url && $server) { + $large2048Url = "https://live.staticflickr.com/{$server}/{$id}_{$photo['secret']}_k.jpg"; + } + + // Determine media type (photo or video) + $mediaType = isset($photo['media']) ? $photo['media'] : 'photo'; + $isVideo = ($mediaType === 'video'); + + // Build page URL - use path_alias, ownername, or owner NSID + $pathAlias = isset($photo['pathalias']) && $photo['pathalias'] ? $photo['pathalias'] : ''; + $ownerName = isset($photo['ownername']) ? $photo['ownername'] : $defaultOwnerName; + $owner = isset($photo['owner']) ? $photo['owner'] : ($defaultOwner ? $defaultOwner : $this->userId); + + // Prefer path_alias, then ownername, then owner NSID (URL encoded) + $userPath = $pathAlias ? $pathAlias : ($ownerName ? $ownerName : rawurlencode($owner)); + $pageUrl = "https://www.flickr.com/photos/{$userPath}/{$id}/"; + + $photos[] = [ + 'id' => $id, + 'secret' => $photo['secret'], + 'server' => $server, + 'farm' => $farm, + 'title' => isset($photo['title']) ? $photo['title'] : 'Untitled', + 'description' => isset($photo['description']['_content']) ? $photo['description']['_content'] : '', + 'date_upload' => isset($photo['dateupload']) ? $photo['dateupload'] : '', + 'date_taken' => isset($photo['datetaken']) ? $photo['datetaken'] : '', + 'original_format' => $originalFormat, + 'original_secret' => $originalSecret, + 'media' => $mediaType, + 'is_video' => $isVideo, + 'urls' => [ + 'square' => isset($photo['url_sq']) ? $photo['url_sq'] : null, + 'thumbnail' => isset($photo['url_t']) ? $photo['url_t'] : null, + 'small' => isset($photo['url_s']) ? $photo['url_s'] : null, + 'medium' => isset($photo['url_m']) ? $photo['url_m'] : null, + 'medium640' => isset($photo['url_z']) ? $photo['url_z'] : null, + 'large' => isset($photo['url_l']) ? $photo['url_l'] : null, + 'large2048' => $large2048Url, + 'original' => $originalUrl, + ], + 'page_url' => $pageUrl, + ]; + } + + return [ + 'photos' => $photos, + 'page' => (int)(isset($response['page']) ? $response['page'] : 1), + 'pages' => (int)(isset($response['pages']) ? $response['pages'] : 1), + 'perpage' => (int)(isset($response['perpage']) ? $response['perpage'] : count($photos)), + 'total' => (int)(isset($response['total']) ? $response['total'] : count($photos)), + ]; + } +} diff --git a/classes/FlickrOAuth.php b/classes/FlickrOAuth.php new file mode 100644 index 0000000..0346eec --- /dev/null +++ b/classes/FlickrOAuth.php @@ -0,0 +1,272 @@ +consumerKey = $consumerKey; + $this->consumerSecret = $consumerSecret; + $this->tokenFile = __DIR__ . '/../data/oauth_token.json'; + + // Load saved tokens if exist + $this->loadTokens(); + } + + /** + * Check if we have valid access tokens + */ + public function isAuthorized() + { + return !empty($this->oauthToken) && !empty($this->oauthTokenSecret); + } + + /** + * Get stored OAuth token + */ + public function getOAuthToken() + { + return $this->oauthToken; + } + + /** + * Get stored OAuth token secret + */ + public function getOAuthTokenSecret() + { + return $this->oauthTokenSecret; + } + + /** + * Step 1: Get request token and return authorization URL + */ + public function getAuthorizationUrl($callbackUrl) + { + $params = [ + 'oauth_callback' => $callbackUrl, + 'oauth_consumer_key' => $this->consumerKey, + 'oauth_nonce' => $this->generateNonce(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => time(), + 'oauth_version' => '1.0', + ]; + + $params['oauth_signature'] = $this->generateSignature('GET', $this->requestTokenUrl, $params); + + $url = $this->requestTokenUrl . '?' . http_build_query($params); + + $response = $this->httpRequest($url); + + if (!$response) { + throw new Exception('Failed to get request token'); + } + + parse_str($response, $data); + + if (!isset($data['oauth_token']) || !isset($data['oauth_token_secret'])) { + throw new Exception('Invalid request token response: ' . $response); + } + + // Store request token temporarily (needed for step 2) + $_SESSION['flickr_request_token'] = $data['oauth_token']; + $_SESSION['flickr_request_token_secret'] = $data['oauth_token_secret']; + + // Return URL for user to authorize + return $this->authorizeUrl . '?oauth_token=' . $data['oauth_token'] . '&perms=read'; + } + + /** + * Step 2: Exchange verifier for access token + */ + public function handleCallback($oauthToken, $oauthVerifier) + { + if (!isset($_SESSION['flickr_request_token_secret'])) { + throw new Exception('Request token secret not found in session'); + } + + $requestTokenSecret = $_SESSION['flickr_request_token_secret']; + + $params = [ + 'oauth_consumer_key' => $this->consumerKey, + 'oauth_token' => $oauthToken, + 'oauth_verifier' => $oauthVerifier, + 'oauth_nonce' => $this->generateNonce(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => time(), + 'oauth_version' => '1.0', + ]; + + $params['oauth_signature'] = $this->generateSignature( + 'GET', + $this->accessTokenUrl, + $params, + $requestTokenSecret + ); + + $url = $this->accessTokenUrl . '?' . http_build_query($params); + + $response = $this->httpRequest($url); + + if (!$response) { + throw new Exception('Failed to get access token'); + } + + parse_str($response, $data); + + if (!isset($data['oauth_token']) || !isset($data['oauth_token_secret'])) { + throw new Exception('Invalid access token response: ' . $response); + } + + // Save access tokens + $this->oauthToken = $data['oauth_token']; + $this->oauthTokenSecret = $data['oauth_token_secret']; + $this->saveTokens($data); + + // Clean up session + unset($_SESSION['flickr_request_token']); + unset($_SESSION['flickr_request_token_secret']); + + return [ + 'oauth_token' => $this->oauthToken, + 'user_nsid' => $data['user_nsid'] ?? null, + 'username' => $data['username'] ?? null, + 'fullname' => $data['fullname'] ?? null, + ]; + } + + /** + * Sign an API request with OAuth + */ + public function signRequest($method, $url, $params = []) + { + $oauthParams = [ + 'oauth_consumer_key' => $this->consumerKey, + 'oauth_token' => $this->oauthToken, + 'oauth_nonce' => $this->generateNonce(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => time(), + 'oauth_version' => '1.0', + ]; + + $allParams = array_merge($params, $oauthParams); + $oauthParams['oauth_signature'] = $this->generateSignature( + $method, + $url, + $allParams, + $this->oauthTokenSecret + ); + + return $oauthParams; + } + + /** + * Generate OAuth signature + */ + private function generateSignature($method, $url, $params, $tokenSecret = '') + { + ksort($params); + + $paramString = http_build_query($params, '', '&', PHP_QUERY_RFC3986); + + $baseString = strtoupper($method) . '&' + . rawurlencode($url) . '&' + . rawurlencode($paramString); + + $signingKey = rawurlencode($this->consumerSecret) . '&' . rawurlencode($tokenSecret); + + return base64_encode(hash_hmac('sha1', $baseString, $signingKey, true)); + } + + /** + * Generate random nonce + */ + private function generateNonce() + { + return md5(uniqid(mt_rand(), true)); + } + + /** + * Make HTTP request + */ + private function httpRequest($url) + { + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + return false; + } + + return $response; + } + + /** + * Save tokens to file + */ + private function saveTokens($data) + { + $dir = dirname($this->tokenFile); + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + + $tokenData = [ + 'oauth_token' => $data['oauth_token'], + 'oauth_token_secret' => $data['oauth_token_secret'], + 'user_nsid' => $data['user_nsid'] ?? null, + 'username' => $data['username'] ?? null, + 'created_at' => date('Y-m-d H:i:s'), + ]; + + file_put_contents($this->tokenFile, json_encode($tokenData, JSON_PRETTY_PRINT)); + chmod($this->tokenFile, 0600); + } + + /** + * Load tokens from file + */ + private function loadTokens() + { + if (file_exists($this->tokenFile)) { + $data = json_decode(file_get_contents($this->tokenFile), true); + if ($data) { + $this->oauthToken = $data['oauth_token'] ?? null; + $this->oauthTokenSecret = $data['oauth_token_secret'] ?? null; + } + } + } + + /** + * Clear saved tokens (logout) + */ + public function clearTokens() + { + $this->oauthToken = null; + $this->oauthTokenSecret = null; + if (file_exists($this->tokenFile)) { + unlink($this->tokenFile); + } + } +} diff --git a/classes/FlickrParser.php b/classes/FlickrParser.php new file mode 100644 index 0000000..c2aeb80 --- /dev/null +++ b/classes/FlickrParser.php @@ -0,0 +1,153 @@ + '_s', // 75x75 + 'LargeSquare' => '_q', // 150x150 + 'Thumbnail' => '_t', // 100 on longest side + 'Small' => '_m', // 240 on longest side + 'Small320' => '_n', // 320 on longest side + 'Medium' => '', // 500 on longest side + 'Medium640' => '_z', // 640 on longest side + 'Medium800' => '_c', // 800 on longest side + 'Large' => '_b', // 1024 on longest side + 'Large1600' => '_h', // 1600 on longest side + 'Large2048' => '_k', // 2048 on longest side + 'Original' => '_o', // original image + ]; + + /** + * Parse a Flickr URL and extract photo information + * + * @param string $url Flickr URL + * @return array|null Photo info or null if not a valid Flickr URL + */ + public function parse($url) + { + $url = trim($url); + + // Direct image URL + if (preg_match('/staticflickr\.com\/\d+\/(\d+)_([a-f0-9]+)(_[a-z])?\.(\w+)/i', $url, $matches)) { + return [ + 'photo_id' => $matches[1], + 'secret' => $matches[2], + 'size_suffix' => isset($matches[3]) ? $matches[3] : '', + 'format' => $matches[4], + 'type' => 'direct', + 'original_url' => $url, + ]; + } + + // Photo page URL + if (preg_match('/flickr\.com\/photos\/[^\/]+\/(\d+)/i', $url, $matches)) { + return [ + 'photo_id' => $matches[1], + 'type' => 'page', + 'original_url' => $url, + ]; + } + + // Short URL (flic.kr) + if (preg_match('/flic\.kr\/p\/([a-zA-Z0-9]+)/i', $url, $matches)) { + $photoId = $this->decodeBase58($matches[1]); + return [ + 'photo_id' => $photoId, + 'type' => 'short', + 'original_url' => $url, + ]; + } + + return null; + } + + /** + * Parse multiple URLs (one per line or comma-separated) + * + * @param string $input Input text with URLs + * @return array Array of parsed photo info + */ + public function parseMultiple($input) + { + $results = []; + + // Split by newlines and commas + $lines = preg_split('/[\r\n,]+/', $input); + + foreach ($lines as $line) { + $url = trim($line); + if (empty($url)) continue; + + $parsed = $this->parse($url); + if ($parsed) { + $results[] = $parsed; + } + } + + return $results; + } + + /** + * Build a direct image URL from photo info + * + * @param array $photoInfo Photo information + * @param string $size Size name from SIZES constant + * @return string Direct image URL + */ + public function buildImageUrl($photoInfo, $size = 'Large') + { + $suffix = isset(self::SIZES[$size]) ? self::SIZES[$size] : self::SIZES['Large']; + + $server = isset($photoInfo['server']) ? $photoInfo['server'] : '65535'; + $photoId = $photoInfo['photo_id']; + $secret = isset($photoInfo['secret']) ? $photoInfo['secret'] : (isset($photoInfo['originalsecret']) ? $photoInfo['originalsecret'] : ''); + $format = isset($photoInfo['format']) ? $photoInfo['format'] : (isset($photoInfo['originalformat']) ? $photoInfo['originalformat'] : 'jpg'); + + // For original size, use originalsecret if available + if ($size === 'Original' && isset($photoInfo['originalsecret'])) { + $secret = $photoInfo['originalsecret']; + $format = isset($photoInfo['originalformat']) ? $photoInfo['originalformat'] : 'jpg'; + } + + return "https://live.staticflickr.com/{$server}/{$photoId}_{$secret}{$suffix}.{$format}"; + } + + /** + * Decode Flickr's base58 short URL to photo ID + * + * @param string $encoded Base58 encoded string + * @return string Photo ID + */ + private function decodeBase58($encoded) + { + $alphabet = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'; + $base = strlen($alphabet); + $decoded = '0'; + + for ($i = 0; $i < strlen($encoded); $i++) { + $pos = strpos($alphabet, $encoded[$i]); + $decoded = bcmul($decoded, (string)$base); + $decoded = bcadd($decoded, (string)$pos); + } + + return $decoded; + } + + /** + * Get available sizes + * + * @return array Size names + */ + public function getAvailableSizes() + { + return array_keys(self::SIZES); + } +} diff --git a/classes/FormatGenerator.php b/classes/FormatGenerator.php new file mode 100644 index 0000000..d331a33 --- /dev/null +++ b/classes/FormatGenerator.php @@ -0,0 +1,200 @@ + [ + 'name' => 'BBCode', + 'description' => 'Standard forum BBCode', + 'template' => '[img]{url}[/img]', + 'separator' => "\n", + ], + 'bbcode_linked' => [ + 'name' => 'BBCode (clickable)', + 'description' => 'BBCode with link to original', + 'template' => '[url={original}][img]{url}[/img][/url]', + 'separator' => "\n", + ], + 'html' => [ + 'name' => 'HTML', + 'description' => 'HTML img tag', + 'template' => '{title}', + 'separator' => "\n", + ], + 'html_linked' => [ + 'name' => 'HTML (clickable)', + 'description' => 'HTML img with link to original', + 'template' => '{title}', + 'separator' => "\n", + ], + 'html_figure' => [ + 'name' => 'HTML Figure', + 'description' => 'HTML5 figure with caption', + 'template' => "
\n \"{title}\"\n
{title}
\n
", + 'separator' => "\n\n", + ], + 'markdown' => [ + 'name' => 'Markdown', + 'description' => 'Markdown image syntax', + 'template' => '![{title}]({url})', + 'separator' => "\n", + ], + 'markdown_linked' => [ + 'name' => 'Markdown (clickable)', + 'description' => 'Markdown with link to original', + 'template' => '[![{title}]({url})]({original})', + 'separator' => "\n", + ], + 'url_only' => [ + 'name' => 'URL only', + 'description' => 'Just the image URL', + 'template' => '{url}', + 'separator' => "\n", + ], + 'url_list' => [ + 'name' => 'URL list (comma)', + 'description' => 'Comma-separated URLs', + 'template' => '{url}', + 'separator' => ', ', + ], + + // ============ FORUM PRESETS ============ + + 'bjdclub' => [ + 'name' => 'BJDClub.ru', + 'description' => 'Оптимизировано для BJDClub (phpBB)', + 'template' => '[url={original}][img]{url}[/img][/url]', + 'separator' => "\n\n", + 'category' => 'forum', + ], + 'babiki' => [ + 'name' => 'Babiki.ru', + 'description' => 'Оптимизировано для Бэбиков', + 'template' => '{title}', + 'separator' => "\n\n", + 'category' => 'forum', + ], + 'babiki_simple' => [ + 'name' => 'Babiki.ru (простой)', + 'description' => 'Только картинки для Бэбиков', + 'template' => '{title}', + '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' => '{title}', + 'html_linked' => '{title}', + 'markdown' => '![{title}]({url})', + 'markdown_linked' => '[![{title}]({url})]({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' => '{title}', + 'html_linked' => '{title}', + 'markdown' => '![{title}]({url})', + 'markdown_linked' => '[![{title}]({url})]({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) + + Header set X-Content-Type-Options "nosniff" + Header set X-Frame-Options "SAMEORIGIN" +'; + + if (file_put_contents(__DIR__ . '/.htaccess', $htaccessContent)) { + $fixResults[] = array('success', 'Created .htaccess file'); + } else { + $fixResults[] = array('error', 'Failed to create .htaccess'); + } + break; + + case 'create_config': + if (file_exists(__DIR__ . '/config.example.php')) { + if (copy(__DIR__ . '/config.example.php', __DIR__ . '/config.php')) { + @chmod(__DIR__ . '/config.php', 0644); + $fixResults[] = array('success', 'Created config.php from example'); + } else { + $fixResults[] = array('error', 'Failed to copy config.example.php'); + } + } else { + $configContent = ' [ + \'api_key\' => \'\', + \'api_secret\' => \'\', + ], + \'flickr_user_id\' => \'\', + \'telegram\' => [ + \'bot_token\' => \'\', + \'channels\' => [], + ], + \'default_size\' => \'Large\', + \'custom_formats\' => [], +];'; + if (file_put_contents(__DIR__ . '/config.php', $configContent)) { + @chmod(__DIR__ . '/config.php', 0644); + $fixResults[] = array('success', 'Created config.php'); + } else { + $fixResults[] = array('error', 'Failed to create config.php'); + } + } + break; + + case 'fix_permissions': + $files = array( + 'config.php' => 0644, + 'auth_config.php' => 0600, + '.htaccess' => 0644, + ); + foreach ($files as $file => $perm) { + $path = __DIR__ . '/' . $file; + if (file_exists($path)) { + if (@chmod($path, $perm)) { + $fixResults[] = array('success', "Fixed permissions for {$file}"); + } else { + $fixResults[] = array('error', "Failed to fix permissions for {$file}"); + } + } + } + break; + + case 'fix_auth_config_perms': + $path = __DIR__ . '/auth_config.php'; + if (file_exists($path)) { + if (@chmod($path, 0600)) { + $fixResults[] = array('success', 'Fixed auth_config.php permissions to 0600'); + } else { + $fixResults[] = array('error', 'Failed to fix auth_config.php permissions'); + } + } else { + $fixResults[] = array('info', 'auth_config.php does not exist yet'); + } + break; + + case 'delete_debug': + // Self-delete + if (@unlink(__FILE__)) { + header('Location: index.php'); + exit; + } else { + $fixResults[] = array('error', 'Failed to delete debug.php - delete manually!'); + } + break; + + case 'reset_user': + $path = __DIR__ . '/auth_config.php'; + if (file_exists($path)) { + if (@unlink($path)) { + $fixResults[] = array('success', 'Deleted auth_config.php - go to setup.php to create new user'); + } else { + $fixResults[] = array('error', 'Failed to delete auth_config.php'); + } + } else { + $fixResults[] = array('info', 'auth_config.php does not exist'); + } + break; + } +} + +// ============ HTML OUTPUT ============ +?> + + + + VH Posting System - Diagnostics + + + + +

VH Posting System - Diagnostics

+ +'; + echo '

Fix Results

'; + foreach ($fixResults as $result) { + echo '
' . htmlspecialchars($result[1]) . '
'; + } + echo ''; +} +?> + + +
+

Quick Fix Panel

+

Click buttons to automatically fix common issues:

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +

+ +
+ + +
+ +
+ + +
+
+ +1. PHP Version"; +echo "

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 "

2. Required Extensions

"; +$extensions = array('curl', 'json', 'mbstring', 'session'); +foreach ($extensions as $ext) { + if (extension_loaded($ext)) { + 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 "

3. Password Hashing

"; +if (defined('PASSWORD_ARGON2ID')) { + echo "

OK: Argon2ID available

"; +} else { + echo "

INFO: Argon2ID not available, using bcrypt (OK)

"; +} + +// Config file +echo "

4. Configuration

"; +if (file_exists(__DIR__ . '/config.php')) { + 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 "

5. File Permissions

"; +if (is_writable(__DIR__)) { + echo "

OK: Root directory is writable

"; +} else { + echo "

ERROR: Root directory is not writable (needed for auth_config.php)

"; +} + +// Test class loading +echo "

6. Class Loading Test

"; + +$classes = array('Auth', 'FlickrParser', 'FormatGenerator', 'FlickrAPI', 'TelegramBot'); +foreach ($classes as $class) { + $file = __DIR__ . '/classes/' . $class . '.php'; + if (file_exists($file)) { + try { + require_once $file; + if (class_exists($class)) { + 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 "

7. Auth System Test

"; +try { + $auth = new Auth(); + 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 "

8. Security Checks

"; + +// HTTPS +$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || + (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') || + (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443); +if ($isHttps) { + 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 "

Leak Detection (API Keys & Credentials)

"; + +// Check if sensitive files are accessible via web +$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . + '://' . $_SERVER['HTTP_HOST'] . dirname($_SERVER['REQUEST_URI']); + +$sensitiveFiles = array( + 'config.php' => 'API Keys (Flickr, Telegram)', + 'auth_config.php' => 'User passwords (hashed)', + 'config.example.php' => 'Config template', + 'classes/Auth.php' => 'Auth class source', + 'classes/FlickrAPI.php' => 'Flickr API class', + 'classes/TelegramBot.php' => 'Telegram Bot class', + '.env' => 'Environment file', + '.git/config' => 'Git config', + 'composer.json' => 'Dependencies', + 'error_log' => 'Error log', + 'debug.log' => 'Debug log', +); + +$leaksFound = 0; +foreach ($sensitiveFiles as $file => $desc) { + $testUrl = rtrim($baseUrl, '/') . '/' . $file; + + $ch = curl_init(); + curl_setopt_array($ch, array( + CURLOPT_URL => $testUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_SSL_VERIFYPEER => false, + )); + $content = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode == 403 || $httpCode == 404 || $httpCode == 500) { + 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 "

Information Disclosure Check

"; +$infoFiles = array( + 'phpinfo.php' => 'PHP Info page', + 'info.php' => 'PHP Info page', + 'test.php' => 'Test file', + 'readme.md' => 'Readme file', + 'README.md' => 'Readme file', + 'CHANGELOG.md' => 'Changelog', + '.htpasswd' => 'Password file', + 'backup.sql' => 'Database backup', + 'dump.sql' => 'Database dump', +); + +foreach ($infoFiles as $file => $desc) { + $path = __DIR__ . '/' . $file; + if (file_exists($path)) { + echo "

WARNING: {$file} exists - consider removing ({$desc})

"; + } +} + +// Check if secrets are exposed in JS files +echo "

Secret Exposure in Public Files

"; +$publicFiles = array( + 'js/app.js', + 'css/style.css', +); + +$secretPatterns = array( + '/api[_-]?key\s*[:=]\s*[\'"][^\'"]+[\'"]/i' => 'API Key', + '/bot[_-]?token\s*[:=]\s*[\'"][^\'"]+[\'"]/i' => 'Bot Token', + '/password\s*[:=]\s*[\'"][^\'"]+[\'"]/i' => 'Password', + '/secret\s*[:=]\s*[\'"][^\'"]+[\'"]/i' => 'Secret', +); + +foreach ($publicFiles as $file) { + $path = __DIR__ . '/' . $file; + if (file_exists($path)) { + $content = file_get_contents($path); + $foundSecrets = array(); + + foreach ($secretPatterns as $pattern => $type) { + if (preg_match($pattern, $content)) { + $foundSecrets[] = $type; + } + } + + if (!empty($foundSecrets)) { + 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 "
"; + echo "SECURITY ALERT: {$leaksFound} potential leak(s) detected!"; + echo "

Use Quick Fix buttons above or manually fix the issues.

"; + echo "
"; +} else { + echo "

No critical leaks detected.

"; +} + +// File permissions +echo "

File Permissions

"; +$checkPerms = array( + 'config.php' => '0600 or 0644', + 'auth_config.php' => '0600', + '.htaccess' => '0644', +); + +foreach ($checkPerms as $file => $recommended) { + $path = __DIR__ . '/' . $file; + if (file_exists($path)) { + $perms = substr(sprintf('%o', fileperms($path)), -4); + $worldReadable = (fileperms($path) & 0x0004); + + if ($file === 'auth_config.php' && $worldReadable) { + echo "

WARNING: {$file} is world-readable ({$perms}), recommended: {$recommended}

"; + } else { + echo "

OK: {$file} permissions: {$perms}

"; + } + } +} + +// PHP security settings +echo "

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 "

Cryptographic Functions

"; +if (function_exists('random_bytes')) { + try { + $test = random_bytes(32); + 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 "

9. Server Info

"; +echo "

Server software: " . htmlspecialchars(isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : 'Unknown') . "

"; +echo "

Document root: " . htmlspecialchars(isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : 'Unknown') . "

"; +echo "

Script path: " . htmlspecialchars(__DIR__) . "

"; + +echo "
"; +echo "

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 = ` + ${escapeHtml(photo.title)} + + flickr + `; + postPhotosPreview.appendChild(div); + }); + + // Render uploaded files + state.uploadedFiles.forEach((file) => { + const div = document.createElement('div'); + const isVideo = file.type.startsWith('video/'); + div.className = 'preview-thumb' + (file.uploading ? ' uploading' : ''); + div.innerHTML = ` + ${isVideo + ? `` + : `${escapeHtml(file.name)}` + } + ${file.uploading ? '
' : ''} + + файл + `; + postPhotosPreview.appendChild(div); + }); + + // Attach remove handlers for Flickr photos + postPhotosPreview.querySelectorAll('.remove-btn[data-source="flickr"]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const index = parseInt(btn.dataset.index); + state.selectedPhotos.splice(index, 1); + updatePostingPreview(); + updateSelectionUI(); + syncGallerySelection(); + saveSelectedPhotos(); + }); + }); + + // Attach remove handlers for uploaded files + postPhotosPreview.querySelectorAll('.remove-btn[data-source="upload"]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.id; + state.uploadedFiles = state.uploadedFiles.filter(f => f.id !== id); + updatePostingPreview(); + saveUploadedFiles(); + }); + }); + } + + function syncGallerySelection() { + document.querySelectorAll('.photo-item').forEach(item => { + const photoId = item.dataset.photoId; + if (state.selectedPhotos.find(p => p.id === photoId)) { + item.classList.add('selected'); + } else { + item.classList.remove('selected'); + } + }); + } + + // ============ TAB NAVIGATION ============ + + const navBtns = document.querySelectorAll('.nav-btn'); + const tabContents = document.querySelectorAll('.tab-content'); + + navBtns.forEach(btn => { + btn.addEventListener('click', () => { + const tabId = btn.dataset.tab; + + navBtns.forEach(b => b.classList.remove('active')); + tabContents.forEach(t => t.classList.remove('active')); + + btn.classList.add('active'); + document.getElementById('tab-' + tabId)?.classList.add('active'); + + // Load data when switching tabs + if (tabId === 'gallery') { + // Albums are loaded on button click now + // But if we have cache, render it + if (window._cachedAlbums && albumsGrid) { + renderAlbumsGrid(window._cachedAlbums); + } + } else if (tabId === 'posting') { + updatePostingPreview(); + loadTelegramStatus(); + loadVKStatus(); + } else if (tabId === 'settings') { + loadTelegramStatus(); + loadVKStatus(); + } + }); + }); + + // ============ LINK CONVERTER ============ + + const btnConvert = document.getElementById('btn-convert'); + const btnCopy = document.getElementById('btn-copy'); + const inputUrls = document.getElementById('input-urls'); + const outputResult = document.getElementById('output-result'); + + btnConvert?.addEventListener('click', async () => { + const urls = inputUrls?.value.trim(); + if (!urls) { + showNotification('Введите ссылки Flickr', 'error'); + return; + } + + btnConvert.disabled = true; + btnConvert.textContent = 'Конвертация...'; + + try { + const formData = new FormData(); + formData.append('action', 'convert'); + formData.append('urls', urls); + formData.append('size', document.getElementById('image-size')?.value || 'Large'); + formData.append('format', document.getElementById('output-format')?.value || 'bbcode'); + + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.error) { + showNotification('Ошибка: ' + data.error, 'error'); + } else { + if (outputResult) outputResult.value = data.output; + showNotification('Конвертация завершена', 'success'); + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + } finally { + btnConvert.disabled = false; + btnConvert.textContent = 'Конвертировать'; + } + }); + + btnCopy?.addEventListener('click', () => { + outputResult?.select(); + document.execCommand('copy'); + if (btnCopy) { + btnCopy.textContent = 'Скопировано!'; + setTimeout(() => btnCopy.textContent = 'Скопировать', 2000); + } + }); + + // ============ FLICKR GALLERY (Albums Grid + Photos View) ============ + + // DOM Elements + const albumsView = document.getElementById('albums-view'); + const photosView = document.getElementById('photos-view'); + const albumsGrid = document.getElementById('albums-grid'); + const btnLoadAlbums = document.getElementById('btn-load-albums'); + const searchAlbums = document.getElementById('search-albums'); + const searchPhotos = document.getElementById('search-photos'); + const btnBackToAlbums = document.getElementById('btn-back-to-albums'); + const currentAlbumTitle = document.getElementById('current-album-title'); + const photosCountEl = document.getElementById('photos-count'); + const btnPrevPage = document.getElementById('btn-prev-page'); + const btnNextPage = document.getElementById('btn-next-page'); + const pageInfo = document.getElementById('page-info'); + const dragHint = document.getElementById('drag-hint'); + + // Drag state + let draggedAlbum = null; + let draggedElement = null; + + // ============ ALBUMS GRID ============ + + function renderAlbumsGrid(albums, filterText = '') { + if (!albumsGrid) return; + + // Apply order from preferences + const prefs = getAlbumPrefs(); + let orderedAlbums = [...albums]; + + // Sort by saved order if available + if (prefs.order && prefs.order.length > 0) { + const orderMap = new Map(prefs.order.map((id, idx) => [id, idx])); + orderedAlbums.sort((a, b) => { + const aIdx = orderMap.has(a.id) ? orderMap.get(a.id) : 9999; + const bIdx = orderMap.has(b.id) ? orderMap.get(b.id) : 9999; + return aIdx - bIdx; + }); + } + + // Filter by search text + if (filterText) { + const lower = filterText.toLowerCase(); + orderedAlbums = orderedAlbums.filter(a => { + const title = (a.title?._content || a.title || '').toLowerCase(); + return title.includes(lower); + }); + } + + if (orderedAlbums.length === 0) { + albumsGrid.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 + ? `${escapeHtml(title)}` + : `
📁
` + } +
+
${escapeHtml(title)}
+
${count} фото
+
+
⋮⋮
+ `; + + // Click to open album + card.addEventListener('click', (e) => { + if (e.target.closest('.album-card-drag-handle')) return; + openAlbum(album); + }); + + // Drag events + card.addEventListener('dragstart', handleDragStart); + card.addEventListener('dragend', handleDragEnd); + card.addEventListener('dragover', handleDragOver); + card.addEventListener('dragenter', handleDragEnter); + card.addEventListener('dragleave', handleDragLeave); + card.addEventListener('drop', handleDrop); + + return card; + } + + // ============ DRAG AND DROP ============ + + function handleDragStart(e) { + draggedElement = e.currentTarget; + draggedAlbum = e.currentTarget.dataset.albumId; + e.currentTarget.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', draggedAlbum); + } + + function handleDragEnd(e) { + e.currentTarget.classList.remove('dragging'); + document.querySelectorAll('.album-card.drag-over').forEach(el => { + el.classList.remove('drag-over'); + }); + draggedElement = null; + draggedAlbum = null; + } + + function handleDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + + function handleDragEnter(e) { + e.preventDefault(); + if (e.currentTarget !== draggedElement) { + e.currentTarget.classList.add('drag-over'); + } + } + + function handleDragLeave(e) { + e.currentTarget.classList.remove('drag-over'); + } + + function handleDrop(e) { + e.preventDefault(); + const targetCard = e.currentTarget; + targetCard.classList.remove('drag-over'); + + if (!draggedElement || targetCard === draggedElement) return; + + const targetId = targetCard.dataset.albumId; + const sourceId = draggedAlbum; + + // Get current order from DOM + const cards = Array.from(albumsGrid.querySelectorAll('.album-card')); + const currentOrder = cards.map(c => c.dataset.albumId); + + const sourceIdx = currentOrder.indexOf(sourceId); + const targetIdx = currentOrder.indexOf(targetId); + + if (sourceIdx === -1 || targetIdx === -1) return; + + // Move element in DOM + if (sourceIdx < targetIdx) { + targetCard.parentNode.insertBefore(draggedElement, targetCard.nextSibling); + } else { + targetCard.parentNode.insertBefore(draggedElement, targetCard); + } + + // Save new order + const newOrder = Array.from(albumsGrid.querySelectorAll('.album-card')) + .map(c => c.dataset.albumId); + saveAlbumOrder(newOrder); + + showNotification('Порядок альбомов сохранён', 'success'); + } + + function saveAlbumOrder(order) { + const prefs = getAlbumPrefs(); + prefs.order = order; + saveAlbumPrefs(prefs); + } + + // ============ ALBUM NAVIGATION ============ + + function openAlbum(album) { + state.currentAlbum = album.id; + state.currentPage = 1; + + // Update UI + if (currentAlbumTitle) { + currentAlbumTitle.textContent = album.title?._content || album.title || 'Альбом'; + } + + // Switch views + albumsView?.classList.add('hidden'); + photosView?.classList.remove('hidden'); + + // Load photos + loadPhotos(); + } + + function closeAlbum() { + state.currentAlbum = ''; + state.currentPage = 1; + + // Switch views + photosView?.classList.add('hidden'); + albumsView?.classList.remove('hidden'); + + // Clear search + if (searchPhotos) searchPhotos.value = ''; + + // Silently refresh albums in background + refreshAlbumsSilently(); + } + + // Refresh albums in background without showing loading to user + async function refreshAlbumsSilently() { + if (state.isLoadingAlbums) return; + + try { + const response = await fetch('api.php?action=flickr_albums'); + if (!response.ok) return; + + const data = await response.json(); + if (data.albums && data.albums.length > 0) { + setAlbumCache(data.albums); + window._cachedAlbums = data.albums; + // Only re-render if on albums view (photos view is hidden) + if (photosView?.classList.contains('hidden')) { + renderAlbumsGrid(data.albums); + } + } + } catch (error) { + console.log('Silent refresh failed:', error.message); + } + } + + // ============ LOAD ALBUMS (with infinite scroll) ============ + + async function loadAlbums(forceRefresh = false) { + console.log('loadAlbums called, forceRefresh:', forceRefresh); + + // Check if albumsGrid exists + if (!albumsGrid) { + console.error('albumsGrid element not found!'); + showNotification('Ошибка: элемент галереи не найден', 'error'); + return; + } + + if (state.isLoadingAlbums) { + console.log('Albums already loading, skipping...'); + showNotification('Альбомы уже загружаются...', 'info'); + return; + } + + // Reset pagination state + state.albumsPage = 1; + state.albumsTotalPages = 1; + state.allAlbums = []; + + // Check cache first + if (!forceRefresh) { + const cached = getAlbumCache(); + if (cached && cached.albums) { + console.log('Using cached albums:', cached.albums.length); + state.allAlbums = cached.albums; + state.albumsPage = cached.page || 1; + state.albumsTotalPages = cached.pages || 1; + state.albumsTotal = cached.total || cached.albums.length; + window._cachedAlbums = cached.albums; + renderAlbumsGrid(cached.albums); + showNotification(`Загружено ${cached.albums.length} альбомов (из кеша)`, 'success'); + setupAlbumsInfiniteScroll(); + return; + } else if (cached && Array.isArray(cached)) { + // Old cache format compatibility + console.log('Using old cached albums:', cached.length); + state.allAlbums = cached; + window._cachedAlbums = cached; + renderAlbumsGrid(cached); + showNotification(`Загружено ${cached.length} альбомов (из кеша)`, 'success'); + return; + } + } + + // Cancel previous request + if (albumAbortController) { + albumAbortController.abort(); + } + albumAbortController = new AbortController(); + + state.isLoadingAlbums = true; + state.albumRequestId++; + const thisRequestId = state.albumRequestId; + + // Show loading state + albumsGrid.innerHTML = ` +
+
+ Загрузка альбомов с Flickr... +
+ `; + if (dragHint) dragHint.classList.add('hidden'); + + // Also update button state + if (btnLoadAlbums) { + btnLoadAlbums.disabled = true; + btnLoadAlbums.innerHTML = ' Загрузка...'; + } + + try { + console.log('Fetching albums from API...'); + + // Add timeout + const timeoutId = setTimeout(() => { + albumAbortController.abort(); + console.error('Request timed out after 30 seconds'); + }, 30000); + + const response = await fetch(`api.php?action=flickr_albums&page=1&per_page=50`, { + signal: albumAbortController.signal + }); + + clearTimeout(timeoutId); + + console.log('API response status:', response.status); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + if (thisRequestId !== state.albumRequestId) { + console.log('Album request superseded'); + return; + } + + const data = await response.json(); + console.log('API response data:', data); + + if (data.error) { + throw new Error(data.error); + } + + if (data.albums && data.albums.length > 0) { + state.allAlbums = data.albums; + state.albumsPage = data.page || 1; + state.albumsTotalPages = data.pages || 1; + state.albumsTotal = data.total || data.albums.length; + + // Save to cache with pagination info + setAlbumCache({ + albums: data.albums, + page: state.albumsPage, + pages: state.albumsTotalPages, + total: state.albumsTotal + }); + window._cachedAlbums = data.albums; + renderAlbumsGrid(data.albums); + + const hasMore = state.albumsPage < state.albumsTotalPages; + const totalInfo = state.albumsTotal > data.albums.length + ? ` (${data.albums.length} из ${state.albumsTotal})` + : ''; + showNotification(`Загружено ${data.albums.length} альбомов${totalInfo}`, 'success'); + + // Setup infinite scroll if there are more pages + if (hasMore) { + setupAlbumsInfiniteScroll(); + } + } else { + albumsGrid.innerHTML = '

Альбомы не найдены. Проверьте настройки Flickr API.

'; + showNotification('Альбомы не найдены', 'error'); + } + } catch (error) { + if (error.name === 'AbortError') { + console.log('Request aborted'); + albumsGrid.innerHTML = ` +
+

⏱️

+

Превышено время ожидания. Flickr API не отвечает.

+ +
+ `; + showNotification('Таймаут: сервер не отвечает', 'error'); + return; + } + console.error('Ошибка загрузки альбомов:', error); + albumsGrid.innerHTML = ` +
+

⚠️

+

Ошибка загрузки: ${escapeHtml(error.message)}

+ +
+ `; + 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 = ` +
+
+ Загрузка фотографий... +
+ `; + + try { + const params = new URLSearchParams({ + action: 'flickr_photos', + page: 1, + per_page: 50 + }); + + if (state.currentAlbum) params.append('album_id', state.currentAlbum); + if (searchPhotos?.value.trim()) params.append('search', searchPhotos.value.trim()); + + const response = await fetch('api.php?' + params, { + signal: photoAbortController.signal + }); + + if (thisRequestId !== state.photoRequestId) { + console.log('Photo request superseded'); + return; + } + + const data = await response.json(); + + if (data.error) { + 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 ? '
' : ''; + + div.innerHTML = ` +
+ ${videoBadge} + ${escapeHtml(photo.title)} +
${escapeHtml(photo.title)}
+ + `; + + // Checkbox click = toggle selection + div.querySelector('.checkbox').addEventListener('click', (e) => { + e.stopPropagation(); + togglePhotoSelection(div, photo); + }); + + // Image click = open lightbox + div.querySelector('img').addEventListener('click', (e) => { + e.stopPropagation(); + openLightbox(photo); + }); + + // Preview button = open lightbox + div.querySelector('.photo-preview-btn').addEventListener('click', (e) => { + e.stopPropagation(); + openLightbox(photo); + }); + + // Background click = toggle selection + div.addEventListener('click', () => togglePhotoSelection(div, photo)); + + photoGallery.appendChild(div); + }); + } + + // Load more photos (infinite scroll) + async function loadMorePhotos() { + if (state.isLoadingMorePhotos || state.currentPage >= state.totalPages) { + return; + } + + state.isLoadingMorePhotos = true; + const nextPage = state.currentPage + 1; + + // Show loading indicator at bottom + const loadingEl = document.createElement('div'); + loadingEl.className = 'photos-loading-more'; + loadingEl.innerHTML = ` +
+ Загрузка фотографий... + `; + photoGallery.appendChild(loadingEl); + + try { + const params = new URLSearchParams({ + action: 'flickr_photos', + page: nextPage, + per_page: 50 + }); + + if (state.currentAlbum) params.append('album_id', state.currentAlbum); + if (searchPhotos?.value.trim()) params.append('search', searchPhotos.value.trim()); + + const response = await fetch('api.php?' + params); + const data = await response.json(); + + // Remove loading indicator + loadingEl.remove(); + + if (data.error) { + throw new Error(data.error); + } + + if (data.photos && data.photos.length > 0) { + state.currentPage = data.pagination?.page || nextPage; + state.allPhotos = [...state.allPhotos, ...data.photos]; + + // Append new photos + renderPhotos(data.photos, true); + updatePagination(); + + console.log(`Loaded photos page ${nextPage}/${state.totalPages}, total: ${state.allPhotos.length}`); + } + } catch (error) { + loadingEl.remove(); + console.error('Error loading more photos:', error); + showNotification('Ошибка загрузки фотографий: ' + error.message, 'error'); + } finally { + state.isLoadingMorePhotos = false; + } + } + + // Setup infinite scroll observer for photos + let photosScrollObserver = null; + + function setupPhotosInfiniteScroll() { + // Remove existing observer + if (photosScrollObserver) { + photosScrollObserver.disconnect(); + } + + // Create sentinel element + let sentinel = document.getElementById('photos-scroll-sentinel'); + if (!sentinel) { + sentinel = document.createElement('div'); + sentinel.id = 'photos-scroll-sentinel'; + sentinel.style.cssText = 'height: 20px; width: 100%; clear: both;'; + } + + // Append sentinel after gallery + if (photoGallery && photoGallery.parentNode) { + photoGallery.parentNode.insertBefore(sentinel, photoGallery.nextSibling); + } + + // Create intersection observer + photosScrollObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && !state.isLoadingMorePhotos && state.currentPage < state.totalPages) { + console.log('Photos sentinel visible, loading more...'); + loadMorePhotos(); + } + }); + }, { + root: null, + rootMargin: '300px', + threshold: 0 + }); + + photosScrollObserver.observe(sentinel); + } + + function togglePhotoSelection(element, photo) { + const index = state.selectedPhotos.findIndex(p => p.id === photo.id); + + if (index === -1) { + if (!canAddPhotos(1)) return; + state.selectedPhotos.push(photo); + element.classList.add('selected'); + } else { + state.selectedPhotos.splice(index, 1); + element.classList.remove('selected'); + } + + updateSelectionUI(); + saveSelectedPhotos(); // Save to localStorage + } + + function updatePagination() { + // Show loaded/total info for infinite scroll + const loaded = state.allPhotos.length; + const total = state.totalPages * 50; // Approximate total + if (pageInfo) { + if (state.currentPage < state.totalPages) { + pageInfo.textContent = `Загружено: ${loaded}`; + } else { + pageInfo.textContent = `Все фото загружены`; + } + } + // Hide old pagination buttons (infinite scroll replaces them) + if (btnPrevPage) btnPrevPage.style.display = 'none'; + if (btnNextPage) btnNextPage.style.display = 'none'; + } + + // ============ EVENT LISTENERS ============ + + // Load albums button + if (btnLoadAlbums) { + console.log('btnLoadAlbums found, attaching click listener'); + btnLoadAlbums.addEventListener('click', () => { + console.log('Load albums button clicked!'); + loadAlbums(true); + }); + } else { + console.error('btnLoadAlbums element not found! Check ID: btn-load-albums'); + } + + // Back to albums + btnBackToAlbums?.addEventListener('click', closeAlbum); + + // Search albums + let albumSearchTimeout; + searchAlbums?.addEventListener('input', () => { + clearTimeout(albumSearchTimeout); + albumSearchTimeout = setTimeout(() => { + if (window._cachedAlbums) { + renderAlbumsGrid(window._cachedAlbums, searchAlbums.value.trim()); + } + }, 300); + }); + + // Search photos + let photoSearchTimeout; + searchPhotos?.addEventListener('input', () => { + clearTimeout(photoSearchTimeout); + photoSearchTimeout = setTimeout(() => { + state.currentPage = 1; + loadPhotos(); + }, 500); + }); + + // Pagination + btnPrevPage?.addEventListener('click', () => { + if (state.currentPage > 1) { + state.currentPage--; + loadPhotos(); + } + }); + + btnNextPage?.addEventListener('click', () => { + if (state.currentPage < state.totalPages) { + state.currentPage++; + loadPhotos(); + } + }); + + // ============ FLOATING ACTION BAR ============ + + document.getElementById('btn-select-all')?.addEventListener('click', () => { + document.querySelectorAll('.photo-item').forEach(item => { + if (getTotalPhotosCount() >= MAX_PHOTOS) return; + const photo = JSON.parse(item.dataset.photoData); + if (!state.selectedPhotos.find(p => p.id === photo.id)) { + state.selectedPhotos.push(photo); + item.classList.add('selected'); + } + }); + if (getTotalPhotosCount() >= MAX_PHOTOS) { + showNotification(`Выбрано максимум ${MAX_PHOTOS} фото`, 'info'); + } + updateSelectionUI(); + saveSelectedPhotos(); + }); + + document.getElementById('btn-deselect-all')?.addEventListener('click', () => { + state.selectedPhotos = []; + document.querySelectorAll('.photo-item').forEach(item => item.classList.remove('selected')); + updateSelectionUI(); + saveSelectedPhotos(); + }); + + document.getElementById('btn-convert-selected')?.addEventListener('click', () => { + if (state.selectedPhotos.length === 0) { + showNotification('Сначала выберите фотографии', 'error'); + return; + } + const urls = state.selectedPhotos.map(p => p.page_url).join('\n'); + if (inputUrls) inputUrls.value = urls; + document.querySelector('.nav-btn[data-tab="converter"]')?.click(); + }); + + document.getElementById('btn-telegram-selected')?.addEventListener('click', () => { + if (state.selectedPhotos.length === 0) { + showNotification('Сначала выберите фотографии', 'error'); + return; + } + document.querySelector('.nav-btn[data-tab="posting"]')?.click(); + }); + + // Get best quality URL for photo (prefer original, but only use actual URLs) + function getBestPhotoUrl(photo) { + // Priority: original > large2048 > large > medium640 > medium + // Only return URLs that actually exist (not null/undefined) + const urls = photo.urls || {}; + return urls.original || urls.large2048 || urls.large || urls.medium640 || urls.medium; + } + + // Get preview URL (medium size for lightbox) + function getPreviewUrl(photo) { + return photo.urls.large || photo.urls.medium640 || photo.urls.medium || photo.urls.original; + } + + // Get file extension from photo + function getPhotoExtension(photo) { + return photo.original_format || 'jpg'; + } + + // ============ SERVER-SIDE DOWNLOAD (bypasses CORS) ============ + + // Download single photo via server proxy + function downloadSinglePhoto(photo) { + const url = getBestPhotoUrl(photo); + const ext = getPhotoExtension(photo); + const title = (photo.title || photo.id).replace(/[<>:"/\\|?*]/g, '_').substring(0, 100); + + // Create download URL through our server proxy + const downloadUrl = `download.php?action=photo&url=${encodeURIComponent(url)}&filename=${encodeURIComponent(title)}&format=${ext}`; + + // Trigger download via hidden iframe or link + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = `${title}.${ext}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + // Download multiple photos as individual files (sequential) + async function downloadPhotosIndividually(photos) { + showNotification(`Скачивание ${photos.length} фото...`, 'info'); + + for (let i = 0; i < photos.length; i++) { + downloadSinglePhoto(photos[i]); + // Small delay between downloads to avoid overwhelming browser + if (i < photos.length - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + showNotification(`✓ Загрузка ${photos.length} фото начата`, 'success'); + } + + // Download multiple photos as ZIP archive + function downloadPhotosAsZip(photos, albumName = '') { + showNotification(`Подготовка архива: ${photos.length} фото...`, 'info'); + + // Prepare photo data for server + const photoData = photos.map(photo => ({ + id: photo.id, + url: getBestPhotoUrl(photo), + title: photo.title || photo.id, + format: getPhotoExtension(photo) + })); + + try { + // Create form for POST request + const form = document.createElement('form'); + form.method = 'POST'; + form.action = 'download.php?action=zip'; + form.style.display = 'none'; + + const photosInput = document.createElement('input'); + photosInput.type = 'hidden'; + photosInput.name = 'photos'; + photosInput.value = JSON.stringify(photoData); + form.appendChild(photosInput); + + const albumInput = document.createElement('input'); + albumInput.type = 'hidden'; + albumInput.name = 'album_name'; + albumInput.value = albumName; + form.appendChild(albumInput); + + document.body.appendChild(form); + form.submit(); + document.body.removeChild(form); + + showNotification('✓ Создание архива на сервере...', 'success'); + } catch (error) { + console.error('Failed to create ZIP:', error); + showNotification('Ошибка создания архива', 'error'); + } + } + + // Show download format choice dialog + function showDownloadChoiceDialog(photos, albumName = '') { + // Remove existing dialog if any + const existing = document.getElementById('download-choice-dialog'); + if (existing) existing.remove(); + + const dialog = document.createElement('div'); + dialog.id = 'download-choice-dialog'; + dialog.className = 'download-dialog-overlay'; + dialog.innerHTML = ` +
+

Скачать ${photos.length} фото

+

Выберите формат загрузки:

+
+ + +
+ +
+ `; + + dialog.querySelector('[data-action="zip"]').addEventListener('click', () => { + dialog.remove(); + downloadPhotosAsZip(photos, albumName); + }); + + dialog.querySelector('[data-action="individual"]').addEventListener('click', () => { + dialog.remove(); + downloadPhotosIndividually(photos); + }); + + dialog.querySelector('.download-dialog-close').addEventListener('click', () => { + dialog.remove(); + }); + + dialog.querySelector('.download-dialog-overlay')?.addEventListener('click', (e) => { + if (e.target === dialog) dialog.remove(); + }); + + // Close on backdrop click + dialog.addEventListener('click', (e) => { + if (e.target === dialog) dialog.remove(); + }); + + document.body.appendChild(dialog); + } + + // Download photos - single directly, multiple with choice + async function downloadPhotos(photos, albumName = '') { + if (photos.length === 0) { + showNotification('Нет фото для скачивания', 'error'); + return; + } + + // Single photo - download directly via server proxy + if (photos.length === 1) { + showNotification('Скачивание фото...', 'info'); + downloadSinglePhoto(photos[0]); + showNotification('✓ Загрузка начата', 'success'); + return; + } + + // Multiple photos - show choice dialog + showDownloadChoiceDialog(photos, albumName); + } + + // ============ PHOTO LIGHTBOX (Preview) ============ + + let lightboxPhoto = null; + + function createLightbox() { + // Create lightbox container if not exists + if (document.getElementById('photo-lightbox')) return; + + const lightbox = document.createElement('div'); + lightbox.id = 'photo-lightbox'; + lightbox.className = 'lightbox hidden'; + lightbox.innerHTML = ` + + + `; + + document.body.appendChild(lightbox); + + // Event listeners + lightbox.querySelector('.lightbox-backdrop').addEventListener('click', closeLightbox); + lightbox.querySelector('.lightbox-close').addEventListener('click', closeLightbox); + lightbox.querySelector('.lightbox-btn-select').addEventListener('click', lightboxSelectPhoto); + lightbox.querySelector('.lightbox-btn-download').addEventListener('click', lightboxDownloadPhoto); + lightbox.querySelector('.lightbox-prev').addEventListener('click', lightboxPrevPhoto); + lightbox.querySelector('.lightbox-next').addEventListener('click', lightboxNextPhoto); + + // Keyboard navigation + document.addEventListener('keydown', handleLightboxKeyboard); + } + + function openLightbox(photo) { + createLightbox(); + lightboxPhoto = photo; + + const lightbox = document.getElementById('photo-lightbox'); + const img = lightbox.querySelector('.lightbox-image'); + const videoContainer = lightbox.querySelector('.lightbox-video-container'); + const video = lightbox.querySelector('.lightbox-video'); + const title = lightbox.querySelector('.lightbox-title'); + const loading = lightbox.querySelector('.lightbox-loading'); + const selectBtn = lightbox.querySelector('.lightbox-btn-select'); + const flickrBtn = lightbox.querySelector('.lightbox-btn-flickr'); + const downloadBtn = lightbox.querySelector('.lightbox-btn-download'); + + const isVideo = photo.is_video; + + // Show loading + loading.classList.remove('hidden'); + + // Update Flickr link + if (flickrBtn) { + flickrBtn.href = photo.page_url || '#'; + } + + // Hide video container (not used due to Flickr restrictions) + videoContainer.style.display = 'none'; + video.src = ''; + + // Show image (for both photos and video thumbnails) + img.style.display = 'block'; + img.style.opacity = '0'; + img.src = getPreviewUrl(photo); + img.alt = photo.title || ''; + + // Handle video play overlay + let playOverlay = lightbox.querySelector('.video-play-overlay'); + if (!playOverlay) { + playOverlay = document.createElement('div'); + playOverlay.className = 'video-play-overlay'; + playOverlay.innerHTML = ` +
+
Смотреть на Flickr
+ `; + lightbox.querySelector('.lightbox-image-container').appendChild(playOverlay); + } + + if (isVideo) { + // Show play overlay for videos + playOverlay.style.display = 'flex'; + playOverlay.onclick = () => window.open(photo.page_url, '_blank'); + if (downloadBtn) downloadBtn.style.display = 'none'; + } else { + // Hide play overlay for photos + playOverlay.style.display = 'none'; + if (downloadBtn) downloadBtn.style.display = ''; + } + + // When image loads + img.onload = () => { + loading.classList.add('hidden'); + img.style.opacity = '1'; + }; + + img.onerror = () => { + loading.classList.add('hidden'); + img.style.opacity = '1'; + }; + + title.textContent = (isVideo ? '▶ ' : '') + (photo.title || 'Без названия'); + + // Update select button state + const isSelected = state.selectedPhotos.find(p => p.id === photo.id); + selectBtn.innerHTML = isSelected + ? ' Выбрано' + : '+ Выбрать'; + selectBtn.className = isSelected ? 'btn btn-success lightbox-btn-select' : 'btn btn-primary lightbox-btn-select'; + + // Show lightbox + lightbox.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + + updateLightboxNavigation(); + } + + function closeLightbox() { + const lightbox = document.getElementById('photo-lightbox'); + if (lightbox) { + // Stop video if playing + const video = lightbox.querySelector('.lightbox-video'); + if (video) video.src = ''; + + lightbox.classList.add('hidden'); + document.body.style.overflow = ''; + lightboxPhoto = null; + } + } + + function lightboxSelectPhoto() { + if (!lightboxPhoto) return; + + const index = state.selectedPhotos.findIndex(p => p.id === lightboxPhoto.id); + + if (index === -1) { + if (!canAddPhotos(1)) return; + state.selectedPhotos.push(lightboxPhoto); + } else { + state.selectedPhotos.splice(index, 1); + } + + // Update button + const selectBtn = document.querySelector('.lightbox-btn-select'); + const isSelected = state.selectedPhotos.find(p => p.id === lightboxPhoto.id); + selectBtn.innerHTML = isSelected + ? ' Выбрано' + : '+ Выбрать'; + selectBtn.className = isSelected ? 'btn btn-success lightbox-btn-select' : 'btn btn-primary lightbox-btn-select'; + + // Update gallery + updateSelectionUI(); + syncGallerySelection(); + saveSelectedPhotos(); + + showNotification(isSelected ? 'Фото добавлено' : 'Фото удалено', 'info'); + } + + function lightboxDownloadPhoto() { + if (!lightboxPhoto) return; + showNotification('Скачивание...', 'info'); + downloadSinglePhoto(lightboxPhoto); + } + + function getPhotosList() { + return Array.from(document.querySelectorAll('.photo-item')).map(item => { + try { + return JSON.parse(item.dataset.photoData); + } catch (e) { + return null; + } + }).filter(p => p !== null); + } + + function lightboxPrevPhoto() { + if (!lightboxPhoto) return; + const photos = getPhotosList(); + const currentIdx = photos.findIndex(p => p.id === lightboxPhoto.id); + if (currentIdx > 0) { + openLightbox(photos[currentIdx - 1]); + } + } + + function lightboxNextPhoto() { + if (!lightboxPhoto) return; + const photos = getPhotosList(); + const currentIdx = photos.findIndex(p => p.id === lightboxPhoto.id); + if (currentIdx < photos.length - 1) { + openLightbox(photos[currentIdx + 1]); + } + } + + function updateLightboxNavigation() { + const lightbox = document.getElementById('photo-lightbox'); + if (!lightbox || !lightboxPhoto) return; + + const photos = getPhotosList(); + const currentIdx = photos.findIndex(p => p.id === lightboxPhoto.id); + + const prevBtn = lightbox.querySelector('.lightbox-prev'); + const nextBtn = lightbox.querySelector('.lightbox-next'); + + prevBtn.style.visibility = currentIdx > 0 ? 'visible' : 'hidden'; + nextBtn.style.visibility = currentIdx < photos.length - 1 ? 'visible' : 'hidden'; + } + + function handleLightboxKeyboard(e) { + const lightbox = document.getElementById('photo-lightbox'); + if (!lightbox || lightbox.classList.contains('hidden')) return; + + switch (e.key) { + case 'Escape': + closeLightbox(); + break; + case 'ArrowLeft': + lightboxPrevPhoto(); + break; + case 'ArrowRight': + lightboxNextPhoto(); + break; + case ' ': + e.preventDefault(); + lightboxSelectPhoto(); + break; + } + } + + // Download selected photos + document.getElementById('btn-download-selected')?.addEventListener('click', () => { + if (state.selectedPhotos.length === 0) { + showNotification('Сначала выберите фотографии', 'error'); + return; + } + downloadPhotos(state.selectedPhotos); + }); + + // Download all photos in current album + window.downloadAllPhotos = async function() { + if (!state.currentAlbum) { + showNotification('Сначала откройте альбом', 'error'); + return; + } + + // Get album name for ZIP filename + const albumName = currentAlbumTitle?.textContent?.replace(/[<>:"/\\|?*]/g, '_') || 'album'; + + showNotification('Получение списка фото альбома...', 'info'); + + try { + // Fetch all photos from the album (increase per_page for full album) + const params = new URLSearchParams({ + action: 'flickr_photos', + album_id: state.currentAlbum, + page: 1, + per_page: 500 // Get more photos at once + }); + + const response = await fetch('api.php?' + params); + const data = await response.json(); + + if (data.error) { + showNotification('Ошибка: ' + data.error, 'error'); + return; + } + + if (!data.photos || data.photos.length === 0) { + showNotification('Альбом пуст', 'error'); + return; + } + + // Download all photos as ZIP with album name + downloadPhotos(data.photos, albumName); + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + } + }; + + // ============ MULTI-PLATFORM POSTING ============ + + async function loadTelegramStatus() { + const statusEl = document.getElementById('tg-bot-status'); + const statusMini = document.getElementById('tg-status-mini'); + const tgChannel = document.getElementById('tg-channel'); + + try { + const response = await fetch('api.php?action=telegram_status'); + const data = await response.json(); + + const connected = data.connected; + const text = connected ? `@${data.bot_username}` : (data.message || 'Не подключён'); + + if (statusEl) { + statusEl.className = `status ${connected ? 'connected' : 'disconnected'}`; + statusEl.textContent = connected ? `Подключён: ${text}` : text; + } + if (statusMini) { + statusMini.className = `status-mini ${connected ? 'connected' : ''}`; + statusMini.textContent = connected ? 'Подключён' : 'Не подключён'; + } + } catch (error) { + if (statusEl) { + statusEl.className = 'status disconnected'; + statusEl.textContent = 'Ошибка проверки'; + } + } + + // Load channels + try { + const response = await fetch('api.php?action=telegram_channels'); + const data = await response.json(); + + if (data.channels && tgChannel) { + tgChannel.innerHTML = ''; + data.channels.forEach(channel => { + const option = document.createElement('option'); + option.value = channel.id || channel; + option.textContent = channel.name || channel; + tgChannel.appendChild(option); + }); + // Auto-select if only one channel + if (data.channels.length === 1) { + tgChannel.selectedIndex = 1; + } + } + } catch (error) { + console.error('Ошибка загрузки каналов:', error); + } + } + + async function loadVKStatus() { + const statusEl = document.getElementById('vk-status'); + const statusMini = document.getElementById('vk-status-mini'); + const vkGroup = document.getElementById('vk-group'); + + try { + const response = await fetch('api.php?action=vk_status'); + const data = await response.json(); + + const connected = data.connected; + const text = connected ? (data.user_name || 'VK') : (data.message || 'Не подключён'); + + if (statusEl) { + statusEl.className = `status ${connected ? 'connected' : 'disconnected'}`; + statusEl.textContent = connected ? `Подключён: ${text}` : text; + } + if (statusMini) { + statusMini.className = `status-mini ${connected ? 'connected' : ''}`; + statusMini.textContent = connected ? 'Подключён' : 'Не подключён'; + } + } catch (error) { + if (statusEl) { + statusEl.className = 'status disconnected'; + statusEl.textContent = 'Ошибка проверки'; + } + } + + // Load groups + try { + const response = await fetch('api.php?action=vk_groups'); + const data = await response.json(); + + if (data.groups && vkGroup) { + vkGroup.innerHTML = ''; + data.groups.forEach(group => { + const option = document.createElement('option'); + option.value = group.id; + option.textContent = group.name; + vkGroup.appendChild(option); + }); + // Auto-select if only one group (or community token) + if (data.groups.length === 1) { + vkGroup.selectedIndex = 1; + } + } + } catch (error) { + console.error('Ошибка загрузки групп VK:', error); + } + } + + // Send post (immediate or scheduled) + document.getElementById('btn-send-post')?.addEventListener('click', async () => { + const postText = document.getElementById('post-text'); + let baseText = postText?.value || ''; + const parseMode = document.getElementById('post-parse-mode')?.value || 'HTML'; + const isScheduled = document.getElementById('chk-schedule')?.checked; + const scheduledTime = document.getElementById('scheduled-datetime')?.value; + + // Check scheduled time + if (isScheduled) { + if (!scheduledTime) { + showNotification('Укажите дату и время публикации', 'error'); + return; + } + const scheduledDate = new Date(scheduledTime); + if (scheduledDate <= new Date()) { + showNotification('Время публикации должно быть в будущем', 'error'); + return; + } + } + + // Add tags to text + const tagsString = getTagsString('post'); + const currentTags = tagContexts?.post || []; + if (tagsString) { + baseText = baseText.trim() ? baseText.trim() + '\n\n' + tagsString : tagsString; + } + + const platforms = []; + const tgChannel = document.getElementById('tg-channel'); + const vkGroup = document.getElementById('vk-group'); + + const postToTelegram = document.getElementById('chk-telegram')?.checked && tgChannel?.value; + const postToVk = document.getElementById('chk-vk')?.checked && vkGroup?.value; + + if (postToTelegram) { + platforms.push({ type: 'telegram', target: tgChannel.value }); + } + if (postToVk) { + platforms.push({ type: 'vk', target: vkGroup.value }); + } + + if (platforms.length === 0) { + showNotification('Выберите платформу и канал/группу', 'error'); + return; + } + + if (state.selectedPhotos.length === 0 && state.uploadedFiles.length === 0 && !baseText.trim()) { + showNotification('Добавьте фото или текст', 'error'); + return; + } + + const btnSendPost = document.getElementById('btn-send-post'); + const postResult = document.getElementById('post-result'); + + // Handle scheduled posting + if (isScheduled) { + if (btnSendPost) { + btnSendPost.disabled = true; + btnSendPost.textContent = 'Планирование...'; + } + + try { + const photoUrls = state.selectedPhotos.map(p => p.urls.large || p.urls.original || p.urls.medium640); + const uploadedFileUrls = state.uploadedFiles + .filter(f => f.url && !f.uploading) + .map(f => ({ url: f.url, type: f.type })); + + const formData = new FormData(); + const isEditing = scheduledState.editingPostId !== null; + formData.append('action', isEditing ? 'update_scheduled_post' : 'create_scheduled_post'); + if (isEditing) { + formData.append('id', scheduledState.editingPostId); + } + formData.append('text', postText?.value || ''); + formData.append('tags', JSON.stringify(currentTags)); + formData.append('photos', JSON.stringify(photoUrls)); + formData.append('uploaded_files', JSON.stringify(uploadedFileUrls)); + formData.append('platforms', JSON.stringify(platforms)); + formData.append('scheduled_time', scheduledTime.replace('T', ' ')); + // Save cross-promo state + const crossPromoEnabled = document.getElementById('chk-cross-promo')?.checked || false; + formData.append('cross_promo', crossPromoEnabled ? '1' : '0'); + + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.error) { + showNotification('Ошибка: ' + data.error, 'error'); + } else { + showNotification(isEditing ? 'Пост обновлён!' : 'Пост запланирован!', 'success'); + + // Clear form + state.selectedPhotos = []; + state.uploadedFiles = []; + updatePostingPreview(); + updateSelectionUI(); + syncGallerySelection(); + if (postText) postText.value = ''; + if (typeof tagContexts !== 'undefined') { + tagContexts.post = []; + const tagsList = document.getElementById('post-tags-list'); + if (tagsList) tagsList.innerHTML = ''; + } + clearPostDraft(); + + // Reset schedule UI and editing state + document.getElementById('chk-schedule').checked = false; + scheduledState.editingPostId = null; + updateScheduleUI(); + + // Refresh scheduled posts list + loadScheduledPosts(); + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + } finally { + if (btnSendPost) { + btnSendPost.disabled = false; + btnSendPost.innerHTML = '📅 Запланировать'; + } + } + return; + } + + if (btnSendPost) { + btnSendPost.disabled = true; + btnSendPost.textContent = 'Публикация...'; + } + + try { + // Check if there are files still uploading + const stillUploading = state.uploadedFiles.some(f => f.uploading); + if (stillUploading) { + showNotification('Дождитесь завершения загрузки файлов', 'error'); + if (btnSendPost) { + btnSendPost.disabled = false; + btnSendPost.textContent = 'Опубликовать'; + } + return; + } + + const photoUrls = state.selectedPhotos.map(p => p.urls.large || p.urls.original || p.urls.medium640); + + // Get uploaded files that finished uploading + const uploadedFileUrls = state.uploadedFiles + .filter(f => f.url && !f.uploading) + .map(f => ({ url: f.url, type: f.type })); + + // Check cross-promo settings + const crossPromoEnabled = document.getElementById('chk-cross-promo')?.checked; + // Get settings from localStorage, but also check current input values as fallback + const storedSettings = getCrossPromoSettings(); + const crossPromoSettings = { + telegramLink: storedSettings.telegramLink || document.getElementById('cross-promo-telegram')?.value.trim() || '', + vkLink: storedSettings.vkLink || document.getElementById('cross-promo-vk')?.value.trim() || '', + textForTg: storedSettings.textForTg || document.getElementById('cross-promo-text-tg')?.value.trim() || 'Мой канал ВКонтакте', + textForVk: storedSettings.textForVk || document.getElementById('cross-promo-text-vk')?.value.trim() || 'Мой канал в Telegram' + }; + + // Prepare platform-specific texts + let textForTelegram = baseText; + let textForVk = baseText; + + if (crossPromoEnabled) { + console.log('Cross-promo enabled, settings:', crossPromoSettings); + // Add VK link to Telegram post + if (crossPromoSettings.vkLink && postToTelegram) { + const linkText = crossPromoSettings.textForTg || 'Мой канал ВКонтакте'; + if (parseMode === 'HTML') { + textForTelegram += `\n\n${linkText}`; + } else if (parseMode === 'Markdown') { + textForTelegram += `\n\n[${linkText}](${crossPromoSettings.vkLink})`; + } else { + textForTelegram += `\n\n${linkText}: ${crossPromoSettings.vkLink}`; + } + } + // Add Telegram link to VK post + if (crossPromoSettings.telegramLink && postToVk) { + const linkText = crossPromoSettings.textForVk || 'Мой канал в Telegram'; + textForVk += `\n\n${linkText}: ${crossPromoSettings.telegramLink}`; + } + } + + // Send to each platform with appropriate text + const results = {}; + + for (const platform of platforms) { + const formData = new FormData(); + formData.append('action', 'multi_post'); + formData.append('platforms', JSON.stringify([platform])); + formData.append('text', platform.type === 'telegram' ? textForTelegram : textForVk); + formData.append('photos', JSON.stringify(photoUrls)); + formData.append('uploaded_files', JSON.stringify(uploadedFileUrls)); + formData.append('parse_mode', parseMode); + + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.results) { + Object.assign(results, data.results); + } else if (data.error) { + results[platform.type] = { success: false, error: data.error }; + } + } + + // Process results + let resultText = 'Результаты:\n'; + let hasErrors = false; + + Object.entries(results).forEach(([platform, result]) => { + const name = platform === 'telegram' ? 'Telegram' : platform === 'vk' ? 'ВКонтакте' : platform; + if (result.success) { + if (result.warning) { + resultText += `⚠ ${name}: Опубликовано с предупреждением\n ${result.warning}\n`; + } else { + resultText += `✓ ${name}: Успешно\n`; + } + } else { + resultText += `✗ ${name}: ${result.error}\n`; + hasErrors = true; + } + }); + + if (postResult) { + postResult.className = hasErrors ? 'result-message error' : 'result-message success'; + postResult.textContent = resultText; + postResult.style.display = 'block'; + } + + if (!hasErrors) { + state.selectedPhotos = []; + state.uploadedFiles = []; // Clear uploaded files + updatePostingPreview(); + updateSelectionUI(); + syncGallerySelection(); + if (postText) postText.value = ''; + // Clear tags + if (typeof tagContexts !== 'undefined') { + tagContexts.post = []; + const tagsList = document.getElementById('post-tags-list'); + if (tagsList) tagsList.innerHTML = ''; + } + // Clear draft + clearPostDraft(); + showNotification('Публикация завершена!', 'success'); + } + } catch (error) { + if (postResult) { + postResult.className = 'result-message error'; + postResult.textContent = 'Ошибка: ' + error.message; + postResult.style.display = 'block'; + } + } finally { + if (btnSendPost) { + btnSendPost.disabled = false; + btnSendPost.textContent = 'Опубликовать'; + } + } + }); + + // ============ SETTINGS ============ + + document.getElementById('btn-change-password')?.addEventListener('click', async () => { + const currentPassword = document.getElementById('current-password')?.value; + const newPassword = document.getElementById('new-password')?.value; + const confirmPassword = document.getElementById('confirm-password')?.value; + + if (!currentPassword || !newPassword) { + showNotification('Заполните все поля', 'error'); + return; + } + if (newPassword !== confirmPassword) { + showNotification('Пароли не совпадают', 'error'); + return; + } + if (newPassword.length < 8) { + showNotification('Минимум 8 символов', 'error'); + return; + } + + try { + const formData = new FormData(); + formData.append('action', 'change_password'); + formData.append('current_password', currentPassword); + formData.append('new_password', newPassword); + + 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'); + document.getElementById('current-password').value = ''; + document.getElementById('new-password').value = ''; + document.getElementById('confirm-password').value = ''; + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + } + }); + + // ============ VK TOKEN MANAGEMENT ============ + + // Toggle VK token visibility + document.getElementById('btn-toggle-vk-token')?.addEventListener('click', () => { + const input = document.getElementById('vk-token-input'); + if (input) { + input.type = input.type === 'password' ? 'text' : 'password'; + } + }); + + // Save VK token + document.getElementById('btn-save-vk-token')?.addEventListener('click', async () => { + const tokenInput = document.getElementById('vk-token-input'); + const token = tokenInput?.value.trim(); + const statusSpan = document.getElementById('vk-token-save-status'); + + if (!token) { + showNotification('Введите токен', 'error'); + return; + } + + const btn = document.getElementById('btn-save-vk-token'); + if (btn) { + btn.disabled = true; + btn.textContent = 'Сохранение...'; + } + + try { + const formData = new FormData(); + formData.append('action', 'save_vk_token'); + formData.append('token', token); + + const response = await fetch('api.php', { method: 'POST', body: formData }); + const data = await response.json(); + + if (data.error) { + showNotification('Ошибка: ' + data.error, 'error'); + if (statusSpan) { + statusSpan.textContent = '✗ Ошибка'; + statusSpan.className = 'save-status error'; + } + } else { + let message = 'Токен сохранён'; + if (data.validation?.valid) { + const type = data.validation.type === 'user' ? 'пользовательский' : 'community'; + const name = data.validation.user_name || ''; + message += ` (${type}${name ? ': ' + name : ''})`; + + // Update VK status display + const vkStatus = document.getElementById('vk-status'); + if (vkStatus) { + vkStatus.textContent = `Подключён (${type})`; + vkStatus.className = 'status connected'; + } + } + showNotification(message, 'success'); + if (statusSpan) { + statusSpan.textContent = '✓ Сохранено'; + statusSpan.className = 'save-status success'; + } + + // Reload VK groups + loadVkGroups(); + } + } catch (error) { + showNotification('Ошибка: ' + error.message, 'error'); + if (statusSpan) { + statusSpan.textContent = '✗ Ошибка'; + statusSpan.className = 'save-status error'; + } + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = 'Сохранить'; + } + } + }); + + // ============ TAGS MANAGEMENT SYSTEM ============ + + const TAGS_STORAGE_KEY = 'vh_tags_history'; + const MAX_TAGS_HISTORY = 100; + + // Store for current tags in each context + const tagContexts = { + post: [], + converter: [] + }; + + // Load tags history from localStorage + function getTagsHistory() { + try { + const data = localStorage.getItem(TAGS_STORAGE_KEY); + return data ? JSON.parse(data) : []; + } catch (e) { + return []; + } + } + + // Save tag to history + function saveTagToHistory(tag) { + const history = getTagsHistory(); + const existingIndex = history.findIndex(t => t.name.toLowerCase() === tag.toLowerCase()); + + if (existingIndex >= 0) { + // Increment usage count + history[existingIndex].count++; + history[existingIndex].lastUsed = Date.now(); + } else { + // Add new tag + history.push({ + name: tag, + count: 1, + lastUsed: Date.now() + }); + } + + // Sort by count and limit + history.sort((a, b) => b.count - a.count); + if (history.length > MAX_TAGS_HISTORY) { + history.splice(MAX_TAGS_HISTORY); + } + + localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(history)); + } + + // Get tag suggestions based on input + function getTagSuggestions(input, excludeTags = []) { + const history = getTagsHistory(); + const search = input.toLowerCase(); + + return history + .filter(t => t.name.toLowerCase().includes(search) && !excludeTags.includes(t.name)) + .slice(0, 10); + } + + // Initialize tag input for a context + function initTagInput(context, inputId, listId, suggestionsId) { + const input = document.getElementById(inputId); + const list = document.getElementById(listId); + const suggestions = document.getElementById(suggestionsId); + + if (!input || !list) return; + + // Render current tags + function renderTags() { + list.innerHTML = tagContexts[context].map(tag => ` + + #${escapeHtml(tag)} + + + `).join(''); + } + + // Add tag + function addTag(tag) { + tag = tag.trim().replace(/^#/, '').replace(/[,\s]+/g, ''); + if (!tag || tagContexts[context].includes(tag)) return; + + tagContexts[context].push(tag); + saveTagToHistory(tag); + renderTags(); + input.value = ''; + hideSuggestions(); + // Save draft immediately when tags change in post context + if (context === 'post') { + savePostDraftNow(); + } + } + + // Remove tag + function removeTag(tag) { + tagContexts[context] = tagContexts[context].filter(t => t !== tag); + renderTags(); + // Save draft immediately when tags change in post context + if (context === 'post') { + savePostDraftNow(); + } + } + + // Show suggestions + function showSuggestions(query) { + if (!suggestions) return; + const items = getTagSuggestions(query, tagContexts[context]); + + if (items.length === 0 && query.length < 2) { + // Show recent tags if no query + const recent = getTagsHistory().slice(0, 8); + if (recent.length > 0) { + suggestions.innerHTML = recent.map(t => ` +
+ #${escapeHtml(t.name)} + ${t.count}× +
+ `).join(''); + suggestions.classList.add('visible'); + } + return; + } + + if (items.length === 0) { + hideSuggestions(); + return; + } + + suggestions.innerHTML = items.map(t => ` +
+ #${escapeHtml(t.name)} + ${t.count}× +
+ `).join(''); + suggestions.classList.add('visible'); + } + + function hideSuggestions() { + if (suggestions) suggestions.classList.remove('visible'); + } + + // Event listeners + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + addTag(input.value); + } else if (e.key === 'Backspace' && !input.value && tagContexts[context].length > 0) { + removeTag(tagContexts[context][tagContexts[context].length - 1]); + } else if (e.key === 'Escape') { + hideSuggestions(); + } + }); + + input.addEventListener('input', () => { + showSuggestions(input.value); + }); + + input.addEventListener('focus', () => { + showSuggestions(input.value); + }); + + input.addEventListener('blur', () => { + setTimeout(hideSuggestions, 200); + }); + + // Click on suggestion + if (suggestions) { + suggestions.addEventListener('click', (e) => { + const item = e.target.closest('.tag-suggestion'); + if (item) { + addTag(item.dataset.tag); + } + }); + } + + // Click remove button + list.addEventListener('click', (e) => { + if (e.target.classList.contains('tag-remove')) { + removeTag(e.target.dataset.tag); + } + }); + + renderTags(); + } + + // ============ TAG PRESETS MANAGEMENT (Server-side storage) ============ + + let editingPresetId = null; + let cachedPresets = []; // In-memory cache for sync operations + + // Load presets from server + async function loadPresetsFromServer() { + try { + const response = await fetch('api.php?action=get_presets'); + const data = await response.json(); + if (data.success && data.presets) { + cachedPresets = data.presets; + return data.presets; + } + return []; + } catch (e) { + console.error('Failed to load presets:', e); + return []; + } + } + + // Save presets to server + async function savePresetsToServer(presets) { + try { + const response = await fetch('api.php?action=save_presets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ presets }) + }); + const data = await response.json(); + if (data.success) { + cachedPresets = presets; + return true; + } + console.error('Failed to save presets:', data.error); + return false; + } catch (e) { + console.error('Failed to save presets:', e); + return false; + } + } + + // Get presets (from cache for sync operations) + function getPresets() { + return cachedPresets; + } + + // Generate unique ID + function generatePresetId() { + const maxId = cachedPresets.reduce((max, p) => Math.max(max, p.id || 0), 0); + return maxId + 1; + } + + // Render presets in both containers + function renderPresets() { + const presets = getPresets(); + const containers = ['post-presets-list', 'converter-presets-list']; + + containers.forEach(containerId => { + const container = document.getElementById(containerId); + if (!container) return; + + const target = containerId.includes('converter') ? 'converter' : 'post'; + + container.innerHTML = presets.map(preset => ` + + `).join(''); + + // Add click handlers + container.querySelectorAll('.tag-preset').forEach(btn => { + btn.addEventListener('click', () => { + applyPreset(btn.dataset.presetId, btn.dataset.target); + }); + }); + }); + } + + // Apply preset tags + function applyPreset(presetId, target) { + const presets = getPresets(); + const preset = presets.find(p => p.id == presetId); + if (!preset) return; + + preset.tags.forEach(tag => { + tag = tag.trim(); + if (tag && !tagContexts[target].includes(tag)) { + tagContexts[target].push(tag); + saveTagToHistory(tag); + } + }); + + // Re-render tags + const listId = target === 'converter' ? 'converter-tags-list' : 'post-tags-list'; + const list = document.getElementById(listId); + if (list) { + list.innerHTML = tagContexts[target].map(tag => ` + + #${escapeHtml(tag)} + + + `).join(''); + } + } + + // Open preset modal + function openPresetModal(mode = 'list') { + const modal = document.getElementById('preset-modal'); + const formSection = document.getElementById('preset-form-section'); + const listSection = document.getElementById('preset-list-section'); + const titleEl = document.getElementById('preset-modal-title'); + + if (mode === 'list') { + formSection.style.display = 'none'; + listSection.style.display = 'block'; + titleEl.textContent = 'Управление пресетами'; + renderPresetManager(); + } else if (mode === 'add') { + formSection.style.display = 'block'; + listSection.style.display = 'none'; + titleEl.textContent = 'Добавить пресет'; + document.getElementById('preset-name').value = ''; + document.getElementById('preset-tags').value = ''; + editingPresetId = null; + } else if (mode === 'edit') { + formSection.style.display = 'block'; + listSection.style.display = 'none'; + titleEl.textContent = 'Редактировать пресет'; + } + + modal.style.display = 'flex'; + } + + // Close preset modal + function closePresetModal() { + document.getElementById('preset-modal').style.display = 'none'; + editingPresetId = null; + } + + // Render preset manager list + function renderPresetManager() { + const presets = getPresets(); + const container = document.getElementById('preset-manager-list'); + + if (presets.length === 0) { + container.innerHTML = '

Нет пресетов. Добавьте первый!

'; + return; + } + + container.innerHTML = presets.map(preset => ` +
+
+ ${escapeHtml(preset.name)} + ${preset.tags.map(t => '#' + escapeHtml(t)).join(' ')} +
+
+ + +
+
+ `).join(''); + + // Add event handlers + container.querySelectorAll('.preset-edit').forEach(btn => { + btn.addEventListener('click', () => editPreset(btn.dataset.presetId)); + }); + + container.querySelectorAll('.preset-delete').forEach(btn => { + btn.addEventListener('click', () => deletePreset(btn.dataset.presetId)); + }); + } + + // Edit preset + function editPreset(presetId) { + const presets = getPresets(); + const preset = presets.find(p => p.id == presetId); + if (!preset) return; + + document.getElementById('preset-name').value = preset.name; + document.getElementById('preset-tags').value = preset.tags.join(', '); + editingPresetId = preset.id; + + openPresetModal('edit'); + } + + // Delete preset + async function deletePreset(presetId) { + if (!confirm('Удалить этот пресет?')) return; + + let presets = getPresets(); + presets = presets.filter(p => p.id != presetId); + + if (await savePresetsToServer(presets)) { + renderPresetManager(); + renderPresets(); + showNotification('Пресет удалён', 'success'); + } else { + showNotification('Ошибка удаления пресета', 'error'); + } + } + + // Save preset (add or edit) + async function savePreset() { + const name = document.getElementById('preset-name').value.trim(); + const tagsStr = document.getElementById('preset-tags').value.trim(); + + if (!name) { + alert('Введите название пресета'); + return; + } + + if (!tagsStr) { + alert('Введите хотя бы один тег'); + return; + } + + const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t); + let presets = [...getPresets()]; + + if (editingPresetId) { + // Edit existing + const index = presets.findIndex(p => p.id == editingPresetId); + if (index !== -1) { + presets[index].name = name; + presets[index].tags = tags; + } + } else { + // Add new + presets.push({ + id: generatePresetId(), + name: name, + tags: tags + }); + } + + if (await savePresetsToServer(presets)) { + renderPresets(); + openPresetModal('list'); + showNotification('Пресет сохранён', 'success'); + } else { + showNotification('Ошибка сохранения пресета', 'error'); + } + } + + // Initialize presets system + async function initTagPresets() { + // Load presets from server + await loadPresetsFromServer(); + + // Initial render + renderPresets(); + + // Add button handlers + document.getElementById('post-preset-add')?.addEventListener('click', () => openPresetModal('add')); + document.getElementById('converter-preset-add')?.addEventListener('click', () => openPresetModal('add')); + document.getElementById('post-preset-manage')?.addEventListener('click', () => openPresetModal('list')); + document.getElementById('converter-preset-manage')?.addEventListener('click', () => openPresetModal('list')); + + // Modal handlers + document.getElementById('preset-modal-close')?.addEventListener('click', closePresetModal); + document.getElementById('preset-add-new')?.addEventListener('click', () => openPresetModal('add')); + document.getElementById('preset-save')?.addEventListener('click', savePreset); + document.getElementById('preset-cancel')?.addEventListener('click', () => openPresetModal('list')); + + // Close on overlay click + document.getElementById('preset-modal')?.addEventListener('click', (e) => { + if (e.target.id === 'preset-modal') closePresetModal(); + }); + } + + // Get tags as formatted string + function getTagsString(context) { + return tagContexts[context].map(t => '#' + t).join(' '); + } + + // Initialize tag inputs + initTagInput('post', 'post-tags-input', 'post-tags-list', 'post-tags-suggestions'); + initTagInput('converter', 'converter-tags-input', 'converter-tags-list', 'converter-tags-suggestions'); + initTagPresets(); + + // Restore draft tags after tagContexts is initialized + restoreDraftTags(); + + // ============ TEXT FORMATTING TOOLBAR ============ + + function initTextEditorToolbar() { + document.querySelectorAll('.toolbar-btn[data-format]').forEach(btn => { + btn.addEventListener('click', () => { + const format = btn.dataset.format; + const targetId = btn.dataset.target || 'post-text'; + const textarea = document.getElementById(targetId); + + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + const selected = text.substring(start, end); + + let before = '', after = ''; + let newText = selected; + + // Get format mode from the post-parse-mode selector + const parseMode = document.getElementById('post-parse-mode')?.value || 'HTML'; + + if (parseMode === 'HTML') { + 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 = ''; + 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 = ` +
+ ${previewPhotos.map(url => ``).join('')} + ${moreCount > 0 ? `+${moreCount}` : ''} +
+ `; + } + + return ` +
+
+ 📅 ${dateStr} + ${platforms} +
+ ${photosPreviewHtml} +
+ ${post.text ? `

${escapeHtml(post.text.substring(0, 150))}${post.text.length > 150 ? '...' : ''}

` : ''} + ${post.tags?.length ? `${post.tags.slice(0, 5).map(t => '#' + t).join(' ')}` : ''} +
+
+ + +
+
+ `; + }).join(''); + + // Attach event handlers + list.querySelectorAll('.btn-delete-scheduled').forEach(btn => { + btn.addEventListener('click', () => deleteScheduledPost(btn.dataset.id)); + }); + list.querySelectorAll('.btn-edit-scheduled').forEach(btn => { + btn.addEventListener('click', () => editScheduledPost(btn.dataset.id, data.posts)); + }); + // Update count badge + updateScheduledCount(pendingPosts.length); + } else { + list.innerHTML = '

Нет запланированных постов

'; + 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 = ` +
+
+ ${allPhotos.length > 0 ? allPhotos.map((url, i) => ` +
+ + +
+ `).join('') : '

Нет фото

'} +
+ +
+
+ ${tags.map(t => `#${escapeHtml(t)} `).join('')} +
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + +
+
+ `; + + // Track editable photos and tags + let editPhotos = [...allPhotos]; + let editTags = [...tags]; + + // Photo remove handlers + card.querySelectorAll('.inline-editor-photos .remove-btn').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.index); + editPhotos.splice(idx, 1); + // Re-render photos + const photosContainer = card.querySelector('.inline-editor-photos'); + if (editPhotos.length > 0) { + photosContainer.innerHTML = editPhotos.map((url, i) => ` +
+ + +
+ `).join(''); + // Re-attach handlers + photosContainer.querySelectorAll('.remove-btn').forEach(b => { + b.addEventListener('click', () => { + editPhotos.splice(parseInt(b.dataset.index), 1); + b.parentElement.remove(); + if (editPhotos.length === 0) { + photosContainer.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 = ` +
+ ${previewPhotos.map(url => ``).join('')} + ${allPhotos.length > 3 ? `+${allPhotos.length - 3}` : ''} +
+ `; + } + + return ` +
+
+ ${dateStr} + ${statusIcons} +
+ ${photosPreviewHtml} + ${post.text ? `

${escapeHtml(post.text.substring(0, 100))}${post.text.length > 100 ? '...' : ''}

` : ''} +
+ `; + }).join(''); + } else { + list.innerHTML = '

Нет опубликованных постов

'; + } + } 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) => ` +
+ ${escapeHtml(file.name?.substring(0, 15) || 'file')} + +
+ `).join(''); + + preview.querySelectorAll('.remove-uploaded').forEach(btn => { + btn.addEventListener('click', () => { + scheduledState.uploadedFiles.splice(parseInt(btn.dataset.idx), 1); + renderScheduledUploads(); + }); + }); + } + + // Update scheduled photos preview when switching to tab + function updateScheduledPhotosPreview() { + const preview = document.getElementById('scheduled-photos-preview'); + if (!preview) return; + + if (state.selectedPhotos.length === 0) { + preview.innerHTML = '

Сначала выберите фото в Галерее, затем вернитесь сюда

'; + } else { + preview.innerHTML = state.selectedPhotos.map(photo => ` +
+ +
+ `).join(''); + } + } + + // Update preview when tab is switched + document.querySelectorAll('.nav-btn[data-tab="scheduled"]').forEach(btn => { + btn.addEventListener('click', updateScheduledPhotosPreview); + }); + + // ============ COPY WITH TAGS ============ + + document.getElementById('btn-copy-with-tags')?.addEventListener('click', () => { + const output = document.getElementById('output-result'); + const title = document.getElementById('converter-title')?.value || ''; + const text = document.getElementById('converter-text')?.value || ''; + const tags = getTagsString('converter'); + + let fullText = ''; + if (title) fullText += title + '\n\n'; + if (text) fullText += text + '\n\n'; + fullText += output.value; + if (tags) fullText += '\n\n' + tags; + + navigator.clipboard.writeText(fullText).then(() => { + const status = document.getElementById('copy-status'); + if (status) { + status.textContent = '✓ Скопировано с тегами!'; + status.classList.add('visible'); + setTimeout(() => status.classList.remove('visible'), 2000); + } + }); + }); + + // Update copy button to show status + document.getElementById('btn-copy')?.addEventListener('click', () => { + const output = document.getElementById('output-result'); + navigator.clipboard.writeText(output.value).then(() => { + const status = document.getElementById('copy-status'); + if (status) { + status.textContent = '✓ Скопировано!'; + status.classList.add('visible'); + setTimeout(() => status.classList.remove('visible'), 2000); + } + }); + }); + + // Check Flickr OAuth status + async function loadFlickrOAuthStatus() { + const statusEl = document.getElementById('flickr-oauth-status'); + const btnEl = document.getElementById('flickr-oauth-btn'); + const bannerEl = document.getElementById('oauth-banner'); + + try { + const response = await fetch('api.php?action=flickr_oauth_status'); + const data = await response.json(); + + if (data.authorized) { + // Settings page elements + if (statusEl) { + statusEl.className = 'status connected'; + statusEl.textContent = 'Авторизован (оригиналы доступны)'; + } + if (btnEl) { + btnEl.textContent = 'Переавторизовать'; + btnEl.className = 'btn btn-small btn-secondary'; + } + // Hide gallery banner when authorized + if (bannerEl) { + bannerEl.classList.add('hidden'); + } + } else { + // Settings page elements + if (statusEl) { + statusEl.className = 'status disconnected'; + statusEl.textContent = 'Не авторизован'; + } + if (btnEl) { + btnEl.textContent = 'Авторизовать'; + btnEl.className = 'btn btn-small btn-primary'; + } + // Show gallery banner when not authorized + if (bannerEl) { + bannerEl.classList.remove('hidden'); + } + } + } catch (error) { + if (statusEl) { + statusEl.className = 'status disconnected'; + statusEl.textContent = 'Ошибка проверки'; + } + } + } + + // ============ WIDGET SETTINGS ============ + + const widgetApiUrlInput = document.getElementById('widget-api-url'); + const widgetApiUrlCode = document.getElementById('widget-api-url-code'); + const widgetEnabled = document.getElementById('widget-enabled'); + const widgetMaxPhotos = document.getElementById('widget-max-photos'); + const widgetCacheTime = document.getElementById('widget-cache-time'); + const widgetAlbumsList = document.getElementById('widget-albums-list'); + const btnLoadWidgetAlbums = document.getElementById('btn-load-widget-albums'); + const btnSaveWidgetSettings = document.getElementById('btn-save-widget-settings'); + const widgetSaveStatus = document.getElementById('widget-save-status'); + + let widgetSelectedAlbums = []; + + // Set API URL + if (widgetApiUrlInput) { + const apiUrl = window.location.origin + window.location.pathname.replace('index.php', '') + 'widget_api.php?action=get_photos'; + widgetApiUrlInput.value = apiUrl; + if (widgetApiUrlCode) { + widgetApiUrlCode.textContent = apiUrl; + } + } + + // Copy widget URL + window.copyWidgetUrl = function() { + if (widgetApiUrlInput) { + widgetApiUrlInput.select(); + document.execCommand('copy'); + showNotification('URL скопирован', 'success'); + } + }; + + // Load widget settings + async function loadWidgetSettings() { + try { + const response = await fetch('widget_api.php?action=get_settings'); + const data = await response.json(); + if (data.success && data.settings) { + const settings = data.settings; + if (widgetEnabled) widgetEnabled.checked = settings.enabled !== false; + if (widgetMaxPhotos) widgetMaxPhotos.value = settings.max_photos || 30; + if (widgetCacheTime) widgetCacheTime.value = settings.cache_time || 3600; + widgetSelectedAlbums = settings.albums || []; + } + } catch (error) { + console.error('Failed to load widget settings:', error); + } + } + + // Load albums for widget selection + async function loadWidgetAlbums() { + if (!widgetAlbumsList) return; + + widgetAlbumsList.innerHTML = '

Загрузка альбомов...

'; + + 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; +?> + + + + + + Настройка - VH Posting System + + + + +
+
+

VH Posting System

+

Первоначальная настройка

+ + +
+ Администратор успешно создан! +

+ Перейти ко входу +
+ +

Создайте учётную запись администратора

+ + +
+ + +
+ + +
+ + +
+ +
+ + + Минимум 8 символов +
+ +
+ + +
+ + +
+ +
+
+ + diff --git a/test_single_download.php b/test_single_download.php new file mode 100644 index 0000000..bb2dca0 --- /dev/null +++ b/test_single_download.php @@ -0,0 +1,110 @@ +Testing Download: $testUrl"; + +// Fetch the image +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $testUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 60, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => [ + 'Accept: image/*, */*', + 'Referer: https://www.flickr.com/', + ], +]); + +$content = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); +$error = curl_error($ch); +curl_close($ch); + +echo "

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) . "

"; + +$isJpeg = (substr($content, 0, 2) === "\xFF\xD8"); +$isPng = (substr($content, 0, 4) === "\x89PNG"); + +echo "

Is JPEG: " . ($isJpeg ? 'YES' : 'NO') . "

"; +echo "

Is PNG: " . ($isPng ? 'YES' : 'NO') . "

"; + +if ($httpCode === 200 && ($isJpeg || $isPng)) { + echo "

✓ Image fetched successfully!

"; + + // Save test file + $testFile = sys_get_temp_dir() . '/flickr_test_' . time() . '.jpg'; + file_put_contents($testFile, $content); + + // Verify with getimagesize + $imgInfo = @getimagesize($testFile); + if ($imgInfo) { + 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 "

Preview (base64):

"; + echo ""; + + // Download link + echo "

Test Download:

"; + echo "

Click to test download

"; + + @unlink($testFile); +} else { + echo "

✗ Failed to fetch valid image

"; + if (strlen($content) < 500) { + 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) => ` +
+ + ${this.escapeHtml(photo.title || '')} + ${photo.title ? `${this.escapeHtml(photo.title)}` : ''} + +
+ `).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"] + +
+

+
+ +
+ + +
+ options['enabled']) || empty($this->options['api_url'])) { + return; + } + + wp_enqueue_style( + 'vh-flickr-mosaic', + plugin_dir_url(__FILE__) . 'assets/css/mosaic.css', + [], + '1.0.0' + ); + + wp_enqueue_script( + 'vh-flickr-mosaic', + plugin_dir_url(__FILE__) . 'assets/js/mosaic.js', + [], + '1.0.0', + true + ); + + wp_localize_script('vh-flickr-mosaic', 'vhMosaicConfig', [ + 'apiUrl' => $this->options['api_url'], + 'rows' => intval($this->options['rows'] ?? 2), + 'photoSize' => intval($this->options['photo_size'] ?? 150), + 'animationSpeed' => floatval($this->options['animation_speed'] ?? 5), + ]); + } + + public function render_mosaic() { + if (empty($this->options['enabled']) || empty($this->options['api_url'])) { + return; + } + + if (($this->options['position'] ?? 'footer') !== 'footer') { + return; + } + + echo $this->get_mosaic_html(); + } + + public function shortcode_mosaic($atts) { + if (empty($this->options['api_url'])) { + return ''; + } + + $atts = shortcode_atts([ + 'rows' => $this->options['rows'] ?? 2, + 'size' => $this->options['photo_size'] ?? 150, + 'speed' => $this->options['animation_speed'] ?? 5, + ], $atts); + + return $this->get_mosaic_html($atts); + } + + private function get_mosaic_html($atts = []) { + $rows = intval($atts['rows'] ?? $this->options['rows'] ?? 2); + $size = intval($atts['size'] ?? $this->options['photo_size'] ?? 150); + $speed = floatval($atts['speed'] ?? $this->options['animation_speed'] ?? 5); + + return sprintf( + '
+
+
', + $rows, + $size, + $speed + ); + } +} + +// Initialize +VH_Flickr_Mosaic::get_instance();