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);
});
});