From 91be1b175182fc7356cecb59af31a400d04a6993 Mon Sep 17 00:00:00 2001 From: zuevav <34027267+zuevav@users.noreply.github.com> Date: Thu, 14 May 2026 23:16:23 +0300 Subject: [PATCH] Fix album scroll loading, lift 9-photo limit, badge import & mobile UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- css/style.css | 54 +++++++++++++++ index.php | 4 ++ js/app.js | 185 +++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 211 insertions(+), 32 deletions(-) diff --git a/css/style.css b/css/style.css index fd3e89b..b1d9b96 100644 --- a/css/style.css +++ b/css/style.css @@ -212,6 +212,30 @@ body { flex-wrap: wrap; } +/* On narrow screens, switch to horizontal scrolling so 5+ tabs stay + on one row instead of breaking into a tall stack. */ +@media (max-width: 720px) { + .main-nav { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x proximity; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .main-nav::-webkit-scrollbar { + display: none; + } + .nav-btn { + flex: 0 0 auto; + min-width: auto; + white-space: nowrap; + scroll-snap-align: start; + padding: 10px 16px; + font-size: 0.9rem; + } +} + .nav-btn { flex: 1; min-width: 120px; @@ -4127,6 +4151,36 @@ select { @media (max-width: 800px) { .badge-editor-layout { grid-template-columns: 1fr; + gap: 12px; + } + /* Keep the canvas visible while the user scrolls down through the + controls — sticky to the top of the viewport. The thumbnail shrinks + to ~62vw so the controls below have room to breathe. */ + .badge-canvas-wrapper { + position: sticky; + top: 0; + z-index: 20; + background: var(--bg-secondary); + padding: 8px 0 6px; + margin: 0 -8px; + border-bottom: 1px solid var(--border-color); + } + #badge-canvas { + max-width: min(62vw, 320px); + } + .badge-canvas-hint { + font-size: 0.78em; + } + .badge-controls { + padding-top: 4px; + } + /* Stack form rows tightly on phones. */ + .badge-color-row { + gap: 6px; + } + .badge-color-swatch { + width: 32px; + height: 32px; } } diff --git a/index.php b/index.php index 66042fb..1a38ce4 100644 --- a/index.php +++ b/index.php @@ -208,6 +208,10 @@ if (isset($_GET['logout'])) { 📤 Загрузить +

Нажмите кнопку выше чтобы добавить фото

diff --git a/js/app.js b/js/app.js index dc964bf..821367d 100644 --- a/js/app.js +++ b/js/app.js @@ -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); }); });