45a20d1edb
Adds four new controls to the digital-badge price tag so users can fine -tune the look: a vertical position slider (raises/lowers the band within the circle), a size slider (60-160% of the default band scale), and two color rows for the ribbon and the digits — each with six preset swatches plus a native color picker for custom colors. Per-item state (priceY, priceSize, priceBg, priceFg) is preserved across selections and renders consistently in the 240x240 export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5211 lines
205 KiB
JavaScript
5211 lines
205 KiB
JavaScript
/**
|
||
* VH Posting System - Frontend JavaScript
|
||
* Apple Style UI | Multi-platform posting
|
||
*/
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// ============ THEME MANAGEMENT ============
|
||
|
||
function initTheme() {
|
||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||
applyTheme(savedTheme);
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
if (theme === 'dark') {
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
document.getElementById('theme-toggle')?.classList.add('dark');
|
||
} else {
|
||
document.documentElement.removeAttribute('data-theme');
|
||
document.getElementById('theme-toggle')?.classList.remove('dark');
|
||
}
|
||
localStorage.setItem('theme', theme);
|
||
}
|
||
|
||
function toggleTheme() {
|
||
const currentTheme = localStorage.getItem('theme') || 'light';
|
||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||
applyTheme(newTheme);
|
||
}
|
||
|
||
document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
|
||
document.getElementById('btn-toggle-theme')?.addEventListener('click', toggleTheme);
|
||
initTheme();
|
||
|
||
// ============ CROSS-PROMO SETTINGS ============
|
||
|
||
const CROSS_PROMO_KEY = 'vh_cross_promo';
|
||
|
||
function getCrossPromoSettings() {
|
||
try {
|
||
const data = localStorage.getItem(CROSS_PROMO_KEY);
|
||
return data ? JSON.parse(data) : {
|
||
telegramLink: '',
|
||
vkLink: '',
|
||
textForTg: 'Мой канал ВКонтакте',
|
||
textForVk: 'Мой канал в Telegram'
|
||
};
|
||
} catch (e) {
|
||
return { telegramLink: '', vkLink: '', textForTg: 'Мой канал ВКонтакте', textForVk: 'Мой канал в Telegram' };
|
||
}
|
||
}
|
||
|
||
function saveCrossPromoSettings(settings) {
|
||
localStorage.setItem(CROSS_PROMO_KEY, JSON.stringify(settings));
|
||
}
|
||
|
||
async function initCrossPromoSettings() {
|
||
// Try to load from server first, fallback to localStorage
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('action', 'get_cross_promo');
|
||
const response = await fetch('api.php', { method: 'POST', body: formData });
|
||
const data = await response.json();
|
||
if (data.settings) {
|
||
saveCrossPromoSettings(data.settings);
|
||
}
|
||
} catch (e) {
|
||
console.log('Using local cross-promo settings');
|
||
}
|
||
|
||
const settings = getCrossPromoSettings();
|
||
const tgInput = document.getElementById('cross-promo-telegram');
|
||
const vkInput = document.getElementById('cross-promo-vk');
|
||
const textTgInput = document.getElementById('cross-promo-text-tg');
|
||
const textVkInput = document.getElementById('cross-promo-text-vk');
|
||
|
||
if (tgInput) tgInput.value = settings.telegramLink || '';
|
||
if (vkInput) vkInput.value = settings.vkLink || '';
|
||
if (textTgInput) textTgInput.value = settings.textForTg || 'Мой канал ВКонтакте';
|
||
if (textVkInput) textVkInput.value = settings.textForVk || 'Мой канал в Telegram';
|
||
}
|
||
|
||
document.getElementById('btn-save-cross-promo')?.addEventListener('click', async () => {
|
||
const settings = {
|
||
telegramLink: document.getElementById('cross-promo-telegram')?.value.trim() || '',
|
||
vkLink: document.getElementById('cross-promo-vk')?.value.trim() || '',
|
||
textForTg: document.getElementById('cross-promo-text-tg')?.value.trim() || 'Мой канал ВКонтакте',
|
||
textForVk: document.getElementById('cross-promo-text-vk')?.value.trim() || 'Мой канал в Telegram'
|
||
};
|
||
|
||
// Save to localStorage
|
||
saveCrossPromoSettings(settings);
|
||
|
||
// Save to server for cron
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('action', 'save_cross_promo');
|
||
formData.append('telegramLink', settings.telegramLink);
|
||
formData.append('vkLink', settings.vkLink);
|
||
formData.append('textForTg', settings.textForTg);
|
||
formData.append('textForVk', settings.textForVk);
|
||
await fetch('api.php', { method: 'POST', body: formData });
|
||
} catch (e) {
|
||
console.warn('Failed to save cross-promo to server:', e);
|
||
}
|
||
|
||
const status = document.getElementById('cross-promo-save-status');
|
||
if (status) {
|
||
status.textContent = '✓ Сохранено';
|
||
status.className = 'save-status success';
|
||
setTimeout(() => { status.textContent = ''; }, 2000);
|
||
}
|
||
showNotification('Настройки кросс-промо сохранены', 'success');
|
||
});
|
||
|
||
// Initialize cross-promo on load
|
||
initCrossPromoSettings();
|
||
|
||
// ============ AUTO-SAVE POST DRAFT (SERVER-BASED) ============
|
||
|
||
let serverDraft = null;
|
||
let draftSaveTimer = null;
|
||
|
||
// Save draft to server (debounced - for text input)
|
||
function savePostDraft() {
|
||
clearTimeout(draftSaveTimer);
|
||
draftSaveTimer = setTimeout(savePostDraftToServer, 1500);
|
||
}
|
||
|
||
// Save draft immediately (for tags, photos)
|
||
function savePostDraftNow() {
|
||
clearTimeout(draftSaveTimer);
|
||
savePostDraftToServer();
|
||
}
|
||
|
||
// Actually save to server
|
||
async function savePostDraftToServer() {
|
||
const postText = document.getElementById('post-text')?.value || '';
|
||
const tags = typeof tagContexts !== 'undefined' ? tagContexts.post : [];
|
||
const photos = state?.selectedPhotos?.map(p => ({
|
||
id: p.id,
|
||
title: p.title,
|
||
urls: p.urls,
|
||
page_url: p.page_url,
|
||
is_video: p.is_video
|
||
})) || [];
|
||
const uploadedFiles = state?.uploadedFiles?.filter(f => !f.uploading && f.url).map(f => ({
|
||
id: f.id,
|
||
name: f.name,
|
||
type: f.type,
|
||
url: f.url
|
||
})) || [];
|
||
|
||
const formData = new FormData();
|
||
formData.append('action', 'save_draft');
|
||
formData.append('text', postText);
|
||
formData.append('tags', JSON.stringify(tags));
|
||
formData.append('photos', JSON.stringify(photos));
|
||
formData.append('uploaded_files', JSON.stringify(uploadedFiles));
|
||
|
||
try {
|
||
await fetch('api.php', { method: 'POST', body: formData });
|
||
} catch (e) {
|
||
console.warn('Could not save draft to server:', e);
|
||
}
|
||
}
|
||
|
||
// Load draft from server
|
||
async function loadPostDraftFromServer() {
|
||
try {
|
||
const response = await fetch('api.php?action=get_draft');
|
||
const data = await response.json();
|
||
if (data.success && data.draft) {
|
||
return data.draft;
|
||
}
|
||
} catch (e) {
|
||
console.error('Error loading draft from server:', e);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Clear draft on server
|
||
async function clearPostDraftOnServer() {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('action', 'clear_draft');
|
||
await fetch('api.php', { method: 'POST', body: formData });
|
||
} catch (e) {
|
||
console.warn('Could not clear draft on server:', e);
|
||
}
|
||
}
|
||
|
||
function clearPostDraft() {
|
||
clearPostDraftOnServer();
|
||
}
|
||
|
||
// Save photos and uploaded files immediately
|
||
function saveSelectedPhotos() {
|
||
savePostDraftNow();
|
||
}
|
||
|
||
function saveUploadedFiles() {
|
||
savePostDraftNow();
|
||
}
|
||
|
||
// Auto-save on text input (debounced)
|
||
document.getElementById('post-text')?.addEventListener('input', () => {
|
||
savePostDraft();
|
||
});
|
||
|
||
// ============ STATE ============
|
||
|
||
const MAX_PHOTOS = 9;
|
||
|
||
function getTotalPhotosCount() {
|
||
return state.selectedPhotos.length + state.uploadedFiles.length;
|
||
}
|
||
|
||
function canAddPhotos(count = 1) {
|
||
if (getTotalPhotosCount() + count > MAX_PHOTOS) {
|
||
showNotification(`Максимум ${MAX_PHOTOS} фото/видео`, 'error');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
const state = {
|
||
selectedPhotos: [],
|
||
uploadedFiles: [],
|
||
currentPage: 1,
|
||
totalPages: 1,
|
||
currentAlbum: '',
|
||
isLoadingPhotos: false,
|
||
isLoadingAlbums: false,
|
||
photoRequestId: 0, // For request deduplication
|
||
albumRequestId: 0,
|
||
// Infinite scroll state for albums
|
||
albumsPage: 1,
|
||
albumsTotalPages: 1,
|
||
albumsTotal: 0,
|
||
isLoadingMoreAlbums: false,
|
||
allAlbums: [],
|
||
// Infinite scroll state for photos
|
||
isLoadingMorePhotos: false,
|
||
allPhotos: []
|
||
};
|
||
|
||
// Load draft from server on page load
|
||
let pendingDraft = null;
|
||
loadPostDraftFromServer().then(draft => {
|
||
if (!draft) return;
|
||
serverDraft = draft;
|
||
pendingDraft = draft;
|
||
|
||
// Restore text
|
||
const postText = document.getElementById('post-text');
|
||
if (postText && draft.text) {
|
||
postText.value = draft.text;
|
||
}
|
||
|
||
// Restore photos
|
||
if (draft.photos && draft.photos.length > 0) {
|
||
state.selectedPhotos = draft.photos;
|
||
}
|
||
|
||
// Restore uploaded files (without dataUrl, just URL)
|
||
if (draft.uploaded_files && draft.uploaded_files.length > 0) {
|
||
state.uploadedFiles = draft.uploaded_files.map(f => ({
|
||
...f,
|
||
dataUrl: f.url, // Use URL as dataUrl for preview
|
||
uploading: false
|
||
}));
|
||
}
|
||
|
||
// Update preview
|
||
updatePostingPreview();
|
||
|
||
// Try to restore tags (will work if tagContexts is already initialized)
|
||
restoreDraftTags();
|
||
});
|
||
|
||
// Function to restore tags after tagContexts is available
|
||
let tagsRestored = false;
|
||
function restoreDraftTags() {
|
||
if (tagsRestored) return;
|
||
if (!pendingDraft || !pendingDraft.tags || pendingDraft.tags.length === 0) return;
|
||
if (typeof tagContexts === 'undefined') return;
|
||
|
||
tagsRestored = true;
|
||
tagContexts.post = pendingDraft.tags;
|
||
const tagsList = document.getElementById('post-tags-list');
|
||
if (tagsList) {
|
||
tagsList.innerHTML = pendingDraft.tags.map(tag => `
|
||
<span class="tag-chip">
|
||
#${escapeHtml(tag)}
|
||
<button type="button" class="tag-remove" data-tag="${escapeHtml(tag)}">×</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
|
||
if (getTotalPhotosCount() >= MAX_PHOTOS) {
|
||
showNotification(`Максимум ${MAX_PHOTOS} фото/видео`, 'error');
|
||
break;
|
||
}
|
||
|
||
// Check file size (max 50MB)
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
showNotification(`Файл ${file.name} слишком большой (макс 50MB)`, 'error');
|
||
continue;
|
||
}
|
||
|
||
// Create preview immediately
|
||
const reader = new FileReader();
|
||
reader.onload = async (event) => {
|
||
const fileId = 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
const fileData = {
|
||
id: fileId,
|
||
name: file.name,
|
||
type: file.type,
|
||
size: file.size,
|
||
dataUrl: event.target.result,
|
||
uploading: true,
|
||
url: null
|
||
};
|
||
state.uploadedFiles.push(fileData);
|
||
renderUploadedFiles();
|
||
|
||
// Upload to server
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('action', 'upload_file');
|
||
formData.append('file', file);
|
||
|
||
const response = await fetch('api.php', { method: 'POST', body: formData });
|
||
const data = await response.json();
|
||
|
||
// Find and update the file in state
|
||
const idx = state.uploadedFiles.findIndex(f => f.id === fileId);
|
||
if (idx !== -1) {
|
||
if (data.error) {
|
||
showNotification(`Ошибка загрузки ${file.name}: ${data.error}`, 'error');
|
||
state.uploadedFiles.splice(idx, 1);
|
||
} else {
|
||
state.uploadedFiles[idx].url = data.url;
|
||
state.uploadedFiles[idx].uploading = false;
|
||
saveUploadedFiles(); // Save to localStorage after successful upload
|
||
}
|
||
renderUploadedFiles();
|
||
}
|
||
} catch (error) {
|
||
showNotification(`Ошибка загрузки ${file.name}`, 'error');
|
||
const idx = state.uploadedFiles.findIndex(f => f.id === fileId);
|
||
if (idx !== -1) {
|
||
state.uploadedFiles.splice(idx, 1);
|
||
renderUploadedFiles();
|
||
}
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
// Clear input for re-upload of same files
|
||
e.target.value = '';
|
||
});
|
||
|
||
function renderUploadedFiles() {
|
||
// Now uses the combined preview
|
||
updatePostingPreview();
|
||
}
|
||
|
||
// Drag and drop support for combined preview
|
||
const combinedPreview = document.getElementById('post-photos-preview');
|
||
if (combinedPreview) {
|
||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||
combinedPreview.addEventListener(eventName, (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
});
|
||
});
|
||
|
||
['dragenter', 'dragover'].forEach(eventName => {
|
||
combinedPreview.addEventListener(eventName, () => {
|
||
combinedPreview.classList.add('drag-over');
|
||
});
|
||
});
|
||
|
||
['dragleave', 'drop'].forEach(eventName => {
|
||
combinedPreview.addEventListener(eventName, () => {
|
||
combinedPreview.classList.remove('drag-over');
|
||
});
|
||
});
|
||
|
||
combinedPreview.addEventListener('drop', (e) => {
|
||
const files = e.dataTransfer.files;
|
||
if (files.length) {
|
||
const fileInput = document.getElementById('file-upload');
|
||
if (fileInput) {
|
||
fileInput.files = files;
|
||
fileInput.dispatchEvent(new Event('change'));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// AbortController for cancelling in-flight requests
|
||
let photoAbortController = null;
|
||
let albumAbortController = null;
|
||
|
||
// ============ ALBUM PREFERENCES (localStorage) ============
|
||
|
||
const ALBUM_CACHE_KEY = 'vh_album_cache';
|
||
const ALBUM_PREFS_KEY = 'vh_album_prefs';
|
||
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||
|
||
function getAlbumCache() {
|
||
try {
|
||
const cached = localStorage.getItem(ALBUM_CACHE_KEY);
|
||
if (!cached) return null;
|
||
const data = JSON.parse(cached);
|
||
if (Date.now() - data.timestamp > CACHE_TTL) {
|
||
localStorage.removeItem(ALBUM_CACHE_KEY);
|
||
return null;
|
||
}
|
||
// Handle both old format (array) and new format (object with albums property)
|
||
const albums = data.albums;
|
||
if (Array.isArray(albums)) {
|
||
return albums;
|
||
} else if (albums && Array.isArray(albums.albums)) {
|
||
return albums.albums;
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function setAlbumCache(albumsOrData) {
|
||
try {
|
||
// Accept either array or object with albums property
|
||
const albums = Array.isArray(albumsOrData) ? albumsOrData : (albumsOrData.albums || []);
|
||
localStorage.setItem(ALBUM_CACHE_KEY, JSON.stringify({
|
||
albums: albums,
|
||
timestamp: Date.now()
|
||
}));
|
||
} catch (e) {
|
||
console.warn('Failed to cache albums:', e);
|
||
}
|
||
}
|
||
|
||
function getAlbumPrefs() {
|
||
try {
|
||
const prefs = localStorage.getItem(ALBUM_PREFS_KEY);
|
||
return prefs ? JSON.parse(prefs) : { favorites: [], order: [] };
|
||
} catch (e) {
|
||
return { favorites: [], order: [] };
|
||
}
|
||
}
|
||
|
||
function saveAlbumPrefs(prefs) {
|
||
try {
|
||
localStorage.setItem(ALBUM_PREFS_KEY, JSON.stringify(prefs));
|
||
} catch (e) {
|
||
console.warn('Failed to save album prefs:', e);
|
||
}
|
||
}
|
||
|
||
function toggleAlbumFavorite(albumId) {
|
||
const prefs = getAlbumPrefs();
|
||
const index = prefs.favorites.indexOf(albumId);
|
||
if (index === -1) {
|
||
prefs.favorites.push(albumId);
|
||
} else {
|
||
prefs.favorites.splice(index, 1);
|
||
}
|
||
saveAlbumPrefs(prefs);
|
||
renderAlbumDropdown(window._cachedAlbums || []);
|
||
}
|
||
|
||
function sortAlbumsByPreference(albums) {
|
||
const prefs = getAlbumPrefs();
|
||
const favorites = new Set(prefs.favorites);
|
||
|
||
// Sort: favorites first, then rest
|
||
return [...albums].sort((a, b) => {
|
||
const aFav = favorites.has(a.id);
|
||
const bFav = favorites.has(b.id);
|
||
if (aFav && !bFav) return -1;
|
||
if (!aFav && bFav) return 1;
|
||
return 0; // Keep original order within groups
|
||
});
|
||
}
|
||
|
||
// ============ DOM ELEMENTS ============
|
||
|
||
const selectionBar = document.getElementById('selection-bar');
|
||
const selectedCountEl = document.getElementById('selected-count');
|
||
const photoGallery = document.getElementById('photo-gallery');
|
||
const postPhotosPreview = document.getElementById('post-photos-preview');
|
||
|
||
// ============ UTILITY FUNCTIONS ============
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text || '';
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function showNotification(message, type = 'info') {
|
||
const notification = document.createElement('div');
|
||
notification.textContent = message;
|
||
notification.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
padding: 16px 24px;
|
||
border-radius: 12px;
|
||
font-weight: 500;
|
||
z-index: 10000;
|
||
animation: slideIn 0.3s ease;
|
||
backdrop-filter: blur(20px);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||
color: white;
|
||
background: ${type === 'success' ? 'rgba(52, 199, 89, 0.9)' :
|
||
type === 'error' ? 'rgba(255, 59, 48, 0.9)' :
|
||
'rgba(0, 122, 255, 0.9)'};
|
||
`;
|
||
document.body.appendChild(notification);
|
||
setTimeout(() => {
|
||
notification.style.animation = 'slideOut 0.3s ease';
|
||
setTimeout(() => notification.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// Add animation styles
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
// ============ SELECTION MANAGEMENT ============
|
||
|
||
function updateSelectionUI() {
|
||
const count = state.selectedPhotos.length;
|
||
const total = getTotalPhotosCount();
|
||
|
||
// Update counter with limit info (gallery floating bar)
|
||
if (selectedCountEl) {
|
||
selectedCountEl.textContent = `${total}/${MAX_PHOTOS}`;
|
||
}
|
||
|
||
// Update counter on posting page
|
||
const photoCounter = document.getElementById('photo-counter');
|
||
if (photoCounter) {
|
||
photoCounter.textContent = `${total}/${MAX_PHOTOS}`;
|
||
photoCounter.classList.toggle('at-limit', total >= MAX_PHOTOS);
|
||
}
|
||
|
||
// Show/hide floating action bar
|
||
if (selectionBar) {
|
||
if (count > 0) {
|
||
selectionBar.classList.remove('hidden');
|
||
} else {
|
||
selectionBar.classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
|
||
function updatePostingPreview() {
|
||
// Always update photo counter
|
||
const total = getTotalPhotosCount();
|
||
const photoCounter = document.getElementById('photo-counter');
|
||
if (photoCounter) {
|
||
photoCounter.textContent = `${total}/${MAX_PHOTOS}`;
|
||
photoCounter.classList.toggle('at-limit', total >= MAX_PHOTOS);
|
||
}
|
||
|
||
if (!postPhotosPreview) return;
|
||
|
||
const hasFlickrPhotos = state.selectedPhotos.length > 0;
|
||
const hasUploadedFiles = state.uploadedFiles.length > 0;
|
||
|
||
if (!hasFlickrPhotos && !hasUploadedFiles) {
|
||
postPhotosPreview.innerHTML = '<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
|
||
async function refreshAlbumsSilently() {
|
||
if (state.isLoadingAlbums) return;
|
||
|
||
try {
|
||
const response = await fetch('api.php?action=flickr_albums');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
if (data.albums && data.albums.length > 0) {
|
||
setAlbumCache(data.albums);
|
||
window._cachedAlbums = data.albums;
|
||
// Only re-render if on albums view (photos view is hidden)
|
||
if (photosView?.classList.contains('hidden')) {
|
||
renderAlbumsGrid(data.albums);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log('Silent refresh failed:', error.message);
|
||
}
|
||
}
|
||
|
||
// ============ LOAD ALBUMS (with infinite scroll) ============
|
||
|
||
async function loadAlbums(forceRefresh = false) {
|
||
console.log('loadAlbums called, forceRefresh:', forceRefresh);
|
||
|
||
// Check if albumsGrid exists
|
||
if (!albumsGrid) {
|
||
console.error('albumsGrid element not found!');
|
||
showNotification('Ошибка: элемент галереи не найден', 'error');
|
||
return;
|
||
}
|
||
|
||
if (state.isLoadingAlbums) {
|
||
console.log('Albums already loading, skipping...');
|
||
showNotification('Альбомы уже загружаются...', 'info');
|
||
return;
|
||
}
|
||
|
||
// Reset pagination state
|
||
state.albumsPage = 1;
|
||
state.albumsTotalPages = 1;
|
||
state.allAlbums = [];
|
||
|
||
// Check cache first
|
||
if (!forceRefresh) {
|
||
const cached = getAlbumCache();
|
||
if (cached && cached.albums) {
|
||
console.log('Using cached albums:', cached.albums.length);
|
||
state.allAlbums = cached.albums;
|
||
state.albumsPage = cached.page || 1;
|
||
state.albumsTotalPages = cached.pages || 1;
|
||
state.albumsTotal = cached.total || cached.albums.length;
|
||
window._cachedAlbums = cached.albums;
|
||
renderAlbumsGrid(cached.albums);
|
||
showNotification(`Загружено ${cached.albums.length} альбомов (из кеша)`, 'success');
|
||
setupAlbumsInfiniteScroll();
|
||
return;
|
||
} else if (cached && Array.isArray(cached)) {
|
||
// Old cache format compatibility
|
||
console.log('Using old cached albums:', cached.length);
|
||
state.allAlbums = cached;
|
||
window._cachedAlbums = cached;
|
||
renderAlbumsGrid(cached);
|
||
showNotification(`Загружено ${cached.length} альбомов (из кеша)`, 'success');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Cancel previous request
|
||
if (albumAbortController) {
|
||
albumAbortController.abort();
|
||
}
|
||
albumAbortController = new AbortController();
|
||
|
||
state.isLoadingAlbums = true;
|
||
state.albumRequestId++;
|
||
const thisRequestId = state.albumRequestId;
|
||
|
||
// Show loading state
|
||
albumsGrid.innerHTML = `
|
||
<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;
|
||
}
|
||
}
|
||
|
||
// Append albums to existing grid
|
||
function appendAlbumsToGrid(albums) {
|
||
albums.forEach(album => {
|
||
const card = createAlbumCard(album);
|
||
albumsGrid.appendChild(card);
|
||
});
|
||
|
||
if (dragHint && state.allAlbums.length > 1) {
|
||
dragHint.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
// Setup infinite scroll observer for albums
|
||
let albumsScrollObserver = null;
|
||
|
||
function setupAlbumsInfiniteScroll() {
|
||
// Remove existing observer
|
||
if (albumsScrollObserver) {
|
||
albumsScrollObserver.disconnect();
|
||
}
|
||
|
||
// Create sentinel element
|
||
let sentinel = document.getElementById('albums-scroll-sentinel');
|
||
if (!sentinel) {
|
||
sentinel = document.createElement('div');
|
||
sentinel.id = 'albums-scroll-sentinel';
|
||
sentinel.style.cssText = 'height: 20px; grid-column: 1/-1;';
|
||
}
|
||
|
||
// Append sentinel after grid
|
||
if (albumsGrid && albumsGrid.parentNode) {
|
||
// Insert after albumsGrid
|
||
albumsGrid.parentNode.insertBefore(sentinel, albumsGrid.nextSibling);
|
||
}
|
||
|
||
// Create intersection observer
|
||
albumsScrollObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting && !state.isLoadingMoreAlbums && state.albumsPage < state.albumsTotalPages) {
|
||
console.log('Albums sentinel visible, loading more...');
|
||
loadMoreAlbums();
|
||
}
|
||
});
|
||
}, {
|
||
root: null,
|
||
rootMargin: '200px',
|
||
threshold: 0
|
||
});
|
||
|
||
albumsScrollObserver.observe(sentinel);
|
||
}
|
||
|
||
// ============ LOAD PHOTOS (with infinite scroll) ============
|
||
|
||
async function loadPhotos() {
|
||
if (!photoGallery) return;
|
||
|
||
if (photoAbortController) {
|
||
photoAbortController.abort();
|
||
}
|
||
photoAbortController = new AbortController();
|
||
|
||
// Reset state for new load
|
||
state.currentPage = 1;
|
||
state.totalPages = 1;
|
||
state.allPhotos = [];
|
||
state.isLoadingPhotos = true;
|
||
state.photoRequestId++;
|
||
const thisRequestId = state.photoRequestId;
|
||
|
||
photoGallery.innerHTML = `
|
||
<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', () => {
|
||
document.querySelectorAll('.photo-item').forEach(item => {
|
||
if (getTotalPhotosCount() >= MAX_PHOTOS) return;
|
||
const photo = JSON.parse(item.dataset.photoData);
|
||
if (!state.selectedPhotos.find(p => p.id === photo.id)) {
|
||
state.selectedPhotos.push(photo);
|
||
item.classList.add('selected');
|
||
}
|
||
});
|
||
if (getTotalPhotosCount() >= MAX_PHOTOS) {
|
||
showNotification(`Выбрано максимум ${MAX_PHOTOS} фото`, 'info');
|
||
}
|
||
updateSelectionUI();
|
||
saveSelectedPhotos();
|
||
});
|
||
|
||
document.getElementById('btn-deselect-all')?.addEventListener('click', () => {
|
||
state.selectedPhotos = [];
|
||
document.querySelectorAll('.photo-item').forEach(item => item.classList.remove('selected'));
|
||
updateSelectionUI();
|
||
saveSelectedPhotos();
|
||
});
|
||
|
||
document.getElementById('btn-convert-selected')?.addEventListener('click', () => {
|
||
if (state.selectedPhotos.length === 0) {
|
||
showNotification('Сначала выберите фотографии', 'error');
|
||
return;
|
||
}
|
||
const urls = state.selectedPhotos.map(p => p.page_url).join('\n');
|
||
if (inputUrls) inputUrls.value = urls;
|
||
document.querySelector('.nav-btn[data-tab="converter"]')?.click();
|
||
});
|
||
|
||
document.getElementById('btn-telegram-selected')?.addEventListener('click', () => {
|
||
if (state.selectedPhotos.length === 0) {
|
||
showNotification('Сначала выберите фотографии', 'error');
|
||
return;
|
||
}
|
||
document.querySelector('.nav-btn[data-tab="posting"]')?.click();
|
||
});
|
||
|
||
// Get best quality URL for photo (prefer original, but only use actual URLs)
|
||
function getBestPhotoUrl(photo) {
|
||
// Priority: original > large2048 > large > medium640 > medium
|
||
// Only return URLs that actually exist (not null/undefined)
|
||
const urls = photo.urls || {};
|
||
return urls.original || urls.large2048 || urls.large || urls.medium640 || urls.medium;
|
||
}
|
||
|
||
// Get preview URL (medium size for lightbox)
|
||
function getPreviewUrl(photo) {
|
||
return photo.urls.large || photo.urls.medium640 || photo.urls.medium || photo.urls.original;
|
||
}
|
||
|
||
// Get file extension from photo
|
||
function getPhotoExtension(photo) {
|
||
return photo.original_format || 'jpg';
|
||
}
|
||
|
||
// ============ SERVER-SIDE DOWNLOAD (bypasses CORS) ============
|
||
|
||
// Download single photo via server proxy
|
||
function downloadSinglePhoto(photo) {
|
||
const url = getBestPhotoUrl(photo);
|
||
const ext = getPhotoExtension(photo);
|
||
const title = (photo.title || photo.id).replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
|
||
|
||
// Create download URL through our server proxy
|
||
const downloadUrl = `download.php?action=photo&url=${encodeURIComponent(url)}&filename=${encodeURIComponent(title)}&format=${ext}`;
|
||
|
||
// Trigger download via hidden iframe or link
|
||
const link = document.createElement('a');
|
||
link.href = downloadUrl;
|
||
link.download = `${title}.${ext}`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
}
|
||
|
||
// Download multiple photos as individual files (sequential)
|
||
async function downloadPhotosIndividually(photos) {
|
||
showNotification(`Скачивание ${photos.length} фото...`, 'info');
|
||
|
||
for (let i = 0; i < photos.length; i++) {
|
||
downloadSinglePhoto(photos[i]);
|
||
// Small delay between downloads to avoid overwhelming browser
|
||
if (i < photos.length - 1) {
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
}
|
||
}
|
||
|
||
showNotification(`✓ Загрузка ${photos.length} фото начата`, 'success');
|
||
}
|
||
|
||
// Download multiple photos as ZIP archive
|
||
function downloadPhotosAsZip(photos, albumName = '') {
|
||
showNotification(`Подготовка архива: ${photos.length} фото...`, 'info');
|
||
|
||
// Prepare photo data for server
|
||
const photoData = photos.map(photo => ({
|
||
id: photo.id,
|
||
url: getBestPhotoUrl(photo),
|
||
title: photo.title || photo.id,
|
||
format: getPhotoExtension(photo)
|
||
}));
|
||
|
||
try {
|
||
// Create form for POST request
|
||
const form = document.createElement('form');
|
||
form.method = 'POST';
|
||
form.action = 'download.php?action=zip';
|
||
form.style.display = 'none';
|
||
|
||
const photosInput = document.createElement('input');
|
||
photosInput.type = 'hidden';
|
||
photosInput.name = 'photos';
|
||
photosInput.value = JSON.stringify(photoData);
|
||
form.appendChild(photosInput);
|
||
|
||
const albumInput = document.createElement('input');
|
||
albumInput.type = 'hidden';
|
||
albumInput.name = 'album_name';
|
||
albumInput.value = albumName;
|
||
form.appendChild(albumInput);
|
||
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
document.body.removeChild(form);
|
||
|
||
showNotification('✓ Создание архива на сервере...', 'success');
|
||
} catch (error) {
|
||
console.error('Failed to create ZIP:', error);
|
||
showNotification('Ошибка создания архива', 'error');
|
||
}
|
||
}
|
||
|
||
// Show download format choice dialog
|
||
function showDownloadChoiceDialog(photos, albumName = '') {
|
||
// Remove existing dialog if any
|
||
const existing = document.getElementById('download-choice-dialog');
|
||
if (existing) existing.remove();
|
||
|
||
const dialog = document.createElement('div');
|
||
dialog.id = 'download-choice-dialog';
|
||
dialog.className = 'download-dialog-overlay';
|
||
dialog.innerHTML = `
|
||
<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 }));
|
||
|
||
// 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) {
|
||
const formData = new FormData();
|
||
formData.append('action', 'multi_post');
|
||
formData.append('platforms', JSON.stringify([platform]));
|
||
formData.append('text', platform.type === 'telegram' ? textForTelegram : textForVk);
|
||
formData.append('photos', JSON.stringify(photoUrls));
|
||
formData.append('uploaded_files', JSON.stringify(uploadedFileUrls));
|
||
formData.append('parse_mode', parseMode);
|
||
|
||
const response = await fetch('api.php', { method: 'POST', body: formData });
|
||
const data = await response.json();
|
||
|
||
if (data.results) {
|
||
Object.assign(results, data.results);
|
||
} else if (data.error) {
|
||
results[platform.type] = { success: false, error: data.error };
|
||
}
|
||
}
|
||
|
||
// Process results
|
||
let resultText = 'Результаты:\n';
|
||
let hasErrors = false;
|
||
|
||
Object.entries(results).forEach(([platform, result]) => {
|
||
const name = platform === 'telegram' ? 'Telegram' : platform === 'vk' ? 'ВКонтакте' : platform;
|
||
if (result.success) {
|
||
if (result.warning) {
|
||
resultText += `⚠ ${name}: Опубликовано с предупреждением\n ${result.warning}\n`;
|
||
} else {
|
||
resultText += `✓ ${name}: Успешно\n`;
|
||
}
|
||
} else {
|
||
resultText += `✗ ${name}: ${result.error}\n`;
|
||
hasErrors = true;
|
||
}
|
||
});
|
||
|
||
if (postResult) {
|
||
postResult.className = hasErrors ? 'result-message error' : 'result-message success';
|
||
postResult.textContent = resultText;
|
||
postResult.style.display = 'block';
|
||
}
|
||
|
||
if (!hasErrors) {
|
||
state.selectedPhotos = [];
|
||
state.uploadedFiles = []; // Clear uploaded files
|
||
updatePostingPreview();
|
||
updateSelectionUI();
|
||
syncGallerySelection();
|
||
if (postText) postText.value = '';
|
||
// Clear tags
|
||
if (typeof tagContexts !== 'undefined') {
|
||
tagContexts.post = [];
|
||
const tagsList = document.getElementById('post-tags-list');
|
||
if (tagsList) tagsList.innerHTML = '';
|
||
}
|
||
// Clear draft
|
||
clearPostDraft();
|
||
showNotification('Публикация завершена!', 'success');
|
||
}
|
||
} catch (error) {
|
||
if (postResult) {
|
||
postResult.className = 'result-message error';
|
||
postResult.textContent = 'Ошибка: ' + error.message;
|
||
postResult.style.display = 'block';
|
||
}
|
||
} finally {
|
||
if (btnSendPost) {
|
||
btnSendPost.disabled = false;
|
||
btnSendPost.textContent = 'Опубликовать';
|
||
}
|
||
}
|
||
});
|
||
|
||
// ============ SETTINGS ============
|
||
|
||
document.getElementById('btn-change-password')?.addEventListener('click', async () => {
|
||
const currentPassword = document.getElementById('current-password')?.value;
|
||
const newPassword = document.getElementById('new-password')?.value;
|
||
const confirmPassword = document.getElementById('confirm-password')?.value;
|
||
|
||
if (!currentPassword || !newPassword) {
|
||
showNotification('Заполните все поля', 'error');
|
||
return;
|
||
}
|
||
if (newPassword !== confirmPassword) {
|
||
showNotification('Пароли не совпадают', 'error');
|
||
return;
|
||
}
|
||
if (newPassword.length < 8) {
|
||
showNotification('Минимум 8 символов', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('action', 'change_password');
|
||
formData.append('current_password', currentPassword);
|
||
formData.append('new_password', newPassword);
|
||
|
||
const response = await fetch('api.php', { method: 'POST', body: formData });
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
showNotification('Ошибка: ' + data.error, 'error');
|
||
} else {
|
||
showNotification('Пароль изменён', 'success');
|
||
document.getElementById('current-password').value = '';
|
||
document.getElementById('new-password').value = '';
|
||
document.getElementById('confirm-password').value = '';
|
||
}
|
||
} catch (error) {
|
||
showNotification('Ошибка: ' + error.message, 'error');
|
||
}
|
||
});
|
||
|
||
// ============ VK TOKEN MANAGEMENT ============
|
||
|
||
// Toggle VK token visibility
|
||
document.getElementById('btn-toggle-vk-token')?.addEventListener('click', () => {
|
||
const input = document.getElementById('vk-token-input');
|
||
if (input) {
|
||
input.type = input.type === 'password' ? 'text' : 'password';
|
||
}
|
||
});
|
||
|
||
// Save VK token
|
||
document.getElementById('btn-save-vk-token')?.addEventListener('click', async () => {
|
||
const tokenInput = document.getElementById('vk-token-input');
|
||
const token = tokenInput?.value.trim();
|
||
const statusSpan = document.getElementById('vk-token-save-status');
|
||
|
||
if (!token) {
|
||
showNotification('Введите токен', 'error');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('btn-save-vk-token');
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = 'Сохранение...';
|
||
}
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('action', 'save_vk_token');
|
||
formData.append('token', token);
|
||
|
||
const response = await fetch('api.php', { method: 'POST', body: formData });
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
showNotification('Ошибка: ' + data.error, 'error');
|
||
if (statusSpan) {
|
||
statusSpan.textContent = '✗ Ошибка';
|
||
statusSpan.className = 'save-status error';
|
||
}
|
||
} else {
|
||
let message = 'Токен сохранён';
|
||
if (data.validation?.valid) {
|
||
const type = data.validation.type === 'user' ? 'пользовательский' : 'community';
|
||
const name = data.validation.user_name || '';
|
||
message += ` (${type}${name ? ': ' + name : ''})`;
|
||
|
||
// Update VK status display
|
||
const vkStatus = document.getElementById('vk-status');
|
||
if (vkStatus) {
|
||
vkStatus.textContent = `Подключён (${type})`;
|
||
vkStatus.className = 'status connected';
|
||
}
|
||
}
|
||
showNotification(message, 'success');
|
||
if (statusSpan) {
|
||
statusSpan.textContent = '✓ Сохранено';
|
||
statusSpan.className = 'save-status success';
|
||
}
|
||
|
||
// Reload VK groups
|
||
loadVkGroups();
|
||
}
|
||
} catch (error) {
|
||
showNotification('Ошибка: ' + error.message, 'error');
|
||
if (statusSpan) {
|
||
statusSpan.textContent = '✗ Ошибка';
|
||
statusSpan.className = 'save-status error';
|
||
}
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Сохранить';
|
||
}
|
||
}
|
||
});
|
||
|
||
// ============ TAGS MANAGEMENT SYSTEM ============
|
||
|
||
const TAGS_STORAGE_KEY = 'vh_tags_history';
|
||
const MAX_TAGS_HISTORY = 100;
|
||
|
||
// Store for current tags in each context
|
||
const tagContexts = {
|
||
post: [],
|
||
converter: []
|
||
};
|
||
|
||
// Load tags history from localStorage
|
||
function getTagsHistory() {
|
||
try {
|
||
const data = localStorage.getItem(TAGS_STORAGE_KEY);
|
||
return data ? JSON.parse(data) : [];
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Save tag to history
|
||
function saveTagToHistory(tag) {
|
||
const history = getTagsHistory();
|
||
const existingIndex = history.findIndex(t => t.name.toLowerCase() === tag.toLowerCase());
|
||
|
||
if (existingIndex >= 0) {
|
||
// Increment usage count
|
||
history[existingIndex].count++;
|
||
history[existingIndex].lastUsed = Date.now();
|
||
} else {
|
||
// Add new tag
|
||
history.push({
|
||
name: tag,
|
||
count: 1,
|
||
lastUsed: Date.now()
|
||
});
|
||
}
|
||
|
||
// Sort by count and limit
|
||
history.sort((a, b) => b.count - a.count);
|
||
if (history.length > MAX_TAGS_HISTORY) {
|
||
history.splice(MAX_TAGS_HISTORY);
|
||
}
|
||
|
||
localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(history));
|
||
}
|
||
|
||
// Get tag suggestions based on input
|
||
function getTagSuggestions(input, excludeTags = []) {
|
||
const history = getTagsHistory();
|
||
const search = input.toLowerCase();
|
||
|
||
return history
|
||
.filter(t => t.name.toLowerCase().includes(search) && !excludeTags.includes(t.name))
|
||
.slice(0, 10);
|
||
}
|
||
|
||
// Initialize tag input for a context
|
||
function initTagInput(context, inputId, listId, suggestionsId) {
|
||
const input = document.getElementById(inputId);
|
||
const list = document.getElementById(listId);
|
||
const suggestions = document.getElementById(suggestionsId);
|
||
|
||
if (!input || !list) return;
|
||
|
||
// Render current tags
|
||
function renderTags() {
|
||
list.innerHTML = tagContexts[context].map(tag => `
|
||
<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}">×</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 cachedAlbums = getAlbumCache();
|
||
if (cachedAlbums && cachedAlbums.length > 0) {
|
||
// Use cache first for instant display
|
||
console.log('Loading cached albums:', cachedAlbums.length);
|
||
window._cachedAlbums = cachedAlbums;
|
||
renderAlbumsGrid(cachedAlbums);
|
||
// Silently refresh in background
|
||
setTimeout(() => refreshAlbumsSilently(), 2000);
|
||
} else {
|
||
// No cache or empty - load from API
|
||
console.log('No cache, loading albums from API...');
|
||
loadAlbums(false);
|
||
}
|
||
}
|
||
|
||
// ============ DIGITAL BADGE (round display) ============
|
||
{
|
||
const BADGE_SIZE = 240; // Final output (round display)
|
||
const CANVAS_DISPLAY_SIZE = 480; // Editor internal resolution
|
||
const FONT_STACK = '-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
|
||
|
||
const badgeState = {
|
||
items: [],
|
||
activeId: null,
|
||
defaultNickname: '',
|
||
};
|
||
|
||
const el = {
|
||
fileInput: document.getElementById('badge-file-upload'),
|
||
btnUpload: document.getElementById('btn-badge-upload'),
|
||
btnFromFlickr: document.getElementById('btn-badge-from-flickr'),
|
||
itemsGrid: document.getElementById('badge-items-grid'),
|
||
itemsCount: document.getElementById('badge-items-count'),
|
||
editor: document.getElementById('badge-editor'),
|
||
canvas: document.getElementById('badge-canvas'),
|
||
zoom: document.getElementById('badge-zoom'),
|
||
zoomValue: document.getElementById('badge-zoom-value'),
|
||
btnReset: document.getElementById('btn-badge-reset'),
|
||
priceEnabled: document.getElementById('badge-price-enabled'),
|
||
priceOptions: document.getElementById('badge-price-options'),
|
||
priceValue: document.getElementById('badge-price-value'),
|
||
priceY: document.getElementById('badge-price-y'),
|
||
priceYValue: document.getElementById('badge-price-y-value'),
|
||
priceSize: document.getElementById('badge-price-size'),
|
||
priceSizeValue: document.getElementById('badge-price-size-value'),
|
||
priceBg: document.getElementById('badge-price-bg'),
|
||
priceFg: document.getElementById('badge-price-fg'),
|
||
nickEnabled: document.getElementById('badge-nickname-enabled'),
|
||
nickOptions: document.getElementById('badge-nickname-options'),
|
||
nickValue: document.getElementById('badge-nickname-value'),
|
||
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 drawNicknameArc(c, size, text, color, position) {
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const R = size / 2;
|
||
const textRadius = R * 0.86;
|
||
const fontSize = Math.max(11, size * 0.075);
|
||
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;
|
||
}
|
||
|
||
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);
|
||
if (color === 'black') {
|
||
c.shadowColor = 'rgba(255,255,255,0.7)';
|
||
c.shadowBlur = size * 0.012;
|
||
c.fillStyle = '#000';
|
||
} else {
|
||
c.shadowColor = 'rgba(0,0,0,0.75)';
|
||
c.shadowBlur = size * 0.016;
|
||
c.shadowOffsetY = size * 0.003;
|
||
c.fillStyle = '#fff';
|
||
}
|
||
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) {
|
||
const nick = (item.nickname || '').trim();
|
||
if (nick) {
|
||
// Auto-position: if price occupies the bottom, draw nickname as a top arc.
|
||
drawNicknameArc(c, size, nick, item.nickColor || 'white', hasPrice ? 'top' : 'bottom');
|
||
}
|
||
}
|
||
|
||
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: 0, // -50..+20: vertical offset from default position
|
||
priceSize: 100, // 60..160 (% scale of band height)
|
||
priceBg: '#FFCC00', // ribbon color
|
||
priceFg: '#000000', // digits color
|
||
showNickname: false,
|
||
nickname: '',
|
||
nickColor: 'white',
|
||
};
|
||
}
|
||
|
||
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 || '';
|
||
document.querySelectorAll('input[name="badge-nick-color"]').forEach(r => {
|
||
r.checked = r.value === item.nickColor;
|
||
});
|
||
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;
|
||
}
|
||
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();
|
||
});
|
||
|
||
document.querySelectorAll('input[name="badge-nick-color"]').forEach(r => {
|
||
r.addEventListener('change', () => {
|
||
const item = currentItem();
|
||
if (!item) return;
|
||
if (r.checked) {
|
||
item.nickColor = 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;
|
||
}
|
||
|
||
// Skip duplicates already in badge list
|
||
const existingSrcs = new Set(badgeState.items.map(it => it.src));
|
||
let added = 0;
|
||
for (const photo of photos) {
|
||
const url = (photo.urls && (photo.urls.large || photo.urls.medium640 || photo.urls.medium)) || null;
|
||
if (!url) continue;
|
||
if (existingSrcs.has(url)) continue;
|
||
const item = await addItemFromUrl(url, photo.title || ('photo_' + photo.id), true);
|
||
if (!badgeState.activeId) selectItem(item.id);
|
||
added++;
|
||
}
|
||
if (added > 0) {
|
||
showNotification(`Добавлено ${added} фото из Flickr`, 'success');
|
||
} else {
|
||
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 ----------
|
||
|
||
document.querySelectorAll('.nav-btn[data-tab="badge"]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
loadBadgeSettings();
|
||
loadHistory();
|
||
});
|
||
});
|
||
|
||
// Initial settings load (so default nickname is available even before opening the tab)
|
||
loadBadgeSettings();
|
||
}
|
||
});
|