Fix album scroll loading, lift 9-photo limit, badge import & mobile UX
Four user-reported fixes: 1. Albums infinite scroll: after a successful page load the IntersectionObserver wouldn't refire when the sentinel stayed in view (small thumbnails or large viewport), so subsequent pages needed a manual refresh. Re-check sentinel position after each load and add a scroll-event fallback for older mobile browsers. 2. 9-photo limit: add a "Больше 9 фото" toggle next to the photo source buttons. When checked, the cap lifts to 99 (covers Telegram auto-album splitting). VK still has its 9-attachment limit — if posting to VK with more selected, prompt a confirm and truncate the VK media payload to the first 9 client-side. Counters and notifications all read the dynamic max. 3. Badge tab: gallery selections now auto-import into the badge list on tab activation, so the photos appear right away with the first one opened in the editor. The "Выбранные в галерее Flickr" button reuses the same path for clarity. 4. Mobile UX: main navigation now scrolls horizontally on screens ≤720px instead of breaking into a tall stack — tabs stay on one row, scroll-snap on. In the badge editor the canvas wrapper is `position: sticky; top: 0` on screens ≤800px so the photo stays visible while scrolling through the controls below it; canvas also shrinks to ~62vw on phones. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -210,15 +210,55 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// ============ STATE ============
|
||||
|
||||
const MAX_PHOTOS = 9;
|
||||
// Soft limit for safe cross-platform posting. Can be lifted with the
|
||||
// "Больше 9 фото" toggle on the posting page; VK gets a confirm warning
|
||||
// because its album endpoint only accepts up to 9 attachments.
|
||||
const DEFAULT_MAX_PHOTOS = 9;
|
||||
const HARD_MAX_PHOTOS = 99;
|
||||
|
||||
function isPhotoLimitUnlocked() {
|
||||
return localStorage.getItem('vh_unlock_9') === '1';
|
||||
}
|
||||
|
||||
function getMaxPhotos() {
|
||||
return isPhotoLimitUnlocked() ? HARD_MAX_PHOTOS : DEFAULT_MAX_PHOTOS;
|
||||
}
|
||||
|
||||
// Backward compat for code that still reads MAX_PHOTOS as a constant —
|
||||
// we keep the name but evaluate dynamically through getter usage below.
|
||||
const MAX_PHOTOS = DEFAULT_MAX_PHOTOS;
|
||||
|
||||
// Sync the "more than 9 photos" toggle with localStorage and refresh counters
|
||||
// whenever it changes.
|
||||
function bindPhotoLimitToggle() {
|
||||
const chk = document.getElementById('chk-unlock-photo-limit');
|
||||
if (!chk) return;
|
||||
chk.checked = isPhotoLimitUnlocked();
|
||||
chk.addEventListener('change', () => {
|
||||
if (chk.checked) {
|
||||
localStorage.setItem('vh_unlock_9', '1');
|
||||
} else {
|
||||
localStorage.removeItem('vh_unlock_9');
|
||||
// If currently over 9 photos, warn — we won't trim automatically.
|
||||
if (getTotalPhotosCount() > DEFAULT_MAX_PHOTOS) {
|
||||
showNotification(`Сейчас выбрано ${getTotalPhotosCount()} фото. Удалите лишние перед публикацией.`, 'info');
|
||||
}
|
||||
}
|
||||
updateSelectionUI();
|
||||
updatePostingPreview();
|
||||
});
|
||||
}
|
||||
// Bind after DOM is ready — we're already inside DOMContentLoaded.
|
||||
bindPhotoLimitToggle();
|
||||
|
||||
function getTotalPhotosCount() {
|
||||
return state.selectedPhotos.length + state.uploadedFiles.length;
|
||||
}
|
||||
|
||||
function canAddPhotos(count = 1) {
|
||||
if (getTotalPhotosCount() + count > MAX_PHOTOS) {
|
||||
showNotification(`Максимум ${MAX_PHOTOS} фото/видео`, 'error');
|
||||
const max = getMaxPhotos();
|
||||
if (getTotalPhotosCount() + count > max) {
|
||||
showNotification(`Максимум ${max} фото/видео`, 'error');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -327,8 +367,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
for (const file of files) {
|
||||
// Check photo limit
|
||||
if (getTotalPhotosCount() >= MAX_PHOTOS) {
|
||||
showNotification(`Максимум ${MAX_PHOTOS} фото/видео`, 'error');
|
||||
const maxNow = getMaxPhotos();
|
||||
if (getTotalPhotosCount() >= maxNow) {
|
||||
showNotification(`Максимум ${maxNow} фото/видео`, 'error');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -574,17 +615,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
function updateSelectionUI() {
|
||||
const count = state.selectedPhotos.length;
|
||||
const total = getTotalPhotosCount();
|
||||
const maxNow = getMaxPhotos();
|
||||
|
||||
// Update counter with limit info (gallery floating bar)
|
||||
if (selectedCountEl) {
|
||||
selectedCountEl.textContent = `${total}/${MAX_PHOTOS}`;
|
||||
selectedCountEl.textContent = `${total}/${maxNow}`;
|
||||
}
|
||||
|
||||
// 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);
|
||||
photoCounter.textContent = `${total}/${maxNow}`;
|
||||
photoCounter.classList.toggle('at-limit', total >= maxNow);
|
||||
}
|
||||
|
||||
// Show/hide floating action bar
|
||||
@@ -600,10 +642,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
function updatePostingPreview() {
|
||||
// Always update photo counter
|
||||
const total = getTotalPhotosCount();
|
||||
const maxNow = getMaxPhotos();
|
||||
const photoCounter = document.getElementById('photo-counter');
|
||||
if (photoCounter) {
|
||||
photoCounter.textContent = `${total}/${MAX_PHOTOS}`;
|
||||
photoCounter.classList.toggle('at-limit', total >= MAX_PHOTOS);
|
||||
photoCounter.textContent = `${total}/${maxNow}`;
|
||||
photoCounter.classList.toggle('at-limit', total >= maxNow);
|
||||
}
|
||||
|
||||
if (!postPhotosPreview) return;
|
||||
@@ -1223,6 +1266,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
showNotification('Ошибка загрузки альбомов: ' + error.message, 'error');
|
||||
} finally {
|
||||
state.isLoadingMoreAlbums = false;
|
||||
// IntersectionObserver fires on STATE CHANGE — if the sentinel never left the
|
||||
// viewport (e.g. small thumbnails) it won't auto-trigger again. Re-check here.
|
||||
if (state.albumsPage < state.albumsTotalPages) {
|
||||
requestAnimationFrame(() => {
|
||||
const sentinel = document.getElementById('albums-scroll-sentinel');
|
||||
if (!sentinel || state.isLoadingMoreAlbums) return;
|
||||
const rect = sentinel.getBoundingClientRect();
|
||||
const viewportH = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (rect.top < viewportH + 200) {
|
||||
loadMoreAlbums();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1276,6 +1332,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
albumsScrollObserver.observe(sentinel);
|
||||
|
||||
// Fallback scroll listener — covers cases where IntersectionObserver
|
||||
// doesn't refire after a load (e.g. sentinel stays in viewport).
|
||||
if (!window._albumsScrollFallbackBound) {
|
||||
window._albumsScrollFallbackBound = true;
|
||||
const checkSentinel = () => {
|
||||
if (state.isLoadingMoreAlbums) return;
|
||||
if (state.albumsPage >= state.albumsTotalPages) return;
|
||||
const s = document.getElementById('albums-scroll-sentinel');
|
||||
if (!s) return;
|
||||
const rect = s.getBoundingClientRect();
|
||||
const viewportH = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (rect.top < viewportH + 200) {
|
||||
loadMoreAlbums();
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', checkSentinel, { passive: true });
|
||||
window.addEventListener('resize', checkSentinel, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ============ LOAD PHOTOS (with infinite scroll) ============
|
||||
@@ -1598,16 +1673,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// ============ FLOATING ACTION BAR ============
|
||||
|
||||
document.getElementById('btn-select-all')?.addEventListener('click', () => {
|
||||
const maxNow = getMaxPhotos();
|
||||
document.querySelectorAll('.photo-item').forEach(item => {
|
||||
if (getTotalPhotosCount() >= MAX_PHOTOS) return;
|
||||
if (getTotalPhotosCount() >= maxNow) return;
|
||||
const photo = JSON.parse(item.dataset.photoData);
|
||||
if (!state.selectedPhotos.find(p => p.id === photo.id)) {
|
||||
state.selectedPhotos.push(photo);
|
||||
item.classList.add('selected');
|
||||
}
|
||||
});
|
||||
if (getTotalPhotosCount() >= MAX_PHOTOS) {
|
||||
showNotification(`Выбрано максимум ${MAX_PHOTOS} фото`, 'info');
|
||||
if (getTotalPhotosCount() >= maxNow) {
|
||||
showNotification(`Выбрано максимум ${maxNow} фото`, 'info');
|
||||
}
|
||||
updateSelectionUI();
|
||||
saveSelectedPhotos();
|
||||
@@ -2357,6 +2433,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.filter(f => f.url && !f.uploading)
|
||||
.map(f => ({ url: f.url, type: f.type }));
|
||||
|
||||
// VK has a max of 9 attachments per post. If we're posting to VK and
|
||||
// the total media exceeds that, ask the user whether to truncate.
|
||||
const totalMedia = photoUrls.length + uploadedFileUrls.length;
|
||||
if (postToVk && totalMedia > DEFAULT_MAX_PHOTOS) {
|
||||
const ok = confirm(
|
||||
`Выбрано ${totalMedia} фото/видео, а VK принимает максимум ${DEFAULT_MAX_PHOTOS} в одной публикации.\n\n` +
|
||||
`В VK уйдут только первые ${DEFAULT_MAX_PHOTOS}. В Telegram отправятся все.\n\nПродолжить?`
|
||||
);
|
||||
if (!ok) {
|
||||
if (btnSendPost) {
|
||||
btnSendPost.disabled = false;
|
||||
btnSendPost.textContent = 'Опубликовать';
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cross-promo settings
|
||||
const crossPromoEnabled = document.getElementById('chk-cross-promo')?.checked;
|
||||
// Get settings from localStorage, but also check current input values as fallback
|
||||
@@ -2396,12 +2489,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const results = {};
|
||||
|
||||
for (const platform of platforms) {
|
||||
// VK: hard-trim combined media to the platform limit.
|
||||
let photosForPlatform = photoUrls;
|
||||
let uploadedForPlatform = uploadedFileUrls;
|
||||
if (platform.type === 'vk') {
|
||||
const cap = DEFAULT_MAX_PHOTOS;
|
||||
if (photoUrls.length >= cap) {
|
||||
photosForPlatform = photoUrls.slice(0, cap);
|
||||
uploadedForPlatform = [];
|
||||
} else {
|
||||
photosForPlatform = photoUrls;
|
||||
uploadedForPlatform = uploadedFileUrls.slice(0, cap - photoUrls.length);
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'multi_post');
|
||||
formData.append('platforms', JSON.stringify([platform]));
|
||||
formData.append('text', platform.type === 'telegram' ? textForTelegram : textForVk);
|
||||
formData.append('photos', JSON.stringify(photoUrls));
|
||||
formData.append('uploaded_files', JSON.stringify(uploadedFileUrls));
|
||||
formData.append('photos', JSON.stringify(photosForPlatform));
|
||||
formData.append('uploaded_files', JSON.stringify(uploadedForPlatform));
|
||||
formData.append('parse_mode', parseMode);
|
||||
|
||||
const response = await fetch('api.php', { method: 'POST', body: formData });
|
||||
@@ -5039,26 +5146,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
el.btnFromFlickr.addEventListener('click', async () => {
|
||||
const photos = state.selectedPhotos || [];
|
||||
if (photos.length === 0) {
|
||||
showNotification('Сначала выберите фото в Галерее', 'info');
|
||||
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');
|
||||
const added = await autoImportGallerySelection(true);
|
||||
if (added === 0) {
|
||||
showNotification('Все выбранные фото уже в списке', 'info');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5290,10 +5384,37 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// ---------- Tab activation ----------
|
||||
|
||||
// Pull in currently-selected gallery photos that aren't already in the
|
||||
// badge list. Called when opening the badge tab and when clicking the
|
||||
// "from Flickr" button. Returns the number of items added.
|
||||
async function autoImportGallerySelection(showFeedback) {
|
||||
const photos = state.selectedPhotos || [];
|
||||
if (photos.length === 0) return 0;
|
||||
const existingSrcs = new Set(badgeState.items.map(it => it.src));
|
||||
const toAdd = [];
|
||||
for (const photo of photos) {
|
||||
const url = (photo.urls && (photo.urls.large || photo.urls.medium640 || photo.urls.medium)) || null;
|
||||
if (!url || existingSrcs.has(url)) continue;
|
||||
toAdd.push({ url, name: photo.title || ('photo_' + photo.id) });
|
||||
}
|
||||
if (toAdd.length === 0) return 0;
|
||||
for (const t of toAdd) {
|
||||
const item = await addItemFromUrl(t.url, t.name, true);
|
||||
if (!badgeState.activeId) selectItem(item.id);
|
||||
}
|
||||
if (showFeedback) {
|
||||
showNotification(`Добавлено ${toAdd.length} фото из Галереи`, 'success');
|
||||
}
|
||||
return toAdd.length;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.nav-btn[data-tab="badge"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
loadBadgeSettings();
|
||||
loadHistory();
|
||||
// Auto-import gallery selections so users see them right away
|
||||
// without having to click "Выбранные в галерее Flickr".
|
||||
autoImportGallerySelection(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user