Compare commits

...

2 Commits

Author SHA1 Message Date
zuevav 91be1b1751 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>
2026-05-14 23:16:23 +03:00
zuevav 8434a6888f Add nickname position selector and "use as price" toggle
Adds two more nickname controls. A position selector (Auto / Снизу /
Сверху) lets users override the auto-positioning that previously moved
the nickname to the top whenever a price tag was visible — Auto keeps
the old behavior. A "Use as price — add ₽" checkbox appends the ruble
symbol to the rendered nickname text and inserts thousands separators
when the text is purely digits, making it usable as a price label
without the yellow band (handy when you want a curved price instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:51:29 +03:00
3 changed files with 278 additions and 35 deletions
+54
View File
@@ -212,6 +212,30 @@ body {
flex-wrap: wrap; 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 { .nav-btn {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
@@ -4127,6 +4151,36 @@ select {
@media (max-width: 800px) { @media (max-width: 800px) {
.badge-editor-layout { .badge-editor-layout {
grid-template-columns: 1fr; 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;
} }
} }
+27
View File
@@ -208,6 +208,10 @@ if (isset($_GET['logout'])) {
<span class="btn-icon-text">📤</span> Загрузить <span class="btn-icon-text">📤</span> Загрузить
</button> </button>
</div> </div>
<label class="checkbox-label compact" style="margin-top:8px;">
<input type="checkbox" id="chk-unlock-photo-limit">
<span>Больше 9 фото (для Telegram). В VK уйдут только первые 9.</span>
</label>
<div id="post-photos-preview" class="photos-preview combined-preview"> <div id="post-photos-preview" class="photos-preview combined-preview">
<p class="placeholder">Нажмите кнопку выше чтобы добавить фото</p> <p class="placeholder">Нажмите кнопку выше чтобы добавить фото</p>
</div> </div>
@@ -685,6 +689,29 @@ https://live.staticflickr.com/65535/12345678901_abcdef1234_b.jpg"></textarea>
<input type="text" id="badge-nickname-value" maxlength="40" placeholder="Никнейм"> <input type="text" id="badge-nickname-value" maxlength="40" placeholder="Никнейм">
<span class="hint">По умолчанию из настроек.</span> <span class="hint">По умолчанию из настроек.</span>
</div> </div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="badge-nick-as-price">
<span>Использовать как цену добавить </span>
</label>
</div>
<div class="form-group">
<label>Положение надписи:</label>
<div class="badge-radio-row">
<label class="radio-label">
<input type="radio" name="badge-nick-position" value="auto" checked>
<span>Авто</span>
</label>
<label class="radio-label">
<input type="radio" name="badge-nick-position" value="bottom">
<span>Снизу</span>
</label>
<label class="radio-label">
<input type="radio" name="badge-nick-position" value="top">
<span>Сверху</span>
</label>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label>Размер текста: <span id="badge-nick-size-value">100%</span></label> <label>Размер текста: <span id="badge-nick-size-value">100%</span></label>
<input type="range" id="badge-nick-size" min="60" max="160" step="5" value="100"> <input type="range" id="badge-nick-size" min="60" max="160" step="5" value="100">
+197 -35
View File
@@ -210,15 +210,55 @@ document.addEventListener('DOMContentLoaded', function() {
// ============ STATE ============ // ============ 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() { function getTotalPhotosCount() {
return state.selectedPhotos.length + state.uploadedFiles.length; return state.selectedPhotos.length + state.uploadedFiles.length;
} }
function canAddPhotos(count = 1) { function canAddPhotos(count = 1) {
if (getTotalPhotosCount() + count > MAX_PHOTOS) { const max = getMaxPhotos();
showNotification(`Максимум ${MAX_PHOTOS} фото/видео`, 'error'); if (getTotalPhotosCount() + count > max) {
showNotification(`Максимум ${max} фото/видео`, 'error');
return false; return false;
} }
return true; return true;
@@ -327,8 +367,9 @@ document.addEventListener('DOMContentLoaded', function() {
for (const file of files) { for (const file of files) {
// Check photo limit // Check photo limit
if (getTotalPhotosCount() >= MAX_PHOTOS) { const maxNow = getMaxPhotos();
showNotification(`Максимум ${MAX_PHOTOS} фото/видео`, 'error'); if (getTotalPhotosCount() >= maxNow) {
showNotification(`Максимум ${maxNow} фото/видео`, 'error');
break; break;
} }
@@ -574,17 +615,18 @@ document.addEventListener('DOMContentLoaded', function() {
function updateSelectionUI() { function updateSelectionUI() {
const count = state.selectedPhotos.length; const count = state.selectedPhotos.length;
const total = getTotalPhotosCount(); const total = getTotalPhotosCount();
const maxNow = getMaxPhotos();
// Update counter with limit info (gallery floating bar) // Update counter with limit info (gallery floating bar)
if (selectedCountEl) { if (selectedCountEl) {
selectedCountEl.textContent = `${total}/${MAX_PHOTOS}`; selectedCountEl.textContent = `${total}/${maxNow}`;
} }
// Update counter on posting page // Update counter on posting page
const photoCounter = document.getElementById('photo-counter'); const photoCounter = document.getElementById('photo-counter');
if (photoCounter) { if (photoCounter) {
photoCounter.textContent = `${total}/${MAX_PHOTOS}`; photoCounter.textContent = `${total}/${maxNow}`;
photoCounter.classList.toggle('at-limit', total >= MAX_PHOTOS); photoCounter.classList.toggle('at-limit', total >= maxNow);
} }
// Show/hide floating action bar // Show/hide floating action bar
@@ -600,10 +642,11 @@ document.addEventListener('DOMContentLoaded', function() {
function updatePostingPreview() { function updatePostingPreview() {
// Always update photo counter // Always update photo counter
const total = getTotalPhotosCount(); const total = getTotalPhotosCount();
const maxNow = getMaxPhotos();
const photoCounter = document.getElementById('photo-counter'); const photoCounter = document.getElementById('photo-counter');
if (photoCounter) { if (photoCounter) {
photoCounter.textContent = `${total}/${MAX_PHOTOS}`; photoCounter.textContent = `${total}/${maxNow}`;
photoCounter.classList.toggle('at-limit', total >= MAX_PHOTOS); photoCounter.classList.toggle('at-limit', total >= maxNow);
} }
if (!postPhotosPreview) return; if (!postPhotosPreview) return;
@@ -1223,6 +1266,19 @@ document.addEventListener('DOMContentLoaded', function() {
showNotification('Ошибка загрузки альбомов: ' + error.message, 'error'); showNotification('Ошибка загрузки альбомов: ' + error.message, 'error');
} finally { } finally {
state.isLoadingMoreAlbums = false; 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); 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) ============ // ============ LOAD PHOTOS (with infinite scroll) ============
@@ -1598,16 +1673,17 @@ document.addEventListener('DOMContentLoaded', function() {
// ============ FLOATING ACTION BAR ============ // ============ FLOATING ACTION BAR ============
document.getElementById('btn-select-all')?.addEventListener('click', () => { document.getElementById('btn-select-all')?.addEventListener('click', () => {
const maxNow = getMaxPhotos();
document.querySelectorAll('.photo-item').forEach(item => { document.querySelectorAll('.photo-item').forEach(item => {
if (getTotalPhotosCount() >= MAX_PHOTOS) return; if (getTotalPhotosCount() >= maxNow) return;
const photo = JSON.parse(item.dataset.photoData); const photo = JSON.parse(item.dataset.photoData);
if (!state.selectedPhotos.find(p => p.id === photo.id)) { if (!state.selectedPhotos.find(p => p.id === photo.id)) {
state.selectedPhotos.push(photo); state.selectedPhotos.push(photo);
item.classList.add('selected'); item.classList.add('selected');
} }
}); });
if (getTotalPhotosCount() >= MAX_PHOTOS) { if (getTotalPhotosCount() >= maxNow) {
showNotification(`Выбрано максимум ${MAX_PHOTOS} фото`, 'info'); showNotification(`Выбрано максимум ${maxNow} фото`, 'info');
} }
updateSelectionUI(); updateSelectionUI();
saveSelectedPhotos(); saveSelectedPhotos();
@@ -2357,6 +2433,23 @@ document.addEventListener('DOMContentLoaded', function() {
.filter(f => f.url && !f.uploading) .filter(f => f.url && !f.uploading)
.map(f => ({ url: f.url, type: f.type })); .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 // Check cross-promo settings
const crossPromoEnabled = document.getElementById('chk-cross-promo')?.checked; const crossPromoEnabled = document.getElementById('chk-cross-promo')?.checked;
// Get settings from localStorage, but also check current input values as fallback // Get settings from localStorage, but also check current input values as fallback
@@ -2396,12 +2489,26 @@ document.addEventListener('DOMContentLoaded', function() {
const results = {}; const results = {};
for (const platform of platforms) { 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(); const formData = new FormData();
formData.append('action', 'multi_post'); formData.append('action', 'multi_post');
formData.append('platforms', JSON.stringify([platform])); formData.append('platforms', JSON.stringify([platform]));
formData.append('text', platform.type === 'telegram' ? textForTelegram : textForVk); formData.append('text', platform.type === 'telegram' ? textForTelegram : textForVk);
formData.append('photos', JSON.stringify(photoUrls)); formData.append('photos', JSON.stringify(photosForPlatform));
formData.append('uploaded_files', JSON.stringify(uploadedFileUrls)); formData.append('uploaded_files', JSON.stringify(uploadedForPlatform));
formData.append('parse_mode', parseMode); formData.append('parse_mode', parseMode);
const response = await fetch('api.php', { method: 'POST', body: formData }); const response = await fetch('api.php', { method: 'POST', body: formData });
@@ -4265,6 +4372,7 @@ document.addEventListener('DOMContentLoaded', function() {
nickSizeValue: document.getElementById('badge-nick-size-value'), nickSizeValue: document.getElementById('badge-nick-size-value'),
nickEdge: document.getElementById('badge-nick-edge'), nickEdge: document.getElementById('badge-nick-edge'),
nickEdgeValue: document.getElementById('badge-nick-edge-value'), nickEdgeValue: document.getElementById('badge-nick-edge-value'),
nickAsPrice: document.getElementById('badge-nick-as-price'),
btnDownload: document.getElementById('btn-badge-download'), btnDownload: document.getElementById('btn-badge-download'),
btnRemove: document.getElementById('btn-badge-remove'), btnRemove: document.getElementById('btn-badge-remove'),
batchActions: document.getElementById('badge-batch-actions'), batchActions: document.getElementById('badge-batch-actions'),
@@ -4494,10 +4602,24 @@ document.addEventListener('DOMContentLoaded', function() {
} }
if (item.showNickname) { if (item.showNickname) {
const nick = (item.nickname || '').trim(); let nick = (item.nickname || '').trim();
// "Use as price" formatting: append ₽; if the text is purely digits,
// also insert thousands separators so it reads as a proper price.
if (item.nickAsPrice && nick) {
const digits = nick.replace(/[^\d]/g, '');
if (digits && /^\d+$/.test(nick)) {
nick = parseInt(digits, 10).toLocaleString('ru-RU') + ' ₽';
} else if (!nick.includes('₽')) {
nick = nick + ' ₽';
}
}
if (nick) { if (nick) {
// Auto-position: if price occupies the bottom, draw nickname as a top arc. // Position: manual override (top/bottom) or auto (top when price occupies the bottom).
drawNicknameArc(c, size, nick, item, hasPrice ? 'top' : 'bottom'); let pos;
if (item.nickPosition === 'top') pos = 'top';
else if (item.nickPosition === 'bottom') pos = 'bottom';
else pos = hasPrice ? 'top' : 'bottom';
drawNicknameArc(c, size, nick, item, pos);
} }
} }
@@ -4563,6 +4685,8 @@ document.addEventListener('DOMContentLoaded', function() {
nickColor: '#FFFFFF', // text color (hex) nickColor: '#FFFFFF', // text color (hex)
nickSize: 100, // 60..160 (% scale of font) nickSize: 100, // 60..160 (% scale of font)
nickEdge: 14, // 0..30: percent inset from circle edge (0 = at edge) nickEdge: 14, // 0..30: percent inset from circle edge (0 = at edge)
nickPosition: 'auto', // 'auto' | 'top' | 'bottom'
nickAsPrice: false, // append ₽ + format digits as price
}; };
} }
@@ -4627,6 +4751,10 @@ document.addEventListener('DOMContentLoaded', function() {
el.nickSizeValue.textContent = item.nickSize + '%'; el.nickSizeValue.textContent = item.nickSize + '%';
el.nickEdge.value = item.nickEdge; el.nickEdge.value = item.nickEdge;
el.nickEdgeValue.textContent = item.nickEdge; el.nickEdgeValue.textContent = item.nickEdge;
el.nickAsPrice.checked = !!item.nickAsPrice;
document.querySelectorAll('input[name="badge-nick-position"]').forEach(r => {
r.checked = r.value === (item.nickPosition || 'auto');
});
renderItemsGrid(); renderItemsGrid();
renderPreview(); renderPreview();
} }
@@ -4962,6 +5090,26 @@ document.addEventListener('DOMContentLoaded', function() {
renderPreview(); renderPreview();
}); });
// Nickname "use as price" — append ₽ to the rendered text.
el.nickAsPrice.addEventListener('change', () => {
const item = currentItem();
if (!item) return;
item.nickAsPrice = el.nickAsPrice.checked;
renderPreview();
});
// Nickname position (auto / top / bottom).
document.querySelectorAll('input[name="badge-nick-position"]').forEach(r => {
r.addEventListener('change', () => {
const item = currentItem();
if (!item) return;
if (r.checked) {
item.nickPosition = r.value;
renderPreview();
}
});
});
el.btnRemove.addEventListener('click', () => { el.btnRemove.addEventListener('click', () => {
const item = currentItem(); const item = currentItem();
if (item) removeItem(item.id); if (item) removeItem(item.id);
@@ -4998,26 +5146,13 @@ document.addEventListener('DOMContentLoaded', function() {
el.btnFromFlickr.addEventListener('click', async () => { el.btnFromFlickr.addEventListener('click', async () => {
const photos = state.selectedPhotos || []; const photos = state.selectedPhotos || [];
if (photos.length === 0) { if (photos.length === 0) {
showNotification('Сначала выберите фото в Галерее', 'info'); showNotification('Сначала выберите фото в Галерее (галочками)', 'info');
document.querySelector('.nav-btn[data-tab="gallery"]')?.click(); document.querySelector('.nav-btn[data-tab="gallery"]')?.click();
return; return;
} }
const added = await autoImportGallerySelection(true);
// Skip duplicates already in badge list if (added === 0) {
const existingSrcs = new Set(badgeState.items.map(it => it.src)); showNotification('Все выбранные фото уже в списке', 'info');
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');
} }
}); });
@@ -5249,10 +5384,37 @@ document.addEventListener('DOMContentLoaded', function() {
// ---------- Tab activation ---------- // ---------- 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 => { document.querySelectorAll('.nav-btn[data-tab="badge"]').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
loadBadgeSettings(); loadBadgeSettings();
loadHistory(); loadHistory();
// Auto-import gallery selections so users see them right away
// without having to click "Выбранные в галерее Flickr".
autoImportGallerySelection(false);
}); });
}); });