Compare commits

...

4 Commits

Author SHA1 Message Date
zuevav 55c9be761c Fix tiny price-input field and iOS zoom-on-focus on number inputs
input[type="number"] wasn't in the global input selector, so the price
field rendered with browser defaults: tiny font, no padding, and a
spinner that squeezed the hit area. On iOS this also caused a focus-
zoom because the rendered font size was below the 16px threshold
Safari uses to decide whether to zoom in.

Adds number/search/tel/url types to the styled input selector, bumps
the global font-size to a hard 16px (still 1rem in practice but
explicit prevents subsequent overrides), and hides the spinner via
webkit/moz appearance reset. Same treatment in the mobile @media
block. Result: price field looks identical to the nickname field and
the page no longer zooms when focusing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:02:58 +03:00
zuevav d776870d35 Remove stale 5-button grid nav rules that broke the 6-tab horizontal layout
The real reason the mobile nav still wrapped into a tall stack: deeper
in style.css (lines ~3260, ~3876, ~3950, ~3978) the original mobile
design used `display: grid` with explicit grid-column overrides for the
4th/5th nav buttons. Those rules sit AFTER the @media block I added at
line 244, so same-specificity source-order made them win — flipping
the nav back from flex to a 3-column grid that no longer fits 6 items.

Removed/neutered those legacy grid rules (designed for the old 5-tab
nav) and added `display: flex !important` plus `grid-template-columns:
none !important` to the new @media block so any future grid rule can't
sneak back in. Result: on screens ≤720px the nav is a single flex row
with horizontal scroll, no matter the source order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:57:44 +03:00
zuevav 3e1cc94258 Force mobile nav rules with !important to defeat stale CSS cache
After multiple deploys the mobile nav still wraps into a tall stack on
some user devices. The CSS source is correct, so the most likely cause
is Safari aggressively caching the previous stylesheet. Adding
!important to the @media block guarantees that once the new CSS
actually parses, no leftover specificity quirk or cached partial
keeps the wrapped layout alive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:52:24 +03:00
zuevav 7ed7d22b6d Actually fix mobile nav + arm albums infinite scroll for cached loads
Two regressions from the previous fix:

1. Mobile nav still wrapped into a tall stack. The @media block was
   placed BEFORE the base `.nav-btn { flex: 1; min-width: 120px }`
   rule, so source-order specificity made the base rule override the
   mobile one. Moved the @media block to AFTER all base nav-btn rules
   so it wins on screens ≤720px.

2. Albums infinite scroll never engaged when the gallery was loaded
   from cache (the common case). `getAlbumCache` returned only the
   array, `setAlbumCache` discarded pagination, and the DOMContentLoad
   auto-load + `refreshAlbumsSilently` both bypassed
   `setupAlbumsInfiniteScroll`. Now the cache preserves `{ list, page,
   pages, total }`, a new `getAlbumCacheFull` exposes it, and every
   cached-render path primes pagination state and arms the observer.
   Background refresh also re-arms the observer after pulling fresh
   `pages`, fixing the case where the cache had a stale `pages: 1`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:29:57 +03:00
