/** * 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 ============ // Soft limit for safe cross-platform posting. Can be lifted with the // "Больше 9 фото" toggle on the posting page; VK gets a confirm warning // because its album endpoint only accepts up to 9 attachments. const DEFAULT_MAX_PHOTOS = 9; const HARD_MAX_PHOTOS = 99; function isPhotoLimitUnlocked() { return localStorage.getItem('vh_unlock_9') === '1'; } function getMaxPhotos() { return isPhotoLimitUnlocked() ? HARD_MAX_PHOTOS : DEFAULT_MAX_PHOTOS; } // Backward compat for code that still reads MAX_PHOTOS as a constant — // we keep the name but evaluate dynamically through getter usage below. const MAX_PHOTOS = DEFAULT_MAX_PHOTOS; // Sync the "more than 9 photos" toggle with localStorage and refresh counters // whenever it changes. function bindPhotoLimitToggle() { const chk = document.getElementById('chk-unlock-photo-limit'); if (!chk) return; chk.checked = isPhotoLimitUnlocked(); chk.addEventListener('change', () => { if (chk.checked) { localStorage.setItem('vh_unlock_9', '1'); } else { localStorage.removeItem('vh_unlock_9'); // If currently over 9 photos, warn — we won't trim automatically. if (getTotalPhotosCount() > DEFAULT_MAX_PHOTOS) { showNotification(`Сейчас выбрано ${getTotalPhotosCount()} фото. Удалите лишние перед публикацией.`, 'info'); } } updateSelectionUI(); updatePostingPreview(); }); } // Bind after DOM is ready — we're already inside DOMContentLoaded. bindPhotoLimitToggle(); function getTotalPhotosCount() { return state.selectedPhotos.length + state.uploadedFiles.length; } function canAddPhotos(count = 1) { const max = getMaxPhotos(); if (getTotalPhotosCount() + count > max) { showNotification(`Максимум ${max} фото/видео`, '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 const maxNow = getMaxPhotos(); if (getTotalPhotosCount() >= maxNow) { showNotification(`Максимум ${maxNow} фото/видео`, '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 // Returns just the albums array (back-compat with callers that destructure // an array). The full pagination object is available via getAlbumCacheFull. function getAlbumCache() { const full = getAlbumCacheFull(); return full ? full.albums : null; } // Returns { albums, page, pages, total } or null. Pagination info may be // missing on legacy caches. function getAlbumCacheFull() { 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; } const albums = data.albums; if (Array.isArray(albums)) { // Legacy flat-array format — pagination unknown. return { albums, page: 1, pages: 1, total: albums.length }; } else if (albums && Array.isArray(albums.albums)) { // Older nested format. return { albums: albums.albums, page: albums.page || 1, pages: albums.pages || 1, total: albums.total || albums.albums.length, }; } else if (Array.isArray(data.list)) { // Current format: stored as { list, page, pages, total }. return { albums: data.list, page: data.page || 1, pages: data.pages || 1, total: data.total || data.list.length, }; } return null; } catch (e) { return null; } } function setAlbumCache(albumsOrData) { try { let payload; if (Array.isArray(albumsOrData)) { payload = { list: albumsOrData, page: 1, pages: 1, total: albumsOrData.length }; } else if (albumsOrData && Array.isArray(albumsOrData.albums)) { payload = { list: albumsOrData.albums, page: albumsOrData.page || 1, pages: albumsOrData.pages || 1, total: albumsOrData.total || albumsOrData.albums.length, }; } else { return; } payload.timestamp = Date.now(); localStorage.setItem(ALBUM_CACHE_KEY, JSON.stringify(payload)); } 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(); const maxNow = getMaxPhotos(); // Update counter with limit info (gallery floating bar) if (selectedCountEl) { selectedCountEl.textContent = `${total}/${maxNow}`; } // Update counter on posting page const photoCounter = document.getElementById('photo-counter'); if (photoCounter) { photoCounter.textContent = `${total}/${maxNow}`; photoCounter.classList.toggle('at-limit', total >= maxNow); } // 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 maxNow = getMaxPhotos(); const photoCounter = document.getElementById('photo-counter'); if (photoCounter) { photoCounter.textContent = `${total}/${maxNow}`; photoCounter.classList.toggle('at-limit', total >= maxNow); } 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. // IMPORTANT: also saves pagination state and arms the infinite-scroll // observer, since the cached-render path skips both. async function refreshAlbumsSilently() { if (state.isLoadingAlbums) return; try { const response = await fetch('api.php?action=flickr_albums&page=1&per_page=50'); if (!response.ok) return; const data = await response.json(); 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; setAlbumCache({ albums: data.albums, page: state.albumsPage, pages: state.albumsTotalPages, total: state.albumsTotal, }); window._cachedAlbums = data.albums; // Only re-render if on albums view (photos view is hidden) if (photosView?.classList.contains('hidden')) { renderAlbumsGrid(data.albums); } // Arm infinite scroll if there are more pages available. if (state.albumsPage < state.albumsTotalPages) { setupAlbumsInfiniteScroll(); } } } 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 full = getAlbumCacheFull(); if (full && full.albums && full.albums.length > 0) { console.log('Using cached albums:', full.albums.length); state.allAlbums = full.albums; state.albumsPage = full.page; state.albumsTotalPages = full.pages; state.albumsTotal = full.total; window._cachedAlbums = full.albums; renderAlbumsGrid(full.albums); showNotification(`Загружено ${full.albums.length} альбомов (из кеша)`, 'success'); // Arm scroll observer if there might be more pages, even when // pagination info is missing from a legacy cache. setupAlbumsInfiniteScroll(); // Refresh in background to get accurate page count. if (full.pages <= 1) { setTimeout(() => refreshAlbumsSilently(), 500); } 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; // IntersectionObserver fires on STATE CHANGE — if the sentinel never left the // viewport (e.g. small thumbnails) it won't auto-trigger again. Re-check here. if (state.albumsPage < state.albumsTotalPages) { requestAnimationFrame(() => { const sentinel = document.getElementById('albums-scroll-sentinel'); if (!sentinel || state.isLoadingMoreAlbums) return; const rect = sentinel.getBoundingClientRect(); const viewportH = window.innerHeight || document.documentElement.clientHeight; if (rect.top < viewportH + 200) { loadMoreAlbums(); } }); } } } // 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); // Fallback scroll listener — covers cases where IntersectionObserver // doesn't refire after a load (e.g. sentinel stays in viewport). if (!window._albumsScrollFallbackBound) { window._albumsScrollFallbackBound = true; const checkSentinel = () => { if (state.isLoadingMoreAlbums) return; if (state.albumsPage >= state.albumsTotalPages) return; const s = document.getElementById('albums-scroll-sentinel'); if (!s) return; const rect = s.getBoundingClientRect(); const viewportH = window.innerHeight || document.documentElement.clientHeight; if (rect.top < viewportH + 200) { loadMoreAlbums(); } }; window.addEventListener('scroll', checkSentinel, { passive: true }); window.addEventListener('resize', checkSentinel, { passive: true }); } } // ============ 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', () => { const maxNow = getMaxPhotos(); document.querySelectorAll('.photo-item').forEach(item => { if (getTotalPhotosCount() >= maxNow) 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() >= maxNow) { showNotification(`Выбрано максимум ${maxNow} фото`, '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 })); // VK has a max of 9 attachments per post. If we're posting to VK and // the total media exceeds that, ask the user whether to truncate. const totalMedia = photoUrls.length + uploadedFileUrls.length; if (postToVk && totalMedia > DEFAULT_MAX_PHOTOS) { const ok = confirm( `Выбрано ${totalMedia} фото/видео, а VK принимает максимум ${DEFAULT_MAX_PHOTOS} в одной публикации.\n\n` + `В VK уйдут только первые ${DEFAULT_MAX_PHOTOS}. В Telegram отправятся все.\n\nПродолжить?` ); if (!ok) { if (btnSendPost) { btnSendPost.disabled = false; btnSendPost.textContent = 'Опубликовать'; } return; } } // 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) { // VK: hard-trim combined media to the platform limit. let photosForPlatform = photoUrls; let uploadedForPlatform = uploadedFileUrls; if (platform.type === 'vk') { const cap = DEFAULT_MAX_PHOTOS; if (photoUrls.length >= cap) { photosForPlatform = photoUrls.slice(0, cap); uploadedForPlatform = []; } else { photosForPlatform = photoUrls; uploadedForPlatform = uploadedFileUrls.slice(0, cap - photoUrls.length); } } 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(photosForPlatform)); formData.append('uploaded_files', JSON.stringify(uploadedForPlatform)); 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) { // Mark scheduled post as published so it appears in archive const markForm = new FormData(); markForm.append('action', 'mark_post_published'); markForm.append('id', postId); markForm.append('results', JSON.stringify(results)); await fetch('api.php', { method: 'POST', body: markForm }); // Surface VK warnings (e.g. photos posted as links instead of attachments) const warnings = Object.entries(results) .filter(([, r]) => r.success && r.warning) .map(([p, r]) => `${p.toUpperCase()}: ${r.warning}`); if (warnings.length > 0) { showNotification('Опубликовано с предупреждением — ' + warnings.join(' | '), 'warning'); } else { showNotification(allSuccess ? 'Опубликовано!' : 'Частично опубликовано', allSuccess ? 'success' : 'warning'); } loadScheduledPosts(); loadPublishedPosts(); } 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 = ''; let warningText = ''; Object.entries(results).forEach(([platform, result]) => { const icon = result.success ? (result.warning ? '⚠' : '✓') : '✗'; const platformName = platform === 'telegram' ? 'TG' : platform === 'vk' ? 'VK' : platform; const tip = result.error || result.warning || 'OK'; const cls = result.success ? (result.warning ? 'result-warning' : 'result-success') : 'result-error'; statusIcons += `${platformName} ${icon} `; if (result.warning) { warningText = `${platformName}: ${result.warning}`; } }); // 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 ? '...' : ''}

` : ''} ${warningText ? `

⚠ ${escapeHtml(warningText)}

` : ''}
`; }).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 fullCache = getAlbumCacheFull(); if (fullCache && fullCache.albums.length > 0) { // Use cache for instant display, and prime pagination state so // the infinite-scroll observer can engage immediately. console.log('Loading cached albums:', fullCache.albums.length); state.allAlbums = fullCache.albums; state.albumsPage = fullCache.page; state.albumsTotalPages = fullCache.pages; state.albumsTotal = fullCache.total; window._cachedAlbums = fullCache.albums; renderAlbumsGrid(fullCache.albums); setupAlbumsInfiniteScroll(); // Silently refresh in background — this also corrects pagination // info if the cache is from a legacy build that lost it. setTimeout(() => refreshAlbumsSilently(), 2000); } else { // No cache or empty - load from API console.log('No cache, loading albums from API...'); loadAlbums(false); } } // ============ DIGITAL BADGE (round display) ============ { const BADGE_SIZE = 240; // Final output (round display) const CANVAS_DISPLAY_SIZE = 480; // Editor internal resolution const FONT_STACK = '-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; const badgeState = { items: [], activeId: null, defaultNickname: '', }; const el = { fileInput: document.getElementById('badge-file-upload'), btnUpload: document.getElementById('btn-badge-upload'), btnFromFlickr: document.getElementById('btn-badge-from-flickr'), itemsGrid: document.getElementById('badge-items-grid'), itemsCount: document.getElementById('badge-items-count'), editor: document.getElementById('badge-editor'), canvas: document.getElementById('badge-canvas'), zoom: document.getElementById('badge-zoom'), zoomValue: document.getElementById('badge-zoom-value'), btnReset: document.getElementById('btn-badge-reset'), priceEnabled: document.getElementById('badge-price-enabled'), priceOptions: document.getElementById('badge-price-options'), priceValue: document.getElementById('badge-price-value'), priceY: document.getElementById('badge-price-y'), priceYValue: document.getElementById('badge-price-y-value'), priceSize: document.getElementById('badge-price-size'), priceSizeValue: document.getElementById('badge-price-size-value'), priceBg: document.getElementById('badge-price-bg'), priceFg: document.getElementById('badge-price-fg'), nickEnabled: document.getElementById('badge-nickname-enabled'), nickOptions: document.getElementById('badge-nickname-options'), nickValue: document.getElementById('badge-nickname-value'), nickColor: document.getElementById('badge-nick-color'), nickSize: document.getElementById('badge-nick-size'), nickSizeValue: document.getElementById('badge-nick-size-value'), nickEdge: document.getElementById('badge-nick-edge'), nickEdgeValue: document.getElementById('badge-nick-edge-value'), nickAsPrice: document.getElementById('badge-nick-as-price'), btnDownload: document.getElementById('btn-badge-download'), btnRemove: document.getElementById('btn-badge-remove'), batchActions: document.getElementById('badge-batch-actions'), btnDownloadAll: document.getElementById('btn-badge-download-all'), historyGrid: document.getElementById('badge-history-grid'), // Settings tab nickDefault: document.getElementById('badge-nickname-default'), btnSaveNick: document.getElementById('btn-save-badge-nickname'), nickSaveStatus: document.getElementById('badge-nickname-save-status'), }; if (!el.canvas) return; // Defensive: tab not present. const ctx = el.canvas.getContext('2d'); // Detect Web Share with file support to label the button appropriately on phones. const canShareFiles = (() => { try { if (typeof File === 'undefined' || !navigator.canShare) return false; const probe = new File([''], 'p.png', { type: 'image/png' }); return navigator.canShare({ files: [probe] }); } catch (e) { return false; } })(); const labelEl = document.getElementById('badge-download-label'); if (labelEl && canShareFiles) { labelEl.textContent = 'Сохранить в Фото'; } // ---------- Drawing helpers ---------- function formatPrice(v) { if (v === '' || v == null) return ''; const n = Number(v); if (!isFinite(n)) return String(v); return Math.round(n).toLocaleString('ru-RU') + ' ₽'; } function roundRect(c, x, y, w, h, r) { c.beginPath(); c.moveTo(x + r, y); c.lineTo(x + w - r, y); c.quadraticCurveTo(x + w, y, x + w, y + r); c.lineTo(x + w, y + h - r); c.quadraticCurveTo(x + w, y + h, x + w - r, y + h); c.lineTo(x + r, y + h); c.quadraticCurveTo(x, y + h, x, y + h - r); c.lineTo(x, y + r); c.quadraticCurveTo(x, y, x + r, y); c.closePath(); } function drawPriceArc(c, size, item, text) { const scale = (item.priceSize || 100) / 100; const offsetY = ((item.priceY || 0) / 100) * size; const baseTopFrac = 0.74; const baseBottomFrac = 0.965; const baseHeight = (baseBottomFrac - baseTopFrac) * size; const height = baseHeight * scale; const centerY = ((baseTopFrac + baseBottomFrac) / 2) * size + offsetY; const yTop = centerY - height / 2; const yBottom = centerY + height / 2; const bg = item.priceBg || '#FFCC00'; const fg = item.priceFg || '#000000'; c.save(); c.fillStyle = bg; c.fillRect(0, yTop, size, yBottom - yTop); // Subtle inner highlight at top edge of the band. const hi = c.createLinearGradient(0, yTop, 0, yTop + size * 0.012); hi.addColorStop(0, 'rgba(255,255,255,0.3)'); hi.addColorStop(1, 'rgba(255,255,255,0)'); c.fillStyle = hi; c.fillRect(0, yTop, size, size * 0.012); const fontSize = (yBottom - yTop) * 0.55; c.fillStyle = fg; c.textAlign = 'center'; c.textBaseline = 'middle'; c.font = `700 ${fontSize}px ${FONT_STACK}`; c.fillText(text, size / 2, (yTop + yBottom) / 2 - size * 0.005); c.restore(); } function drawPriceRect(c, size, item, text) { const scale = (item.priceSize || 100) / 100; const offsetY = ((item.priceY || 0) / 100) * size; const baseW = size * 0.78; const baseH = size * 0.13; const w = baseW * scale; const h = baseH * scale; const x = (size - w) / 2; const baseTop = size * 0.755; const y = baseTop + offsetY + (baseH - h) / 2; const bg = item.priceBg || '#FFCC00'; const fg = item.priceFg || '#000000'; c.save(); c.fillStyle = 'rgba(0,0,0,0.22)'; roundRect(c, x, y + size * 0.008, w, h, h * 0.28); c.fill(); c.fillStyle = bg; roundRect(c, x, y, w, h, h * 0.28); c.fill(); const fontSize = h * 0.62; c.fillStyle = fg; c.textAlign = 'center'; c.textBaseline = 'middle'; c.font = `700 ${fontSize}px ${FONT_STACK}`; c.fillText(text, size / 2, y + h / 2); c.restore(); } function hexBrightness(hex) { const c = (hex || '').replace('#', ''); if (c.length !== 6) return 255; const r = parseInt(c.substr(0, 2), 16); const g = parseInt(c.substr(2, 2), 16); const b = parseInt(c.substr(4, 2), 16); return (r * 299 + g * 587 + b * 114) / 1000; } // Map legacy "white"/"black" values to hex so old items still render. function normalizeNickColor(v) { if (!v) return '#FFFFFF'; if (v === 'white') return '#FFFFFF'; if (v === 'black') return '#000000'; return v; } function drawNicknameArc(c, size, text, item, position) { const color = normalizeNickColor(item.nickColor); const sizePct = item.nickSize || 100; const edgePct = (item.nickEdge != null) ? item.nickEdge : 14; const cx = size / 2; const cy = size / 2; const R = size / 2; const textRadius = R * Math.max(0.55, Math.min(0.99, 1 - edgePct / 100)); const fontSize = Math.max(8, size * 0.075 * (sizePct / 100)); c.save(); c.font = `700 ${fontSize}px ${FONT_STACK}`; c.textAlign = 'center'; c.textBaseline = 'middle'; const chars = Array.from(text); const widths = chars.map(ch => c.measureText(ch).width + size * 0.003); const totalW = widths.reduce((a, b) => a + b, 0); const totalAngle = totalW / textRadius; // Bottom arc: text reads right→left visually (start at upper-right of bottom, sweep counterclockwise). // Top arc: text reads left→right (start at upper-left of top, sweep clockwise). let startAngle, direction, rotationOffset; if (position === 'top') { startAngle = -Math.PI / 2 - totalAngle / 2; direction = 1; rotationOffset = Math.PI / 2; } else { startAngle = Math.PI / 2 + totalAngle / 2; direction = -1; rotationOffset = -Math.PI / 2; } // Pick a contrasting shadow so the text is readable against any background. const isDarkText = hexBrightness(color) < 130; const shadowColor = isDarkText ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.75)'; const shadowBlur = size * (isDarkText ? 0.012 : 0.016); let cumulative = 0; for (let i = 0; i < chars.length; i++) { const w = widths[i]; const a = startAngle + direction * (cumulative + w / 2) / textRadius; cumulative += w; const x = cx + Math.cos(a) * textRadius; const y = cy + Math.sin(a) * textRadius; c.save(); c.translate(x, y); c.rotate(a + rotationOffset); c.shadowColor = shadowColor; c.shadowBlur = shadowBlur; if (!isDarkText) c.shadowOffsetY = size * 0.003; c.fillStyle = color; c.fillText(chars[i], 0, 0); c.restore(); } c.restore(); } function drawBadge(c, size, item) { const cx = size / 2; const cy = size / 2; const R = size / 2; const ratio = size / CANVAS_DISPLAY_SIZE; c.clearRect(0, 0, size, size); c.save(); c.beginPath(); c.arc(cx, cy, R, 0, Math.PI * 2); c.closePath(); c.clip(); if (item.image && item.image.complete && item.image.naturalWidth) { const img = item.image; const baseFit = Math.max(size / img.naturalWidth, size / img.naturalHeight); const scale = baseFit * (item.zoom / 100); const drawW = img.naturalWidth * scale; const drawH = img.naturalHeight * scale; const dx = cx - drawW / 2 + item.offsetX * ratio; const dy = cy - drawH / 2 + item.offsetY * ratio; c.drawImage(img, dx, dy, drawW, drawH); } else { c.fillStyle = '#eee'; c.fillRect(0, 0, size, size); c.fillStyle = '#999'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.font = `500 ${size * 0.05}px ${FONT_STACK}`; c.fillText('Загрузка...', cx, cy); } const priceText = item.showPrice ? formatPrice(item.priceValue) : ''; const hasPrice = !!priceText; if (hasPrice) { if (item.priceStyle === 'rect') { drawPriceRect(c, size, item, priceText); } else { drawPriceArc(c, size, item, priceText); } } if (item.showNickname) { let nick = (item.nickname || '').trim(); // "Use as price" formatting: append ₽; if the text is purely digits, // also insert thousands separators so it reads as a proper price. if (item.nickAsPrice && nick) { const digits = nick.replace(/[^\d]/g, ''); if (digits && /^\d+$/.test(nick)) { nick = parseInt(digits, 10).toLocaleString('ru-RU') + ' ₽'; } else if (!nick.includes('₽')) { nick = nick + ' ₽'; } } if (nick) { // Position: manual override (top/bottom) or auto (top when price occupies the bottom). let pos; if (item.nickPosition === 'top') pos = 'top'; else if (item.nickPosition === 'bottom') pos = 'bottom'; else pos = hasPrice ? 'top' : 'bottom'; drawNicknameArc(c, size, nick, item, pos); } } c.restore(); } function renderPreview() { const item = currentItem(); if (!item) return; drawBadge(ctx, CANVAS_DISPLAY_SIZE, item); } function exportItemToBlob(item) { return new Promise(resolve => { const off = document.createElement('canvas'); off.width = BADGE_SIZE; off.height = BADGE_SIZE; const offCtx = off.getContext('2d'); drawBadge(offCtx, BADGE_SIZE, item); off.toBlob(blob => resolve({ blob, canvas: off }), 'image/png'); }); } // ---------- Image loading ---------- function loadImage(src, useCrossOrigin) { return new Promise((resolve, reject) => { const img = new Image(); if (useCrossOrigin) img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => reject(new Error('Не удалось загрузить изображение')); img.src = src; }); } // ---------- Item management ---------- function currentItem() { return badgeState.items.find(it => it.id === badgeState.activeId) || null; } function makeItem(srcUrl, name, isRemote) { return { id: 'b_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8), src: srcUrl, name: name || 'photo', isRemote: !!isRemote, image: null, imageLoading: true, imageError: null, zoom: 100, offsetX: 0, offsetY: 0, showPrice: false, priceValue: '', priceStyle: 'arc', priceY: 9, // -50..+20: vertical offset from default position priceSize: 60, // 60..160 (% scale of band height) priceBg: '#FFCC00', // ribbon color priceFg: '#000000', // digits color showNickname: false, nickname: '', nickColor: '#FFFFFF', // text color (hex) nickSize: 100, // 60..160 (% scale of font) nickEdge: 14, // 0..30: percent inset from circle edge (0 = at edge) nickPosition: 'auto', // 'auto' | 'top' | 'bottom' nickAsPrice: false, // append ₽ + format digits as price }; } async function addItemFromUrl(srcUrl, name, isRemote) { const item = makeItem(srcUrl, name, isRemote); badgeState.items.push(item); renderItemsGrid(); try { const img = await loadImage(srcUrl, isRemote); item.image = img; item.imageLoading = false; renderItemsGrid(); if (badgeState.activeId === item.id) renderPreview(); } catch (err) { item.imageLoading = false; item.imageError = err.message || 'ошибка'; renderItemsGrid(); showNotification(`Не удалось загрузить ${name}`, 'error'); } return item; } function selectItem(id) { const wasHidden = el.editor.classList.contains('hidden'); badgeState.activeId = id; const item = currentItem(); if (!item) { el.editor.classList.add('hidden'); return; } el.editor.classList.remove('hidden'); if (wasHidden && window.innerWidth < 900) { setTimeout(() => { el.editor.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 60); } // Populate controls from item state el.zoom.value = item.zoom; el.zoomValue.textContent = item.zoom + '%'; el.priceEnabled.checked = item.showPrice; el.priceOptions.classList.toggle('hidden', !item.showPrice); el.priceValue.value = item.priceValue; document.querySelectorAll('input[name="badge-price-style"]').forEach(r => { r.checked = r.value === item.priceStyle; }); el.priceY.value = item.priceY; el.priceYValue.textContent = item.priceY; el.priceSize.value = item.priceSize; el.priceSizeValue.textContent = item.priceSize + '%'; el.priceBg.value = item.priceBg; el.priceFg.value = item.priceFg; syncSwatches('bg', item.priceBg); syncSwatches('fg', item.priceFg); el.nickEnabled.checked = item.showNickname; el.nickOptions.classList.toggle('hidden', !item.showNickname); el.nickValue.value = item.nickname || badgeState.defaultNickname || ''; const nickHex = normalizeNickColor(item.nickColor); item.nickColor = nickHex; el.nickColor.value = nickHex; syncSwatches('nick', nickHex); el.nickSize.value = item.nickSize; el.nickSizeValue.textContent = item.nickSize + '%'; el.nickEdge.value = item.nickEdge; el.nickEdgeValue.textContent = item.nickEdge; el.nickAsPrice.checked = !!item.nickAsPrice; document.querySelectorAll('input[name="badge-nick-position"]').forEach(r => { r.checked = r.value === (item.nickPosition || 'auto'); }); renderItemsGrid(); renderPreview(); } function removeItem(id) { const idx = badgeState.items.findIndex(it => it.id === id); if (idx === -1) return; badgeState.items.splice(idx, 1); if (badgeState.activeId === id) { const next = badgeState.items[0]; if (next) { selectItem(next.id); } else { badgeState.activeId = null; el.editor.classList.add('hidden'); } } renderItemsGrid(); } function renderItemsGrid() { const grid = el.itemsGrid; if (!grid) return; el.itemsCount.textContent = String(badgeState.items.length); el.batchActions.classList.toggle('hidden', badgeState.items.length < 2); if (badgeState.items.length === 0) { grid.innerHTML = '

Выберите фото из галереи или загрузите с устройства

'; return; } grid.innerHTML = ''; badgeState.items.forEach(item => { const div = document.createElement('div'); div.className = 'badge-item' + (item.id === badgeState.activeId ? ' active' : ''); if (item.showPrice || item.showNickname) div.classList.add('has-extras'); div.dataset.id = item.id; const img = document.createElement('img'); img.className = 'badge-item-preview'; img.alt = item.name; img.src = item.src; if (item.isRemote) img.crossOrigin = 'anonymous'; div.appendChild(img); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'badge-item-remove'; remove.title = 'Убрать'; remove.textContent = '×'; remove.addEventListener('click', (e) => { e.stopPropagation(); removeItem(item.id); }); div.appendChild(remove); const dot = document.createElement('span'); dot.className = 'badge-item-badge-dot'; dot.title = 'С декором (цена/никнейм)'; div.appendChild(dot); div.addEventListener('click', () => selectItem(item.id)); grid.appendChild(div); }); } // ---------- Drag interactions ---------- let dragging = false; let dragStart = null; let pinchStart = null; function pointerPosOnCanvas(e) { const rect = el.canvas.getBoundingClientRect(); const t = (e.touches && e.touches[0]) ? e.touches[0] : e; return { x: (t.clientX - rect.left) * (CANVAS_DISPLAY_SIZE / rect.width), y: (t.clientY - rect.top) * (CANVAS_DISPLAY_SIZE / rect.height), }; } function touchDistance(touches) { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.hypot(dx, dy); } function setZoom(item, value) { const next = Math.max(100, Math.min(400, Math.round(value))); item.zoom = next; el.zoom.value = next; el.zoomValue.textContent = next + '%'; } function onPointerDown(e) { const item = currentItem(); if (!item) return; e.preventDefault(); dragging = true; const p = pointerPosOnCanvas(e); dragStart = { px: p.x, py: p.y, ox: item.offsetX, oy: item.offsetY }; } function onPointerMove(e) { if (!dragging || !dragStart) return; const item = currentItem(); if (!item) return; e.preventDefault(); const p = pointerPosOnCanvas(e); item.offsetX = dragStart.ox + (p.x - dragStart.px); item.offsetY = dragStart.oy + (p.y - dragStart.py); renderPreview(); } function onPointerUp() { dragging = false; dragStart = null; } function onCanvasTouchStart(e) { const item = currentItem(); if (!item) return; if (e.touches.length >= 2) { e.preventDefault(); dragging = false; dragStart = null; pinchStart = { dist: touchDistance(e.touches), zoom: item.zoom }; return; } onPointerDown(e); } function onCanvasTouchMove(e) { const item = currentItem(); if (!item) return; if (e.touches.length >= 2 && pinchStart) { e.preventDefault(); const dist = touchDistance(e.touches); if (pinchStart.dist > 0) { setZoom(item, pinchStart.zoom * (dist / pinchStart.dist)); renderPreview(); } return; } onPointerMove(e); } function onCanvasTouchEnd(e) { if (e.touches.length < 2) pinchStart = null; if (e.touches.length === 0) onPointerUp(); } el.canvas.addEventListener('mousedown', onPointerDown); window.addEventListener('mousemove', onPointerMove); window.addEventListener('mouseup', onPointerUp); el.canvas.addEventListener('touchstart', onCanvasTouchStart, { passive: false }); el.canvas.addEventListener('touchmove', onCanvasTouchMove, { passive: false }); el.canvas.addEventListener('touchend', onCanvasTouchEnd); el.canvas.addEventListener('touchcancel', onCanvasTouchEnd); el.canvas.addEventListener('wheel', (e) => { const item = currentItem(); if (!item) return; e.preventDefault(); const delta = e.deltaY > 0 ? -5 : 5; setZoom(item, item.zoom + delta); renderPreview(); }, { passive: false }); // ---------- Controls ---------- el.zoom.addEventListener('input', () => { const item = currentItem(); if (!item) return; const v = parseInt(el.zoom.value, 10) || 100; item.zoom = v; el.zoomValue.textContent = v + '%'; renderPreview(); }); el.btnReset.addEventListener('click', () => { const item = currentItem(); if (!item) return; item.zoom = 100; item.offsetX = 0; item.offsetY = 0; el.zoom.value = 100; el.zoomValue.textContent = '100%'; renderPreview(); }); el.priceEnabled.addEventListener('change', () => { const item = currentItem(); if (!item) return; item.showPrice = el.priceEnabled.checked; el.priceOptions.classList.toggle('hidden', !item.showPrice); renderPreview(); renderItemsGrid(); }); el.priceValue.addEventListener('input', () => { const item = currentItem(); if (!item) return; item.priceValue = el.priceValue.value; renderPreview(); }); document.querySelectorAll('input[name="badge-price-style"]').forEach(r => { r.addEventListener('change', () => { const item = currentItem(); if (!item) return; if (r.checked) { item.priceStyle = r.value; renderPreview(); } }); }); // Price vertical position slider el.priceY.addEventListener('input', () => { const item = currentItem(); if (!item) return; const v = parseInt(el.priceY.value, 10) || 0; item.priceY = v; el.priceYValue.textContent = v; renderPreview(); }); // Price size slider el.priceSize.addEventListener('input', () => { const item = currentItem(); if (!item) return; const v = parseInt(el.priceSize.value, 10) || 100; item.priceSize = v; el.priceSizeValue.textContent = v + '%'; renderPreview(); }); // Color: ribbon background function normalizeHex(v) { return (v || '').toString().toLowerCase(); } function syncSwatches(target, color) { const norm = normalizeHex(color); document.querySelectorAll(`.badge-color-row[data-target="${target}"] .badge-color-swatch`).forEach(sw => { sw.classList.toggle('active', normalizeHex(sw.dataset.color) === norm); }); } el.priceBg.addEventListener('input', () => { const item = currentItem(); if (!item) return; item.priceBg = el.priceBg.value; syncSwatches('bg', item.priceBg); renderPreview(); }); el.priceFg.addEventListener('input', () => { const item = currentItem(); if (!item) return; item.priceFg = el.priceFg.value; syncSwatches('fg', item.priceFg); renderPreview(); }); document.querySelectorAll('.badge-color-row .badge-color-swatch').forEach(sw => { sw.addEventListener('click', () => { const item = currentItem(); if (!item) return; const target = sw.parentElement.dataset.target; const color = sw.dataset.color; if (target === 'bg') { item.priceBg = color; el.priceBg.value = color; } else if (target === 'fg') { item.priceFg = color; el.priceFg.value = color; } else if (target === 'nick') { item.nickColor = color; el.nickColor.value = color; } syncSwatches(target, color); renderPreview(); }); }); el.nickEnabled.addEventListener('change', () => { const item = currentItem(); if (!item) return; item.showNickname = el.nickEnabled.checked; el.nickOptions.classList.toggle('hidden', !item.showNickname); if (item.showNickname && !item.nickname && badgeState.defaultNickname) { item.nickname = badgeState.defaultNickname; el.nickValue.value = badgeState.defaultNickname; } renderPreview(); renderItemsGrid(); }); el.nickValue.addEventListener('input', () => { const item = currentItem(); if (!item) return; item.nickname = el.nickValue.value; renderPreview(); }); // Nickname color (hex input + swatches handled together with price swatches) el.nickColor.addEventListener('input', () => { const item = currentItem(); if (!item) return; item.nickColor = el.nickColor.value; syncSwatches('nick', item.nickColor); renderPreview(); }); // Nickname size el.nickSize.addEventListener('input', () => { const item = currentItem(); if (!item) return; const v = parseInt(el.nickSize.value, 10) || 100; item.nickSize = v; el.nickSizeValue.textContent = v + '%'; renderPreview(); }); // Nickname edge offset el.nickEdge.addEventListener('input', () => { const item = currentItem(); if (!item) return; const v = parseInt(el.nickEdge.value, 10) || 0; item.nickEdge = v; el.nickEdgeValue.textContent = v; renderPreview(); }); // Nickname "use as price" — append ₽ to the rendered text. el.nickAsPrice.addEventListener('change', () => { const item = currentItem(); if (!item) return; item.nickAsPrice = el.nickAsPrice.checked; renderPreview(); }); // Nickname position (auto / top / bottom). document.querySelectorAll('input[name="badge-nick-position"]').forEach(r => { r.addEventListener('change', () => { const item = currentItem(); if (!item) return; if (r.checked) { item.nickPosition = r.value; renderPreview(); } }); }); el.btnRemove.addEventListener('click', () => { const item = currentItem(); if (item) removeItem(item.id); }); // ---------- File upload ---------- el.btnUpload.addEventListener('click', () => el.fileInput.click()); el.fileInput.addEventListener('change', (e) => { const files = Array.from(e.target.files || []); if (!files.length) return; files.forEach(file => { if (!file.type.startsWith('image/')) { showNotification(`${file.name}: не изображение`, 'error'); return; } if (file.size > 25 * 1024 * 1024) { showNotification(`${file.name}: слишком большой (макс 25MB)`, 'error'); return; } const reader = new FileReader(); reader.onload = async (ev) => { const item = await addItemFromUrl(ev.target.result, file.name, false); if (!badgeState.activeId) selectItem(item.id); }; reader.readAsDataURL(file); }); e.target.value = ''; }); // ---------- Flickr selection ---------- el.btnFromFlickr.addEventListener('click', async () => { const photos = state.selectedPhotos || []; if (photos.length === 0) { showNotification('Сначала выберите фото в Галерее (галочками)', 'info'); document.querySelector('.nav-btn[data-tab="gallery"]')?.click(); return; } const added = await autoImportGallerySelection(true); if (added === 0) { showNotification('Все выбранные фото уже в списке', 'info'); } }); // ---------- Download / save ---------- function triggerDownload(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } // On phones, prefer the OS share sheet ("Save to Photos" on iOS, "Save image" on Android). // Falls back to a regular download elsewhere. async function saveOrShare(blob, filename) { try { if (typeof File !== 'undefined' && navigator.canShare) { const file = new File([blob], filename, { type: 'image/png' }); if (navigator.canShare({ files: [file] })) { await navigator.share({ files: [file], title: 'Цифровой бейдж' }); return 'shared'; } } } catch (err) { if (err && err.name === 'AbortError') return 'aborted'; // fall through to download } triggerDownload(blob, filename); return 'downloaded'; } function badgeFilename(item, idx) { const safeName = (item.name || 'badge') .replace(/\.[^.]+$/, '') .replace(/[^\wа-яА-ЯёЁ\-_.]+/g, '_') .slice(0, 32) || 'badge'; return `badge_${safeName}${idx != null ? '_' + (idx + 1) : ''}.png`; } async function saveToServerHistory(canvas, item) { try { const dataUrl = canvas.toDataURL('image/png'); const fd = new FormData(); fd.append('action', 'badge_save'); fd.append('image', dataUrl); fd.append('has_price', item.showPrice ? '1' : '0'); fd.append('has_nickname', item.showNickname ? '1' : '0'); fd.append('label', item.name || ''); const res = await fetch('api.php', { method: 'POST', body: fd }); const data = await res.json(); if (data.error) { console.warn('Не удалось сохранить в историю:', data.error); } return data; } catch (err) { console.warn('История недоступна:', err); } } el.btnDownload.addEventListener('click', async () => { const item = currentItem(); if (!item) return; if (!item.image) { showNotification('Фото ещё не загружено', 'error'); return; } try { const { blob, canvas } = await exportItemToBlob(item); if (!blob) { showNotification('Не удалось сохранить (CORS?)', 'error'); return; } const result = await saveOrShare(blob, badgeFilename(item)); await saveToServerHistory(canvas, item); loadHistory(); if (result === 'shared') { showNotification('Готово — сохраните в «Фото»', 'success'); } else if (result === 'downloaded') { showNotification('Файл скачан', 'success'); } } catch (err) { showNotification('Ошибка: ' + (err.message || err), 'error'); } }); el.btnDownloadAll.addEventListener('click', async () => { if (badgeState.items.length === 0) return; const ready = badgeState.items.filter(it => it.image); if (ready.length === 0) { showNotification('Нет загруженных фото', 'error'); return; } showNotification(`Сохраняю ${ready.length} бейджей...`, 'info'); for (let i = 0; i < ready.length; i++) { const item = ready[i]; try { const { blob, canvas } = await exportItemToBlob(item); if (!blob) continue; triggerDownload(blob, badgeFilename(item, i)); await saveToServerHistory(canvas, item); await new Promise(r => setTimeout(r, 250)); } catch (err) { console.warn('Skip item:', err); } } loadHistory(); }); // ---------- History ---------- async function loadHistory() { if (!el.historyGrid) return; try { const fd = new FormData(); fd.append('action', 'badge_list'); const res = await fetch('api.php', { method: 'POST', body: fd }); const data = await res.json(); renderHistory(data.items || []); } catch (err) { console.warn('Не удалось загрузить историю:', err); } } function renderHistory(items) { if (!items.length) { el.historyGrid.innerHTML = '

История пуста — сгенерированные бейджи будут появляться здесь.

'; return; } el.historyGrid.innerHTML = ''; items.forEach(it => { const div = document.createElement('div'); div.className = 'badge-history-item'; const date = document.createElement('span'); date.className = 'badge-history-date'; date.textContent = (it.created_at || '').slice(5, 16).replace(' ', ' '); div.appendChild(date); const img = document.createElement('img'); img.src = it.url; img.alt = it.filename; div.appendChild(img); const actions = document.createElement('div'); actions.className = 'badge-history-actions'; const dl = document.createElement('button'); dl.type = 'button'; dl.textContent = 'Сохранить'; dl.addEventListener('click', async () => { try { const res = await fetch(it.url); const blob = await res.blob(); await saveOrShare(blob, it.filename); } catch (err) { const a = document.createElement('a'); a.href = it.url; a.download = it.filename; document.body.appendChild(a); a.click(); a.remove(); } }); actions.appendChild(dl); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'danger'; rm.textContent = 'Удалить'; rm.addEventListener('click', async () => { if (!confirm('Удалить этот бейдж из истории?')) return; const fd = new FormData(); fd.append('action', 'badge_delete'); fd.append('filename', it.filename); await fetch('api.php', { method: 'POST', body: fd }); loadHistory(); }); actions.appendChild(rm); div.appendChild(actions); el.historyGrid.appendChild(div); }); } // ---------- Settings (nickname) ---------- async function loadBadgeSettings() { try { const fd = new FormData(); fd.append('action', 'get_badge_settings'); const res = await fetch('api.php', { method: 'POST', body: fd }); const data = await res.json(); const nick = (data.settings && data.settings.nickname) || ''; badgeState.defaultNickname = nick; if (el.nickDefault) el.nickDefault.value = nick; } catch (err) { console.warn('Badge settings load failed:', err); } } if (el.btnSaveNick) { el.btnSaveNick.addEventListener('click', async () => { const nick = el.nickDefault.value.trim(); el.nickSaveStatus.textContent = 'Сохранение...'; try { const fd = new FormData(); fd.append('action', 'save_badge_settings'); fd.append('nickname', nick); const res = await fetch('api.php', { method: 'POST', body: fd }); const data = await res.json(); if (data.success) { badgeState.defaultNickname = nick; el.nickSaveStatus.textContent = '✓ Сохранено'; el.nickSaveStatus.style.color = '#34C759'; setTimeout(() => { el.nickSaveStatus.textContent = ''; }, 2000); } else { el.nickSaveStatus.textContent = 'Ошибка: ' + (data.error || ''); el.nickSaveStatus.style.color = '#FF3B30'; } } catch (err) { el.nickSaveStatus.textContent = 'Ошибка сети'; el.nickSaveStatus.style.color = '#FF3B30'; } }); } // ---------- Tab activation ---------- // Pull in currently-selected gallery photos that aren't already in the // badge list. Called when opening the badge tab and when clicking the // "from Flickr" button. Returns the number of items added. async function autoImportGallerySelection(showFeedback) { const photos = state.selectedPhotos || []; if (photos.length === 0) return 0; const existingSrcs = new Set(badgeState.items.map(it => it.src)); const toAdd = []; for (const photo of photos) { const url = (photo.urls && (photo.urls.large || photo.urls.medium640 || photo.urls.medium)) || null; if (!url || existingSrcs.has(url)) continue; toAdd.push({ url, name: photo.title || ('photo_' + photo.id) }); } if (toAdd.length === 0) return 0; for (const t of toAdd) { const item = await addItemFromUrl(t.url, t.name, true); if (!badgeState.activeId) selectItem(item.id); } if (showFeedback) { showNotification(`Добавлено ${toAdd.length} фото из Галереи`, 'success'); } return toAdd.length; } document.querySelectorAll('.nav-btn[data-tab="badge"]').forEach(btn => { btn.addEventListener('click', () => { loadBadgeSettings(); loadHistory(); // Auto-import gallery selections so users see them right away // without having to click "Выбранные в галерее Flickr". autoImportGallerySelection(false); }); }); // Initial settings load (so default nickname is available even before opening the tab) loadBadgeSettings(); } });