Files
VH_posting_system/js/app.js
T
zuevav 7ed7d22b6d Actually fix mobile nav + arm albums infinite scroll for cached loads
Two regressions from the previous fix:

1. Mobile nav still wrapped into a tall stack. The @media block was
   placed BEFORE the base `.nav-btn { flex: 1; min-width: 120px }`
   rule, so source-order specificity made the base rule override the
   mobile one. Moved the @media block to AFTER all base nav-btn rules
   so it wins on screens ≤720px.

2. Albums infinite scroll never engaged when the gallery was loaded
   from cache (the common case). `getAlbumCache` returned only the
   array, `setAlbumCache` discarded pagination, and the DOMContentLoad
   auto-load + `refreshAlbumsSilently` both bypassed
   `setupAlbumsInfiniteScroll`. Now the cache preserves `{ list, page,
   pages, total }`, a new `getAlbumCacheFull` exposes it, and every
   cached-render path primes pagination state and arms the observer.
   Background refresh also re-arms the observer after pulling fresh
   `pages`, fixing the case where the cache had a stale `pages: 1`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:29:57 +03:00

5480 lines
218 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 => `
<span class="tag-chip">
#${escapeHtml(tag)}
<button type="button" class="tag-remove" data-tag="${escapeHtml(tag)}">&times;</button>
</span>
`).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 = '<p class="placeholder">Нажмите кнопку выше чтобы добавить фото</p>';
return;
}
postPhotosPreview.innerHTML = '';
// Render Flickr photos
state.selectedPhotos.forEach((photo, index) => {
const div = document.createElement('div');
div.className = 'preview-thumb';
div.innerHTML = `
<img src="${photo.urls.small || photo.urls.medium || photo.urls.square}" alt="${escapeHtml(photo.title)}">
<button class="remove-btn" data-index="${index}" data-source="flickr">×</button>
<span class="source-badge">flickr</span>
`;
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
? `<video src="${file.dataUrl}"></video><span class="video-badge">▶</span>`
: `<img src="${file.dataUrl}" alt="${escapeHtml(file.name)}">`
}
${file.uploading ? '<div class="upload-spinner"></div>' : ''}
<button class="remove-btn" data-id="${file.id}" data-source="upload">×</button>
<span class="source-badge">файл</span>
`;
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 = '<p class="placeholder">Альбомы не найдены</p>';
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
? `<img class="album-card-cover" src="${coverUrl}" alt="${escapeHtml(title)}" loading="lazy">`
: `<div class="album-card-placeholder">📁</div>`
}
<div class="album-card-overlay">
<div class="album-card-title">${escapeHtml(title)}</div>
<div class="album-card-count">${count} фото</div>
</div>
<div class="album-card-drag-handle" title="Перетащите для изменения порядка">⋮⋮</div>
`;
// 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 = `
<div class="loading" style="grid-column: 1/-1;">
<div class="loading-spinner"></div>
<span>Загрузка альбомов с Flickr...</span>
</div>
`;
if (dragHint) dragHint.classList.add('hidden');
// Also update button state
if (btnLoadAlbums) {
btnLoadAlbums.disabled = true;
btnLoadAlbums.innerHTML = '<span class="btn-icon-text">⏳</span> Загрузка...';
}
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 = '<p class="placeholder">Альбомы не найдены. Проверьте настройки Flickr API.</p>';
showNotification('Альбомы не найдены', 'error');
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request aborted');
albumsGrid.innerHTML = `
<div class="error-message" style="grid-column: 1/-1; text-align: center; padding: 40px;">
<p style="font-size: 2rem; margin-bottom: 10px;">⏱️</p>
<p class="placeholder">Превышено время ожидания. Flickr API не отвечает.</p>
<button class="btn btn-primary" style="margin-top: 15px;" onclick="location.reload()">Попробовать снова</button>
</div>
`;
showNotification('Таймаут: сервер не отвечает', 'error');
return;
}
console.error('Ошибка загрузки альбомов:', error);
albumsGrid.innerHTML = `
<div class="error-message" style="grid-column: 1/-1; text-align: center; padding: 40px;">
<p style="font-size: 2rem; margin-bottom: 10px;">⚠️</p>
<p class="placeholder">Ошибка загрузки: ${escapeHtml(error.message)}</p>
<button onclick="location.reload()" class="btn btn-small" style="margin-top: 15px;">Перезагрузить страницу</button>
</div>
`;
showNotification('Ошибка: ' + error.message, 'error');
} finally {
state.isLoadingAlbums = false;
if (btnLoadAlbums) {
btnLoadAlbums.disabled = false;
btnLoadAlbums.innerHTML = '<span class="btn-icon-text">↻</span> Загрузить альбомы';
}
}
}
// 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 = `
<div class="loading-spinner"></div>
<span>Загрузка альбомов...</span>
`;
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 = `
<div class="loading">
<div class="loading-spinner"></div>
<span>Загрузка фотографий...</span>
</div>
`;
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 = `<p class="placeholder">Ошибка: ${data.error}</p>`;
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 = '<p class="placeholder">Фотографии не найдены</p>';
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 = `<p class="placeholder">Ошибка: ${error.message}</p>`;
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 class="video-badge"><span class="video-icon">▶</span></div>' : '';
div.innerHTML = `
<div class="checkbox"></div>
${videoBadge}
<img src="${photo.urls.small || photo.urls.medium || photo.urls.square}" alt="${escapeHtml(photo.title)}" loading="lazy">
<div class="title">${escapeHtml(photo.title)}</div>
<button class="photo-preview-btn" title="Просмотр">${photo.is_video ? '▶' : '🔍'}</button>
`;
// 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 = `
<div class="loading-spinner"></div>
<span>Загрузка фотографий...</span>
`;
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 = `
<div class="download-dialog">
<h3>Скачать ${photos.length} фото</h3>
<p>Выберите формат загрузки:</p>
<div class="download-dialog-buttons">
<button class="btn btn-primary" data-action="zip">
<span class="btn-icon">📦</span>
ZIP архив
</button>
<button class="btn btn-secondary" data-action="individual">
<span class="btn-icon">📄</span>
Отдельные файлы
</button>
</div>
<button class="download-dialog-close" title="Отмена">×</button>
</div>
`;
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 = `
<div class="lightbox-backdrop"></div>
<div class="lightbox-content">
<button class="lightbox-close" title="Закрыть">×</button>
<div class="lightbox-image-container">
<img class="lightbox-image" src="" alt="">
<div class="lightbox-video-container" style="display: none;">
<iframe class="lightbox-video" src="" frameborder="0" allowfullscreen allow="autoplay"></iframe>
</div>
<div class="lightbox-loading">
<div class="loading-spinner"></div>
</div>
</div>
<div class="lightbox-info">
<h3 class="lightbox-title"></h3>
</div>
<div class="lightbox-actions">
<button class="btn btn-primary lightbox-btn-select">
<span class="btn-icon">✓</span> Выбрать
</button>
<button class="btn btn-secondary lightbox-btn-download">
<span class="btn-icon">↓</span> Скачать
</button>
<a class="btn btn-secondary lightbox-btn-flickr" href="#" target="_blank" title="Открыть на Flickr">
<span class="btn-icon">🔗</span> Flickr
</a>
</div>
<button class="lightbox-nav lightbox-prev" title="Предыдущее"></button>
<button class="lightbox-nav lightbox-next" title="Следующее"></button>
</div>
`;
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 = `
<div class="video-play-button">▶</div>
<div class="video-play-text">Смотреть на Flickr</div>
`;
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
? '<span class="btn-icon">✓</span> Выбрано'
: '<span class="btn-icon">+</span> Выбрать';
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
? '<span class="btn-icon">✓</span> Выбрано'
: '<span class="btn-icon">+</span> Выбрать';
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 = '<option value="">Выберите канал...</option>';
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 = '<option value="">Выберите группу...</option>';
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<a href="${crossPromoSettings.vkLink}">${linkText}</a>`;
} 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 => `
<span class="tag-chip">
#${escapeHtml(tag)}
<button type="button" class="tag-remove" data-tag="${escapeHtml(tag)}" title="Удалить">×</button>
</span>
`).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 => `
<div class="tag-suggestion" data-tag="${escapeHtml(t.name)}">
<span>#${escapeHtml(t.name)}</span>
<span class="tag-count">${t.count}×</span>
</div>
`).join('');
suggestions.classList.add('visible');
}
return;
}
if (items.length === 0) {
hideSuggestions();
return;
}
suggestions.innerHTML = items.map(t => `
<div class="tag-suggestion" data-tag="${escapeHtml(t.name)}">
<span>#${escapeHtml(t.name)}</span>
<span class="tag-count">${t.count}×</span>
</div>
`).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 => `
<button type="button" class="tag-preset" data-preset-id="${preset.id}" data-tags="${preset.tags.join(',')}" data-target="${target}">
${escapeHtml(preset.name)}
</button>
`).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 => `
<span class="tag-chip">
#${escapeHtml(tag)}
<button type="button" class="tag-remove" data-tag="${escapeHtml(tag)}" title="Удалить">×</button>
</span>
`).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 = '<p class="text-muted">Нет пресетов. Добавьте первый!</p>';
return;
}
container.innerHTML = presets.map(preset => `
<div class="preset-manager-item" data-preset-id="${preset.id}">
<div class="preset-info">
<span class="preset-name">${escapeHtml(preset.name)}</span>
<span class="preset-tags-preview">${preset.tags.map(t => '#' + escapeHtml(t)).join(' ')}</span>
</div>
<div class="preset-actions">
<button type="button" class="btn-icon preset-edit" data-preset-id="${preset.id}" title="Редактировать">✏️</button>
<button type="button" class="btn-icon preset-delete" data-preset-id="${preset.id}" title="Удалить">🗑️</button>
</div>
</div>
`).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 = '<b>'; after = '</b>'; break;
case 'italic': before = '<i>'; after = '</i>'; break;
case 'underline': before = '<u>'; after = '</u>'; break;
case 'strike': before = '<s>'; after = '</s>'; break;
case 'code': before = '<code>'; after = '</code>'; break;
case 'link':
const url = prompt('Введите URL:', 'https://');
if (url) {
before = '<a href="' + url + '">';
after = '</a>';
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 = '<p class="placeholder">Нет запланированных постов</p>';
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 = `
<div class="scheduled-photos-preview">
${previewPhotos.map(url => `<img src="${url}" alt="" class="scheduled-thumb" loading="lazy">`).join('')}
${moreCount > 0 ? `<span class="more-photos">+${moreCount}</span>` : ''}
</div>
`;
}
return `
<div class="scheduled-post-card" data-id="${post.id}">
<div class="scheduled-post-header">
<span class="scheduled-time">📅 ${dateStr}</span>
<span class="scheduled-platforms">${platforms}</span>
</div>
${photosPreviewHtml}
<div class="scheduled-post-content">
${post.text ? `<p class="scheduled-text">${escapeHtml(post.text.substring(0, 150))}${post.text.length > 150 ? '...' : ''}</p>` : ''}
${post.tags?.length ? `<span class="scheduled-tags">${post.tags.slice(0, 5).map(t => '#' + t).join(' ')}</span>` : ''}
</div>
<div class="scheduled-post-actions">
<button class="btn btn-small btn-secondary btn-edit-scheduled" data-id="${post.id}">✏️</button>
<button class="btn btn-small btn-danger btn-delete-scheduled" data-id="${post.id}">🗑️</button>
</div>
</div>
`;
}).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 = '<p class="placeholder">Нет запланированных постов</p>';
updateScheduledCount(0);
}
} catch (error) {
list.innerHTML = '<p class="placeholder error">Ошибка загрузки</p>';
}
}
// 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 = `
<div class="inline-editor">
<div class="inline-editor-photos">
${allPhotos.length > 0 ? allPhotos.map((url, i) => `
<div class="preview-thumb">
<img src="${url}" alt="">
<button class="remove-btn" data-index="${i}">×</button>
</div>
`).join('') : '<p class="placeholder">Нет фото</p>'}
</div>
<textarea class="inline-editor-text" rows="4" placeholder="Текст публикации...">${escapeHtml(post.text || '')}</textarea>
<div class="inline-editor-tags">
<div class="tags-list inline-tags-list">
${tags.map(t => `<span class="tag-chip">#${escapeHtml(t)} <button type="button" class="tag-remove" data-tag="${escapeHtml(t)}">×</button></span>`).join('')}
</div>
<input type="text" class="tags-input inline-tags-input" placeholder="Добавить тег...">
</div>
<div class="inline-editor-row">
<div class="inline-editor-field">
<label>Дата:</label>
<input type="date" class="schedule-input inline-date" value="${dateVal}">
</div>
<div class="inline-editor-field">
<label>Время:</label>
<input type="time" class="schedule-input inline-time" value="${timeVal}">
</div>
</div>
<div class="inline-editor-row">
<label class="checkbox-label compact"><input type="checkbox" class="inline-chk-tg" ${hasTg ? 'checked' : ''}> TG</label>
<label class="checkbox-label compact"><input type="checkbox" class="inline-chk-vk" ${hasVk ? 'checked' : ''}> VK</label>
</div>
<div class="inline-editor-actions">
<button class="btn btn-primary btn-small inline-save">💾 Сохранить</button>
<button class="btn btn-accent btn-small inline-publish-now">🚀 Опубликовать сейчас</button>
<button class="btn btn-secondary btn-small inline-cancel">Отмена</button>
</div>
</div>
`;
// 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) => `
<div class="preview-thumb">
<img src="${url}" alt="">
<button class="remove-btn" data-index="${i}">×</button>
</div>
`).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 = '<p class="placeholder">Нет фото</p>';
}
});
});
} else {
photosContainer.innerHTML = '<p class="placeholder">Нет фото</p>';
}
});
});
// 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)} <button type="button" class="tag-remove" data-tag="${escapeHtml(tag)}">×</button>`;
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<a href="${s.vkLink}">${s.textForTg || 'Мой канал ВКонтакте'}</a>`;
}
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 += `<span class="${cls}" title="${escapeHtml(tip)}">${platformName} ${icon}</span> `;
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 = `
<div class="archive-photos-preview">
${previewPhotos.map(url => `<img src="${url}" alt="" class="archive-thumb" loading="lazy">`).join('')}
${allPhotos.length > 3 ? `<span class="more-photos">+${allPhotos.length - 3}</span>` : ''}
</div>
`;
}
return `
<div class="archive-post-card">
<div class="archive-post-header">
<span class="archive-time">${dateStr}</span>
<span class="archive-results">${statusIcons}</span>
</div>
${photosPreviewHtml}
${post.text ? `<p class="archive-text">${escapeHtml(post.text.substring(0, 100))}${post.text.length > 100 ? '...' : ''}</p>` : ''}
${warningText ? `<p class="archive-warning">⚠ ${escapeHtml(warningText)}</p>` : ''}
</div>
`;
}).join('');
} else {
list.innerHTML = '<p class="placeholder">Нет опубликованных постов</p>';
}
} catch (error) {
list.innerHTML = '<p class="placeholder error">Ошибка загрузки</p>';
}
}
// 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) => `
<div class="preview-item uploaded-item">
<span class="file-name">${escapeHtml(file.name?.substring(0, 15) || 'file')}</span>
<button type="button" class="remove-uploaded" data-idx="${idx}">&times;</button>
</div>
`).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 = '<p class="placeholder">Сначала выберите фото в Галерее, затем вернитесь сюда</p>';
} else {
preview.innerHTML = state.selectedPhotos.map(photo => `
<div class="preview-item">
<img src="${photo.urls.small || photo.urls.thumbnail}" class="preview-thumb" alt="">
</div>
`).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 = '<p class="loading">Загрузка альбомов...</p>';
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 = '<p class="error">Ошибка загрузки альбомов</p>';
}
} catch (error) {
widgetAlbumsList.innerHTML = '<p class="error">Ошибка: ' + error.message + '</p>';
}
}
// Render widget albums selection
function renderWidgetAlbums(albums) {
if (!widgetAlbumsList) return;
if (albums.length === 0) {
widgetAlbumsList.innerHTML = '<p class="placeholder">Нет доступных альбомов</p>';
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 `
<label class="widget-album-item ${isSelected ? 'selected' : ''}" data-album-id="${album.id}">
<input type="checkbox" ${isSelected ? 'checked' : ''} value="${album.id}">
${thumb ? `<img src="${thumb}" alt="" class="widget-album-thumb">` : '<div class="widget-album-thumb" style="background:#eee;display:flex;align-items:center;justify-content:center;">📁</div>'}
<span class="widget-album-title">${escapeHtml(title)}</span>
<span class="widget-album-count">${count} фото</span>
</label>
`;
}).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 = '<p class="placeholder">Выберите фото из галереи или загрузите с устройства</p>';
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 = '<p class="placeholder">История пуста — сгенерированные бейджи будут появляться здесь.</p>';
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();
}
});