${escapeHtml(post.text.substring(0, 150))}${post.text.length > 150 ? '...' : ''}
` : ''} ${post.tags?.length ? `` : ''}/** * 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 function getAlbumCache() { try { const cached = localStorage.getItem(ALBUM_CACHE_KEY); if (!cached) return null; const data = JSON.parse(cached); if (Date.now() - data.timestamp > CACHE_TTL) { localStorage.removeItem(ALBUM_CACHE_KEY); return null; } // Handle both old format (array) and new format (object with albums property) const albums = data.albums; if (Array.isArray(albums)) { return albums; } else if (albums && Array.isArray(albums.albums)) { return albums.albums; } return null; } catch (e) { return null; } } function setAlbumCache(albumsOrData) { try { // Accept either array or object with albums property const albums = Array.isArray(albumsOrData) ? albumsOrData : (albumsOrData.albums || []); localStorage.setItem(ALBUM_CACHE_KEY, JSON.stringify({ albums: albums, timestamp: Date.now() })); } catch (e) { console.warn('Failed to cache albums:', e); } } function getAlbumPrefs() { try { const prefs = localStorage.getItem(ALBUM_PREFS_KEY); return prefs ? JSON.parse(prefs) : { favorites: [], order: [] }; } catch (e) { return { favorites: [], order: [] }; } } function saveAlbumPrefs(prefs) { try { localStorage.setItem(ALBUM_PREFS_KEY, JSON.stringify(prefs)); } catch (e) { console.warn('Failed to save album prefs:', e); } } function toggleAlbumFavorite(albumId) { const prefs = getAlbumPrefs(); const index = prefs.favorites.indexOf(albumId); if (index === -1) { prefs.favorites.push(albumId); } else { prefs.favorites.splice(index, 1); } saveAlbumPrefs(prefs); renderAlbumDropdown(window._cachedAlbums || []); } function sortAlbumsByPreference(albums) { const prefs = getAlbumPrefs(); const favorites = new Set(prefs.favorites); // Sort: favorites first, then rest return [...albums].sort((a, b) => { const aFav = favorites.has(a.id); const bFav = favorites.has(b.id); if (aFav && !bFav) return -1; if (!aFav && bFav) return 1; return 0; // Keep original order within groups }); } // ============ DOM ELEMENTS ============ const selectionBar = document.getElementById('selection-bar'); const selectedCountEl = document.getElementById('selected-count'); const photoGallery = document.getElementById('photo-gallery'); const postPhotosPreview = document.getElementById('post-photos-preview'); // ============ UTILITY FUNCTIONS ============ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } function showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.textContent = message; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 16px 24px; border-radius: 12px; font-weight: 500; z-index: 10000; animation: slideIn 0.3s ease; backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); color: white; background: ${type === 'success' ? 'rgba(52, 199, 89, 0.9)' : type === 'error' ? 'rgba(255, 59, 48, 0.9)' : 'rgba(0, 122, 255, 0.9)'}; `; document.body.appendChild(notification); setTimeout(() => { notification.style.animation = 'slideOut 0.3s ease'; setTimeout(() => notification.remove(), 300); }, 3000); } // Add animation styles const style = document.createElement('style'); style.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } `; document.head.appendChild(style); // ============ SELECTION MANAGEMENT ============ function updateSelectionUI() { const count = state.selectedPhotos.length; const total = getTotalPhotosCount(); 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 = `Альбомы не найдены
'; if (dragHint) dragHint.classList.add('hidden'); return; } if (dragHint) dragHint.classList.remove('hidden'); albumsGrid.innerHTML = ''; orderedAlbums.forEach(album => { const card = createAlbumCard(album); albumsGrid.appendChild(card); }); } function createAlbumCard(album) { const card = document.createElement('div'); card.className = 'album-card'; card.dataset.albumId = album.id; card.draggable = true; const title = album.title?._content || album.title || 'Без названия'; const count = album.photos || 0; const coverUrl = album.primary_photo_extras?.url_m || album.primary_photo_extras?.url_s || album.primary_photo_extras?.url_sq || null; card.innerHTML = ` ${coverUrl ? `Альбомы не найдены. Проверьте настройки Flickr API.
'; showNotification('Альбомы не найдены', 'error'); } } catch (error) { if (error.name === 'AbortError') { console.log('Request aborted'); albumsGrid.innerHTML = ` `; showNotification('Таймаут: сервер не отвечает', 'error'); return; } console.error('Ошибка загрузки альбомов:', error); albumsGrid.innerHTML = ` `; showNotification('Ошибка: ' + error.message, 'error'); } finally { state.isLoadingAlbums = false; if (btnLoadAlbums) { btnLoadAlbums.disabled = false; btnLoadAlbums.innerHTML = ' Загрузить альбомы'; } } } // Load more albums (infinite scroll) async function loadMoreAlbums() { if (state.isLoadingMoreAlbums || state.albumsPage >= state.albumsTotalPages) { return; } state.isLoadingMoreAlbums = true; const nextPage = state.albumsPage + 1; // Show loading indicator at bottom const loadingEl = document.createElement('div'); loadingEl.className = 'albums-loading-more'; loadingEl.innerHTML = ` Загрузка альбомов... `; albumsGrid.appendChild(loadingEl); try { const response = await fetch(`api.php?action=flickr_albums&page=${nextPage}&per_page=50`); const data = await response.json(); // Remove loading indicator loadingEl.remove(); if (data.error) { throw new Error(data.error); } if (data.albums && data.albums.length > 0) { state.albumsPage = data.page; state.allAlbums = [...state.allAlbums, ...data.albums]; window._cachedAlbums = state.allAlbums; // Update cache setAlbumCache({ albums: state.allAlbums, page: state.albumsPage, pages: state.albumsTotalPages, total: state.albumsTotal }); // Append new albums to grid appendAlbumsToGrid(data.albums); console.log(`Loaded page ${nextPage}/${state.albumsTotalPages}, total albums: ${state.allAlbums.length}`); } } catch (error) { loadingEl.remove(); console.error('Error loading more albums:', error); showNotification('Ошибка загрузки альбомов: ' + error.message, 'error'); } finally { state.isLoadingMoreAlbums = false; // 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 = `Ошибка: ${data.error}
`; return; } state.totalPages = data.pagination?.pages || 1; state.currentPage = data.pagination?.page || 1; const totalPhotos = data.pagination?.total || 0; updatePagination(); if (photosCountEl) { photosCountEl.textContent = `${totalPhotos} фото`; } if (!data.photos || data.photos.length === 0) { photoGallery.innerHTML = 'Фотографии не найдены
'; return; } state.allPhotos = data.photos; photoGallery.innerHTML = ''; renderPhotos(data.photos); // Setup infinite scroll if there are more pages if (state.currentPage < state.totalPages) { setupPhotosInfiniteScroll(); } } catch (error) { if (error.name === 'AbortError') return; photoGallery.innerHTML = `Ошибка: ${error.message}
`; showNotification('Ошибка загрузки фотографий', 'error'); } finally { state.isLoadingPhotos = false; } } // Render photos to gallery function renderPhotos(photos, append = false) { if (!append) { photoGallery.innerHTML = ''; } photos.forEach(photo => { const div = document.createElement('div'); div.className = 'photo-item'; if (photo.is_video) { div.classList.add('is-video'); } div.dataset.photoId = photo.id; div.dataset.photoData = JSON.stringify(photo); if (state.selectedPhotos.find(p => p.id === photo.id)) { div.classList.add('selected'); } const videoBadge = photo.is_video ? 'Выберите формат загрузки:
Нет пресетов. Добавьте первый!
'; return; } container.innerHTML = presets.map(preset => `'; after = ''; break;
case 'link':
const url = prompt('Введите URL:', 'https://');
if (url) {
before = '';
after = '';
if (!selected) newText = url;
}
break;
}
} else if (parseMode === 'Markdown') {
switch (format) {
case 'bold': before = '**'; after = '**'; break;
case 'italic': before = '_'; after = '_'; break;
case 'underline': before = '__'; after = '__'; break;
case 'strike': before = '~~'; after = '~~'; break;
case 'code': before = '`'; after = '`'; break;
case 'link':
const url = prompt('Введите URL:', 'https://');
if (url) {
before = '[';
after = '](' + url + ')';
if (!selected) newText = 'ссылка';
}
break;
}
} else {
// Plain text - just insert without formatting
if (format === 'link') {
const url = prompt('Введите URL:', 'https://');
if (url) {
newText = url;
}
}
before = '';
after = '';
}
if (before || after || newText !== selected) {
const replacement = before + newText + after;
textarea.value = text.substring(0, start) + replacement + text.substring(end);
textarea.focus();
textarea.setSelectionRange(start + before.length, start + before.length + newText.length);
}
});
});
}
initTextEditorToolbar();
// ============ SCHEDULED POSTS ============
const scheduledState = {
uploadedFiles: [],
editingPostId: null
};
// Schedule toggle handler
const scheduleCheckbox = document.getElementById('chk-schedule');
const scheduleOptions = document.getElementById('schedule-options');
const btnSendPostMain = document.getElementById('btn-send-post');
function updateScheduleUI() {
const isScheduled = scheduleCheckbox?.checked;
if (scheduleOptions) {
scheduleOptions.classList.toggle('hidden', !isScheduled);
}
if (btnSendPostMain) {
btnSendPostMain.innerHTML = isScheduled ? '📅 Запланировать' : '🚀 Опубликовать';
}
}
scheduleCheckbox?.addEventListener('change', updateScheduleUI);
// Schedule date/time picker
const scheduleDate = document.getElementById('schedule-date');
const scheduleTime = document.getElementById('schedule-time');
const scheduledDatetime = document.getElementById('scheduled-datetime');
// Set default to 1 hour from now
function setDefaultScheduleTime() {
const oneHourLater = new Date();
oneHourLater.setHours(oneHourLater.getHours() + 1);
oneHourLater.setMinutes(0, 0, 0); // Round to nearest hour
setScheduleDateTime(oneHourLater);
}
function setScheduleDateTime(date) {
if (scheduleDate) {
// Use local date components to avoid timezone issues
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
scheduleDate.value = `${year}-${month}-${day}`;
}
if (scheduleTime) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
scheduleTime.value = `${hours}:${minutes}`;
}
syncScheduledDatetime();
}
function syncScheduledDatetime() {
if (scheduleDate && scheduleTime && scheduledDatetime) {
scheduledDatetime.value = `${scheduleDate.value}T${scheduleTime.value}`;
}
}
// Sync hidden field when date/time changes
scheduleDate?.addEventListener('change', syncScheduledDatetime);
scheduleTime?.addEventListener('change', syncScheduledDatetime);
// Set min date to today (local date)
if (scheduleDate) {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
scheduleDate.min = `${year}-${month}-${day}`;
}
// Handle preset buttons
document.querySelectorAll('.schedule-presets .preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const preset = btn.dataset.preset;
const now = new Date();
let targetDate = new Date();
switch (preset) {
case '1h':
targetDate.setHours(now.getHours() + 1);
break;
case '3h':
targetDate.setHours(now.getHours() + 3);
break;
case 'tomorrow-10':
targetDate.setDate(now.getDate() + 1);
targetDate.setHours(10, 0, 0, 0);
break;
case 'tomorrow-18':
targetDate.setDate(now.getDate() + 1);
targetDate.setHours(18, 0, 0, 0);
break;
}
setScheduleDateTime(targetDate);
// Highlight active preset
document.querySelectorAll('.schedule-presets .preset-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Initialize with default time
setDefaultScheduleTime();
// Load scheduled and published posts on posting tab switch
document.querySelectorAll('.nav-btn[data-tab="posting"]').forEach(btn => {
btn.addEventListener('click', () => {
loadScheduledPosts();
loadPublishedPosts();
updateScheduledCount();
});
});
async function loadScheduledPosts() {
const list = document.getElementById('scheduled-posts-list');
if (!list) return;
try {
const formData = new FormData();
formData.append('action', 'get_scheduled_posts');
const response = await fetch('api.php', { method: 'POST', body: formData });
const data = await response.json();
if (data.posts && data.posts.length > 0) {
const pendingPosts = data.posts.filter(p => p.status === 'pending');
if (pendingPosts.length === 0) {
list.innerHTML = 'Нет запланированных постов
'; return; } list.innerHTML = pendingPosts.map(post => { const dateStr = new Date(post.scheduled_time).toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); const platforms = (post.platforms || []).map(p => { const type = p.type || p; return type === 'telegram' ? 'TG' : type === 'vk' ? 'VK' : type; }).join(' · '); // Collect all photo URLs for preview const allPhotos = [ ...(post.photos || []), ...(post.uploaded_files || []).map(f => f.url || f) ]; const photosCount = allPhotos.length; // Generate photo preview HTML (show up to 4 photos) let photosPreviewHtml = ''; if (photosCount > 0) { const previewPhotos = allPhotos.slice(0, 4); const moreCount = photosCount > 4 ? photosCount - 4 : 0; photosPreviewHtml = `${escapeHtml(post.text.substring(0, 150))}${post.text.length > 150 ? '...' : ''}
` : ''} ${post.tags?.length ? `` : ''}Нет запланированных постов
'; updateScheduledCount(0); } } catch (error) { list.innerHTML = 'Ошибка загрузки
'; } } // Update scheduled posts count badge async function updateScheduledCount(count = null) { const badge = document.getElementById('scheduled-count'); if (!badge) return; if (count !== null) { badge.textContent = count; badge.style.display = count > 0 ? 'inline-block' : 'none'; return; } try { const formData = new FormData(); formData.append('action', 'get_scheduled_posts'); const response = await fetch('api.php', { method: 'POST', body: formData }); const data = await response.json(); if (data.posts) { const pendingCount = data.posts.filter(p => p.status === 'pending').length; badge.textContent = pendingCount; badge.style.display = pendingCount > 0 ? 'inline-block' : 'none'; } } catch (error) { console.error('Failed to update scheduled count:', error); } } async function deleteScheduledPost(postId) { if (!confirm('Удалить этот запланированный пост?')) return; try { const formData = new FormData(); formData.append('action', 'delete_scheduled_post'); formData.append('id', postId); const response = await fetch('api.php', { method: 'POST', body: formData }); const data = await response.json(); if (data.error) { showNotification('Ошибка: ' + data.error, 'error'); } else { showNotification('Пост удалён', 'success'); loadScheduledPosts(); } } catch (error) { showNotification('Ошибка: ' + error.message, 'error'); } } function editScheduledPost(postId, posts) { const post = posts.find(p => p.id === postId); if (!post) return; const card = document.querySelector(`.scheduled-post-card[data-id="${postId}"]`); if (!card) return; // Collect all photo URLs const allPhotos = [ ...(post.photos || []), ...(post.uploaded_files || []).map(f => f.url || f) ]; const hasTg = (post.platforms || []).some(p => (p.type || p) === 'telegram'); const hasVk = (post.platforms || []).some(p => (p.type || p) === 'vk'); const tags = post.tags || []; // Parse scheduled_time into date and time inputs let dateVal = '', timeVal = ''; if (post.scheduled_time) { const dt = new Date(post.scheduled_time.replace(' ', 'T')); dateVal = `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`; timeVal = `${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`; } // Save original HTML for cancel const originalHtml = card.innerHTML; // Render inline editor card.classList.add('editing'); card.innerHTML = `Нет фото
'}Нет фото
'; } }); }); } else { photosContainer.innerHTML = 'Нет фото
'; } }); }); // Tag remove handlers card.querySelectorAll('.tag-remove').forEach(btn => { btn.addEventListener('click', () => { editTags = editTags.filter(t => t !== btn.dataset.tag); btn.parentElement.remove(); }); }); // Tag add on Enter const tagInput = card.querySelector('.inline-tags-input'); tagInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const tag = tagInput.value.trim().replace(/^#/, '').replace(/[,\s]+/g, ''); if (tag && !editTags.includes(tag)) { editTags.push(tag); const chip = document.createElement('span'); chip.className = 'tag-chip'; chip.innerHTML = `#${escapeHtml(tag)} `; chip.querySelector('.tag-remove').addEventListener('click', () => { editTags = editTags.filter(t => t !== tag); chip.remove(); }); card.querySelector('.inline-tags-list').appendChild(chip); } tagInput.value = ''; } }); // Cancel card.querySelector('.inline-cancel').addEventListener('click', () => { card.classList.remove('editing'); card.innerHTML = originalHtml; // Re-attach original handlers card.querySelector('.btn-edit-scheduled')?.addEventListener('click', () => editScheduledPost(postId, posts)); card.querySelector('.btn-delete-scheduled')?.addEventListener('click', () => deleteScheduledPost(postId)); }); // Save card.querySelector('.inline-save').addEventListener('click', async () => { const text = card.querySelector('.inline-editor-text').value; const dateInput = card.querySelector('.inline-date').value; const timeInput = card.querySelector('.inline-time').value; const tgChecked = card.querySelector('.inline-chk-tg').checked; const vkChecked = card.querySelector('.inline-chk-vk').checked; if (!dateInput || !timeInput) { showNotification('Укажите дату и время', 'error'); return; } const scheduledTime = `${dateInput} ${timeInput}:00`; const platforms = []; if (tgChecked) platforms.push({ type: 'telegram', target: document.getElementById('tg-channel')?.value || '' }); if (vkChecked) platforms.push({ type: 'vk', target: document.getElementById('vk-group')?.value || '' }); if (platforms.length === 0) { showNotification('Выберите платформу', 'error'); return; } // Separate photos and uploaded files based on original data const origPhotos = post.photos || []; const origUploaded = (post.uploaded_files || []).map(f => f.url || f); const newPhotos = editPhotos.filter(url => origPhotos.includes(url)); const newUploaded = editPhotos.filter(url => origUploaded.includes(url)).map(url => { const orig = (post.uploaded_files || []).find(f => (f.url || f) === url); return orig && typeof orig === 'object' ? orig : { url }; }); // Photos not in either original list go to photos editPhotos.forEach(url => { if (!newPhotos.includes(url) && !newUploaded.some(f => (f.url || f) === url)) { newPhotos.push(url); } }); const saveBtn = card.querySelector('.inline-save'); saveBtn.disabled = true; saveBtn.textContent = 'Сохранение...'; try { const formData = new FormData(); formData.append('action', 'update_scheduled_post'); formData.append('id', postId); formData.append('text', text); formData.append('tags', JSON.stringify(editTags)); formData.append('photos', JSON.stringify(newPhotos)); formData.append('uploaded_files', JSON.stringify(newUploaded)); formData.append('platforms', JSON.stringify(platforms)); formData.append('scheduled_time', scheduledTime); const response = await fetch('api.php', { method: 'POST', body: formData }); const data = await response.json(); if (data.error) { showNotification('Ошибка: ' + data.error, 'error'); saveBtn.disabled = false; saveBtn.textContent = '💾 Сохранить'; } else { showNotification('Пост обновлён!', 'success'); loadScheduledPosts(); } } catch (error) { showNotification('Ошибка: ' + error.message, 'error'); saveBtn.disabled = false; saveBtn.textContent = '💾 Сохранить'; } }); // Publish now card.querySelector('.inline-publish-now').addEventListener('click', async () => { const text = card.querySelector('.inline-editor-text').value; const tgChecked = card.querySelector('.inline-chk-tg').checked; const vkChecked = card.querySelector('.inline-chk-vk').checked; const platforms = []; const tgChannel = document.getElementById('tg-channel')?.value; const vkGroup = document.getElementById('vk-group')?.value; if (tgChecked && tgChannel) platforms.push({ type: 'telegram', target: tgChannel }); if (vkChecked && vkGroup) platforms.push({ type: 'vk', target: vkGroup }); if (platforms.length === 0) { showNotification('Выберите платформу и канал/группу', 'error'); return; } if (editPhotos.length === 0 && !text.trim()) { showNotification('Добавьте фото или текст', 'error'); return; } if (!confirm('Опубликовать этот пост сейчас?')) return; const pubBtn = card.querySelector('.inline-publish-now'); pubBtn.disabled = true; pubBtn.textContent = 'Публикация...'; try { // Add tags to text const tagsStr = editTags.map(t => '#' + t).join(' '); let fullText = text.trim(); if (tagsStr) { fullText = fullText ? fullText + '\n\n' + tagsStr : tagsStr; } // Add cross-promo const crossPromoEnabled = post.cross_promo; if (crossPromoEnabled) { const storedSettings = getCrossPromoSettings(); if (tgChecked && storedSettings.vkLink) { // Will be handled per-platform below } } // Publish to each platform const results = {}; for (const platform of platforms) { let platformText = fullText; // Cross-promo if (crossPromoEnabled) { const s = getCrossPromoSettings(); if (platform.type === 'telegram' && s.vkLink) { const parseMode = 'HTML'; platformText += `\n\n${s.textForTg || 'Мой канал ВКонтакте'}`; } if (platform.type === 'vk' && s.telegramLink) { platformText += `\n\n${s.textForVk || 'Мой канал в Telegram'}: ${s.telegramLink}`; } } const formData = new FormData(); formData.append('action', 'multi_post'); formData.append('platforms', JSON.stringify([platform])); formData.append('text', platformText); formData.append('photos', JSON.stringify(editPhotos)); formData.append('uploaded_files', JSON.stringify([])); formData.append('parse_mode', 'HTML'); const response = await fetch('api.php', { method: 'POST', body: formData }); const data = await response.json(); if (data.results) Object.assign(results, data.results); } // Check results const allSuccess = Object.values(results).every(r => r.success); const anySuccess = Object.values(results).some(r => r.success); if (anySuccess) { // 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 = `${escapeHtml(post.text.substring(0, 100))}${post.text.length > 100 ? '...' : ''}
` : ''} ${warningText ? `⚠ ${escapeHtml(warningText)}
` : ''}Нет опубликованных постов
'; } } catch (error) { list.innerHTML = 'Ошибка загрузки
'; } } // Refresh archive button document.getElementById('btn-refresh-archive')?.addEventListener('click', loadPublishedPosts); // Load scheduled and published posts on initial page load loadScheduledPosts(); loadPublishedPosts(); // Create/Update scheduled post document.getElementById('btn-create-scheduled')?.addEventListener('click', async () => { const text = document.getElementById('scheduled-text')?.value || ''; const datetime = document.getElementById('scheduled-datetime')?.value || ''; const tgChecked = document.getElementById('scheduled-chk-telegram')?.checked; const vkChecked = document.getElementById('scheduled-chk-vk')?.checked; if (!datetime) { showNotification('Укажите дату и время публикации', 'error'); return; } const platforms = []; if (tgChecked) platforms.push({ type: 'telegram' }); if (vkChecked) platforms.push({ type: 'vk' }); if (platforms.length === 0) { showNotification('Выберите хотя бы одну платформу', 'error'); return; } // Get photos from gallery selection const photoUrls = state.selectedPhotos.map(p => p.urls.large || p.urls.original || p.urls.medium640); const formData = new FormData(); formData.append('action', scheduledState.editingPostId ? 'update_scheduled_post' : 'create_scheduled_post'); if (scheduledState.editingPostId) { formData.append('id', scheduledState.editingPostId); } formData.append('text', text); formData.append('tags', JSON.stringify([])); // TODO: add tags support formData.append('photos', JSON.stringify(photoUrls)); formData.append('uploaded_files', JSON.stringify(scheduledState.uploadedFiles.filter(f => f.url))); formData.append('platforms', JSON.stringify(platforms)); formData.append('scheduled_time', datetime.replace('T', ' ')); try { const response = await fetch('api.php', { method: 'POST', body: formData }); const data = await response.json(); if (data.error) { showNotification('Ошибка: ' + data.error, 'error'); } else { showNotification(scheduledState.editingPostId ? 'Пост обновлён' : 'Пост запланирован!', 'success'); // Reset form document.getElementById('scheduled-text').value = ''; document.getElementById('scheduled-datetime').value = ''; scheduledState.editingPostId = null; scheduledState.uploadedFiles = []; document.getElementById('scheduled-uploaded-preview').innerHTML = ''; const btn = document.getElementById('btn-create-scheduled'); if (btn) btn.textContent = '📅 Запланировать публикацию'; loadScheduledPosts(); } } catch (error) { showNotification('Ошибка: ' + error.message, 'error'); } }); // File upload for scheduled posts document.getElementById('btn-scheduled-upload')?.addEventListener('click', () => { document.getElementById('scheduled-file-upload')?.click(); }); document.getElementById('scheduled-file-upload')?.addEventListener('change', async (e) => { const files = Array.from(e.target.files); if (!files.length) return; for (const file of files) { if (file.size > 50 * 1024 * 1024) { showNotification(`Файл ${file.name} слишком большой`, 'error'); continue; } const formData = new FormData(); formData.append('action', 'upload_file'); formData.append('file', file); try { const response = await fetch('api.php', { method: 'POST', body: formData }); const data = await response.json(); if (data.error) { showNotification(`Ошибка: ${data.error}`, 'error'); } else { scheduledState.uploadedFiles.push({ url: data.url, type: data.type, name: file.name }); renderScheduledUploads(); } } catch (error) { showNotification('Ошибка загрузки', 'error'); } } e.target.value = ''; }); function renderScheduledUploads() { const preview = document.getElementById('scheduled-uploaded-preview'); if (!preview) return; preview.innerHTML = scheduledState.uploadedFiles.map((file, idx) => `Сначала выберите фото в Галерее, затем вернитесь сюда
'; } else { preview.innerHTML = state.selectedPhotos.map(photo => `Загрузка альбомов...
'; try { const response = await fetch('widget_api.php?action=get_albums'); const data = await response.json(); if (data.success && data.albums) { renderWidgetAlbums(data.albums); } else { widgetAlbumsList.innerHTML = 'Ошибка загрузки альбомов
'; } } catch (error) { widgetAlbumsList.innerHTML = 'Ошибка: ' + error.message + '
'; } } // Render widget albums selection function renderWidgetAlbums(albums) { if (!widgetAlbumsList) return; if (albums.length === 0) { widgetAlbumsList.innerHTML = 'Нет доступных альбомов
'; return; } widgetAlbumsList.innerHTML = albums.map(album => { const isSelected = widgetSelectedAlbums.includes(album.id); const title = album.title?._content || album.title || 'Без названия'; const count = album.photos || 0; const thumb = album.primary_photo_extras?.url_m || album.primary_photo_extras?.url_s || album.primary_photo_extras?.url_sq || ''; return ` `; }).join(''); // Add event listeners widgetAlbumsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { checkbox.addEventListener('change', function() { const albumId = this.value; const label = this.closest('.widget-album-item'); if (this.checked) { if (!widgetSelectedAlbums.includes(albumId)) { widgetSelectedAlbums.push(albumId); } label.classList.add('selected'); } else { widgetSelectedAlbums = widgetSelectedAlbums.filter(id => id !== albumId); label.classList.remove('selected'); } }); }); } // Save widget settings async function saveWidgetSettings() { const settings = { enabled: widgetEnabled ? widgetEnabled.checked : true, albums: widgetSelectedAlbums, max_photos: widgetMaxPhotos ? parseInt(widgetMaxPhotos.value) : 30, cache_time: widgetCacheTime ? parseInt(widgetCacheTime.value) : 3600 }; try { const response = await fetch('widget_api.php?action=save_settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); const data = await response.json(); if (data.success) { if (widgetSaveStatus) { widgetSaveStatus.textContent = '✓ Сохранено'; widgetSaveStatus.className = 'save-status success'; setTimeout(() => { widgetSaveStatus.textContent = ''; }, 3000); } showNotification('Настройки виджета сохранены', 'success'); } else { showNotification(data.error || 'Ошибка сохранения', 'error'); } } catch (error) { showNotification('Ошибка: ' + error.message, 'error'); } } // Widget event listeners if (btnLoadWidgetAlbums) { btnLoadWidgetAlbums.addEventListener('click', loadWidgetAlbums); } if (btnSaveWidgetSettings) { btnSaveWidgetSettings.addEventListener('click', saveWidgetSettings); } // Load widget settings on tab switch document.querySelectorAll('.nav-btn[data-tab="widget"]').forEach(btn => { btn.addEventListener('click', () => { loadWidgetSettings(); }); }); // Initial load loadTelegramStatus(); loadVKStatus(); loadFlickrOAuthStatus(); updateScheduledCount(); // Load scheduled posts count badge // Restore selected photos and uploaded files UI if any were saved if (state.selectedPhotos.length > 0 || state.uploadedFiles.length > 0) { updateSelectionUI(); updatePostingPreview(); if (state.selectedPhotos.length > 0) { console.log('Restored ' + state.selectedPhotos.length + ' Flickr photos from previous session'); } if (state.uploadedFiles.length > 0) { console.log('Restored ' + state.uploadedFiles.length + ' uploaded files from previous session'); } } // Auto-load albums on page init if (albumsGrid) { const cachedAlbums = getAlbumCache(); if (cachedAlbums && cachedAlbums.length > 0) { // Use cache first for instant display console.log('Loading cached albums:', cachedAlbums.length); window._cachedAlbums = cachedAlbums; renderAlbumsGrid(cachedAlbums); // Silently refresh in background setTimeout(() => refreshAlbumsSilently(), 2000); } else { // No cache or empty - load from API console.log('No cache, loading albums from API...'); loadAlbums(false); } } // ============ 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(); } });