2 changed files with 156 additions and 110 deletions
+64 -73
View File
@@ -212,30 +212,6 @@ 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;
@@ -261,6 +237,36 @@ body {
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3); box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
} }
/* On narrow screens, switch to horizontal scrolling so all 6 tabs stay
on a single row instead of breaking into a tall stack. */
@media (max-width: 720px) {
.main-nav {
display: flex !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
overflow-y: hidden !important;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
grid-template-columns: none !important;
gap: 4px !important;
padding: 6px !important;
}
.main-nav::-webkit-scrollbar {
display: none;
}
.nav-btn {
flex: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
white-space: nowrap !important;
scroll-snap-align: start;
padding: 10px 14px !important;
font-size: 0.88rem !important;
grid-column: auto !important;
}
}
/* ============ Panels ============ */ /* ============ Panels ============ */
.panel { .panel {
background: var(--glass-bg); background: var(--glass-bg);
@@ -329,6 +335,10 @@ body {
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
input[type="email"], input[type="email"],
input[type="number"],
input[type="search"],
input[type="tel"],
input[type="url"],
select, select,
textarea { textarea {
width: 100%; width: 100%;
@@ -336,7 +346,9 @@ textarea {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 1rem; /* 16px minimum prevents iOS Safari from zooming the page when an input
receives focus. */
font-size: 16px;
font-family: inherit; font-family: inherit;
color: var(--text-primary); color: var(--text-primary);
transition: all var(--transition-fast); transition: all var(--transition-fast);
@@ -356,6 +368,17 @@ textarea {
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
} }
/* Hide the up/down spinner on number inputs so the field looks the same
as the text inputs (and the actual hit area isn't squeezed). */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
select { select {
cursor: pointer; cursor: pointer;
appearance: none; appearance: none;
@@ -3252,34 +3275,13 @@ select {
transform: translateX(16px); transform: translateX(16px);
} }
/* Navigation - grid layout for mobile */ /* Mobile navigation tweaks — horizontal-scroll layout is defined at the
top of this file (@media max-width: 720px). Here we only adjust the
outer margin for the smaller breakpoint. */
.main-nav { .main-nav {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 6px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.nav-btn {
padding: 10px 6px;
font-size: 0.75rem;
text-align: center;
min-width: 0;
flex: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Make settings button span 2 columns for 5 items grid */
.nav-btn:nth-child(4) {
grid-column: 1 / 2;
}
.nav-btn:nth-child(5) {
grid-column: 2 / 4;
}
.panel { .panel {
padding: 12px; padding: 12px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -3604,6 +3606,11 @@ select {
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
input[type="email"],
input[type="number"],
input[type="search"],
input[type="tel"],
input[type="url"],
textarea, textarea,
select { select {
font-size: 16px; font-size: 16px;
@@ -3868,20 +3875,9 @@ select {
font-size: 1rem; font-size: 1rem;
} }
.main-nav { /* Mobile nav layout is defined in the top-level @media (max-width: 720px)
grid-template-columns: repeat(2, 1fr); — horizontal scroll with nowrap. We don't need to redefine it for
gap: 4px; 480px; everything inherits. */
}
/* Adjust nav buttons for 2-column grid */
.nav-btn:nth-child(5) {
grid-column: 1 / 3;
}
.nav-btn {
padding: 10px 4px;
font-size: 0.7rem;
}
.panel { .panel {
padding: 10px; padding: 10px;
@@ -3942,9 +3938,11 @@ select {
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Keep nav button text readable even on tiny phones, but smaller than
720px breakpoint. */
.nav-btn { .nav-btn {
font-size: 0.65rem; font-size: 0.78rem !important;
padding: 8px 2px; padding: 9px 11px !important;
} }
.panel { .panel {
@@ -4000,14 +3998,7 @@ select {
/* Landscape phones */ /* Landscape phones */
@media (max-width: 768px) and (orientation: landscape) { @media (max-width: 768px) and (orientation: landscape) {
.main-nav { /* Navigation uses the horizontal-scroll layout from the 720px block. */
grid-template-columns: repeat(5, 1fr);
}
.nav-btn:nth-child(4),
.nav-btn:nth-child(5) {
grid-column: auto;
}
.floating-action-bar { .floating-action-bar {
bottom: 6px; bottom: 6px;
+92 -37
View File
@@ -482,7 +482,16 @@ document.addEventListener('DOMContentLoaded', function() {
const ALBUM_PREFS_KEY = 'vh_album_prefs'; const ALBUM_PREFS_KEY = 'vh_album_prefs';
const CACHE_TTL = 60 * 60 * 1000; // 1 hour const CACHE_TTL = 60 * 60 * 1000; // 1 hour
// Returns just the albums array (back-compat with callers that destructure
// an array). The full pagination object is available via getAlbumCacheFull.
function getAlbumCache() { function getAlbumCache() {
const full = getAlbumCacheFull();
return full ? full.albums : null;
}
// Returns { albums, page, pages, total } or null. Pagination info may be
// missing on legacy caches.
function getAlbumCacheFull() {
try { try {
const cached = localStorage.getItem(ALBUM_CACHE_KEY); const cached = localStorage.getItem(ALBUM_CACHE_KEY);
if (!cached) return null; if (!cached) return null;
@@ -491,12 +500,26 @@ document.addEventListener('DOMContentLoaded', function() {
localStorage.removeItem(ALBUM_CACHE_KEY); localStorage.removeItem(ALBUM_CACHE_KEY);
return null; return null;
} }
// Handle both old format (array) and new format (object with albums property)
const albums = data.albums; const albums = data.albums;
if (Array.isArray(albums)) { if (Array.isArray(albums)) {
return albums; // Legacy flat-array format — pagination unknown.
return { albums, page: 1, pages: 1, total: albums.length };
} else if (albums && Array.isArray(albums.albums)) { } else if (albums && Array.isArray(albums.albums)) {
return albums.albums; // Older nested format.
return {
albums: albums.albums,
page: albums.page || 1,
pages: albums.pages || 1,
total: albums.total || albums.albums.length,
};
} else if (Array.isArray(data.list)) {
// Current format: stored as { list, page, pages, total }.
return {
albums: data.list,
page: data.page || 1,
pages: data.pages || 1,
total: data.total || data.list.length,
};
} }
return null; return null;
} catch (e) { } catch (e) {
@@ -506,12 +529,21 @@ document.addEventListener('DOMContentLoaded', function() {
function setAlbumCache(albumsOrData) { function setAlbumCache(albumsOrData) {
try { try {
// Accept either array or object with albums property let payload;
const albums = Array.isArray(albumsOrData) ? albumsOrData : (albumsOrData.albums || []); if (Array.isArray(albumsOrData)) {
localStorage.setItem(ALBUM_CACHE_KEY, JSON.stringify({ payload = { list: albumsOrData, page: 1, pages: 1, total: albumsOrData.length };
albums: albums, } else if (albumsOrData && Array.isArray(albumsOrData.albums)) {
timestamp: Date.now() payload = {
})); list: albumsOrData.albums,
page: albumsOrData.page || 1,
pages: albumsOrData.pages || 1,
total: albumsOrData.total || albumsOrData.albums.length,
};
} else {
return;
}
payload.timestamp = Date.now();
localStorage.setItem(ALBUM_CACHE_KEY, JSON.stringify(payload));
} catch (e) { } catch (e) {
console.warn('Failed to cache albums:', e); console.warn('Failed to cache albums:', e);
} }
@@ -1024,22 +1056,40 @@ document.addEventListener('DOMContentLoaded', function() {
refreshAlbumsSilently(); refreshAlbumsSilently();
} }
// Refresh albums in background without showing loading to user // Refresh albums in background without showing loading to user.
// IMPORTANT: also saves pagination state and arms the infinite-scroll
// observer, since the cached-render path skips both.
async function refreshAlbumsSilently() { async function refreshAlbumsSilently() {
if (state.isLoadingAlbums) return; if (state.isLoadingAlbums) return;
try { try {
const response = await fetch('api.php?action=flickr_albums'); const response = await fetch('api.php?action=flickr_albums&page=1&per_page=50');
if (!response.ok) return; if (!response.ok) return;
const data = await response.json(); const data = await response.json();
if (data.albums && data.albums.length > 0) { if (data.albums && data.albums.length > 0) {
setAlbumCache(data.albums); state.allAlbums = data.albums;
state.albumsPage = data.page || 1;
state.albumsTotalPages = data.pages || 1;
state.albumsTotal = data.total || data.albums.length;
setAlbumCache({
albums: data.albums,
page: state.albumsPage,
pages: state.albumsTotalPages,
total: state.albumsTotal,
});
window._cachedAlbums = data.albums; window._cachedAlbums = data.albums;
// Only re-render if on albums view (photos view is hidden) // Only re-render if on albums view (photos view is hidden)
if (photosView?.classList.contains('hidden')) { if (photosView?.classList.contains('hidden')) {
renderAlbumsGrid(data.albums); renderAlbumsGrid(data.albums);
} }
// Arm infinite scroll if there are more pages available.
if (state.albumsPage < state.albumsTotalPages) {
setupAlbumsInfiniteScroll();
}
} }
} catch (error) { } catch (error) {
console.log('Silent refresh failed:', error.message); console.log('Silent refresh failed:', error.message);
@@ -1071,25 +1121,23 @@ document.addEventListener('DOMContentLoaded', function() {
// Check cache first // Check cache first
if (!forceRefresh) { if (!forceRefresh) {
const cached = getAlbumCache(); const full = getAlbumCacheFull();
if (cached && cached.albums) { if (full && full.albums && full.albums.length > 0) {
console.log('Using cached albums:', cached.albums.length); console.log('Using cached albums:', full.albums.length);
state.allAlbums = cached.albums; state.allAlbums = full.albums;
state.albumsPage = cached.page || 1; state.albumsPage = full.page;
state.albumsTotalPages = cached.pages || 1; state.albumsTotalPages = full.pages;
state.albumsTotal = cached.total || cached.albums.length; state.albumsTotal = full.total;
window._cachedAlbums = cached.albums; window._cachedAlbums = full.albums;
renderAlbumsGrid(cached.albums); renderAlbumsGrid(full.albums);
showNotification(`Загружено ${cached.albums.length} альбомов (из кеша)`, 'success'); showNotification(`Загружено ${full.albums.length} альбомов (из кеша)`, 'success');
// Arm scroll observer if there might be more pages, even when
// pagination info is missing from a legacy cache.
setupAlbumsInfiniteScroll(); setupAlbumsInfiniteScroll();
return; // Refresh in background to get accurate page count.
} else if (cached && Array.isArray(cached)) { if (full.pages <= 1) {
// Old cache format compatibility setTimeout(() => refreshAlbumsSilently(), 500);
console.log('Using old cached albums:', cached.length); }
state.allAlbums = cached;
window._cachedAlbums = cached;
renderAlbumsGrid(cached);
showNotification(`Загружено ${cached.length} альбомов (из кеша)`, 'success');
return; return;
} }
} }
@@ -4317,13 +4365,20 @@ document.addEventListener('DOMContentLoaded', function() {
// Auto-load albums on page init // Auto-load albums on page init
if (albumsGrid) { if (albumsGrid) {
const cachedAlbums = getAlbumCache(); const fullCache = getAlbumCacheFull();
if (cachedAlbums && cachedAlbums.length > 0) { if (fullCache && fullCache.albums.length > 0) {
// Use cache first for instant display // Use cache for instant display, and prime pagination state so
console.log('Loading cached albums:', cachedAlbums.length); // the infinite-scroll observer can engage immediately.
window._cachedAlbums = cachedAlbums; console.log('Loading cached albums:', fullCache.albums.length);
renderAlbumsGrid(cachedAlbums); state.allAlbums = fullCache.albums;
// Silently refresh in background state.albumsPage = fullCache.page;
state.albumsTotalPages = fullCache.pages;
state.albumsTotal = fullCache.total;
window._cachedAlbums = fullCache.albums;
renderAlbumsGrid(fullCache.albums);
setupAlbumsInfiniteScroll();
// Silently refresh in background — this also corrects pagination
// info if the cache is from a legacy build that lost it.
setTimeout(() => refreshAlbumsSilently(), 2000); setTimeout(() => refreshAlbumsSilently(), 2000);
} else { } else {
// No cache or empty - load from API // No cache or empty - load from API