feat: pinch-zoom, heatmap iframe, and scroll strips on /explore
- Enable native mobile pinch-zoom by rewriting the viewport meta. - Strip Content-Security-Policy / X-Frame-Options from HTML responses so heatmap.jeffemmett.com can be iframed in. - On /explore: inject a collapsible heatmap iframe at the top, and replace People + Places sections (truncated grid with 'view more') with horizontally-scrollable strips fetched from /api/people and /api/search/cities. - Re-run injection on history pushState + MutationObserver to cover Svelte SPA route changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2418298d77
commit
7be768e2db
|
|
@ -742,6 +742,263 @@
|
|||
|
||||
console.log('[live-search] Immich live search with map view loaded');
|
||||
|
||||
// --- Enable pinch-zoom on mobile ---
|
||||
// Immich ships a viewport meta that disables user scaling. Keep rewriting
|
||||
// it so any future re-render doesn't clobber our override.
|
||||
(function enablePinchZoom() {
|
||||
const desired = 'width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes';
|
||||
function apply() {
|
||||
let meta = document.querySelector('meta[name="viewport"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'viewport';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
if (meta.content !== desired) meta.content = desired;
|
||||
}
|
||||
apply();
|
||||
new MutationObserver(apply).observe(document.head, {
|
||||
childList: true, subtree: true,
|
||||
attributes: true, attributeFilter: ['content']
|
||||
});
|
||||
})();
|
||||
|
||||
// --- /explore page enhancements ---
|
||||
// 1. Iframe the photo-locations heatmap at the top of the page.
|
||||
// 2. Replace People + Places sections (truncated + "view more") with
|
||||
// horizontally-scrollable strips showing the full list.
|
||||
(function setupExploreEnhancements() {
|
||||
const HEATMAP_URL = 'https://heatmap.jeffemmett.com/';
|
||||
const PEOPLE_CACHE_MS = 5 * 60 * 1000;
|
||||
const PLACES_CACHE_MS = 5 * 60 * 1000;
|
||||
const peopleCache = { data: null, ts: 0 };
|
||||
const placesCache = { data: null, ts: 0 };
|
||||
|
||||
// Inject styles once
|
||||
const st = document.createElement('style');
|
||||
st.textContent = `
|
||||
.ls-heatmap-banner {
|
||||
margin: 0 0 16px 0; border-radius: 10px; overflow: hidden;
|
||||
background: #16213e; box-shadow: 0 4px 14px rgba(0,0,0,0.25);
|
||||
}
|
||||
.ls-heatmap-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 14px; color: #fff; font-size: 14px; font-weight: 600;
|
||||
background: rgba(0,0,0,0.25);
|
||||
}
|
||||
.ls-heatmap-header button {
|
||||
background: rgba(255,255,255,0.15); color: #fff; border: none;
|
||||
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.ls-heatmap-header button:hover { background: rgba(255,255,255,0.25); }
|
||||
.ls-heatmap-frame {
|
||||
width: 100%; height: 360px; border: none; display: block;
|
||||
}
|
||||
.ls-heatmap-banner.collapsed .ls-heatmap-frame { display: none; }
|
||||
|
||||
.ls-strip-wrap { margin: 0 0 20px 0; }
|
||||
.ls-strip-wrap h3 {
|
||||
font-size: 15px; font-weight: 600; margin: 0 0 8px 0;
|
||||
}
|
||||
.ls-strip {
|
||||
display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden;
|
||||
padding-bottom: 8px; scroll-snap-type: x proximity;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.ls-strip::-webkit-scrollbar { height: 8px; }
|
||||
.ls-strip::-webkit-scrollbar-thumb {
|
||||
background: rgba(127,127,127,0.4); border-radius: 4px;
|
||||
}
|
||||
.ls-strip-item {
|
||||
flex: 0 0 auto; text-align: center; cursor: pointer;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.ls-strip-item img {
|
||||
display: block; border-radius: 50%; object-fit: cover;
|
||||
background: #333;
|
||||
}
|
||||
.ls-strip-item.ls-place img {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ls-strip-item .ls-strip-label {
|
||||
font-size: 12px; margin-top: 6px; max-width: 100px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ls-strip-people .ls-strip-item img { width: 90px; height: 90px; }
|
||||
.ls-strip-places .ls-strip-item img { width: 140px; height: 100px; }
|
||||
.ls-strip-empty {
|
||||
font-size: 13px; opacity: 0.6; padding: 12px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(st);
|
||||
|
||||
async function fetchPeople() {
|
||||
const now = Date.now();
|
||||
if (peopleCache.data && (now - peopleCache.ts) < PEOPLE_CACHE_MS) return peopleCache.data;
|
||||
const r = await fetch('/api/people?size=1000&withHidden=false', {
|
||||
headers: getAuthHeaders(), credentials: 'include'
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
const j = await r.json();
|
||||
const people = (j.people || [])
|
||||
.filter(p => p.name) // only named people — matches Immich's Explore filter
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
peopleCache.data = people;
|
||||
peopleCache.ts = now;
|
||||
return people;
|
||||
}
|
||||
|
||||
async function fetchPlaces() {
|
||||
const now = Date.now();
|
||||
if (placesCache.data && (now - placesCache.ts) < PLACES_CACHE_MS) return placesCache.data;
|
||||
const r = await fetch('/api/search/cities', {
|
||||
headers: getAuthHeaders(), credentials: 'include'
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
const j = await r.json();
|
||||
const places = (Array.isArray(j) ? j : [])
|
||||
.filter(a => a && a.exifInfo && a.exifInfo.city)
|
||||
.sort((a, b) => a.exifInfo.city.localeCompare(b.exifInfo.city));
|
||||
placesCache.data = places;
|
||||
placesCache.ts = now;
|
||||
return places;
|
||||
}
|
||||
|
||||
function buildStrip(className, items, makeItem) {
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'ls-strip ' + className;
|
||||
if (!items.length) {
|
||||
strip.innerHTML = '<div class="ls-strip-empty">Nothing yet.</div>';
|
||||
return strip;
|
||||
}
|
||||
for (const it of items) {
|
||||
const el = makeItem(it);
|
||||
if (el) strip.appendChild(el);
|
||||
}
|
||||
return strip;
|
||||
}
|
||||
|
||||
function peopleItem(p) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ls-strip-item';
|
||||
const u = encodeURIComponent(p.updatedAt || '');
|
||||
el.innerHTML =
|
||||
`<img loading="lazy" src="/api/people/${p.id}/thumbnail?updatedAt=${u}" alt="">` +
|
||||
`<div class="ls-strip-label">${escapeHtml(p.name || '')}</div>`;
|
||||
el.addEventListener('click', () => {
|
||||
location.href = '/people/' + p.id;
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function placeItem(a) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ls-strip-item ls-place';
|
||||
el.innerHTML =
|
||||
`<img loading="lazy" src="/api/assets/${a.id}/thumbnail?size=thumbnail" alt="">` +
|
||||
`<div class="ls-strip-label">${escapeHtml(a.exifInfo.city)}</div>`;
|
||||
el.addEventListener('click', () => {
|
||||
const city = encodeURIComponent(a.exifInfo.city);
|
||||
location.href = '/search?query=' + city;
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function injectHeatmapBanner(container) {
|
||||
if (container.querySelector('.ls-heatmap-banner')) return;
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'ls-heatmap-banner';
|
||||
banner.innerHTML = `
|
||||
<div class="ls-heatmap-header">
|
||||
<span>📍 Photo Locations Heatmap</span>
|
||||
<button type="button">Collapse</button>
|
||||
</div>
|
||||
<iframe class="ls-heatmap-frame"
|
||||
src="${HEATMAP_URL}"
|
||||
referrerpolicy="no-referrer"
|
||||
allow="geolocation"></iframe>
|
||||
`;
|
||||
const btn = banner.querySelector('button');
|
||||
btn.addEventListener('click', () => {
|
||||
banner.classList.toggle('collapsed');
|
||||
btn.textContent = banner.classList.contains('collapsed') ? 'Expand' : 'Collapse';
|
||||
});
|
||||
container.insertBefore(banner, container.firstChild);
|
||||
}
|
||||
|
||||
async function enhanceSection(section, kind) {
|
||||
if (!section || section.dataset.lsEnhanced === '1') return;
|
||||
section.dataset.lsEnhanced = '1';
|
||||
|
||||
// Hide original truncated grid + "view more" link, keep the first
|
||||
// header row (contains section title). The section is div.mb-6.mt-2
|
||||
// with its first child being the title row.
|
||||
const children = Array.from(section.children);
|
||||
for (let i = 1; i < children.length; i++) {
|
||||
children[i].style.display = 'none';
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ls-strip-wrap';
|
||||
wrap.innerHTML = '<div class="ls-strip-placeholder" style="opacity:0.5;font-size:13px;">Loading…</div>';
|
||||
section.appendChild(wrap);
|
||||
|
||||
try {
|
||||
if (kind === 'people') {
|
||||
const data = await fetchPeople();
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(buildStrip('ls-strip-people', data, peopleItem));
|
||||
} else if (kind === 'places') {
|
||||
const data = await fetchPlaces();
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(buildStrip('ls-strip-places', data, placeItem));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[live-search] section enhance failed', e);
|
||||
wrap.innerHTML = '<div class="ls-strip-empty">Failed to load.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function runOnExplore() {
|
||||
if (location.pathname !== '/explore') return;
|
||||
|
||||
const peopleAnchor = document.querySelector('main a[href="/people"]');
|
||||
const placesAnchor = document.querySelector('main a[href="/places"]');
|
||||
const peopleSection = peopleAnchor && peopleAnchor.closest('div.mb-6');
|
||||
const placesSection = placesAnchor && placesAnchor.closest('div.mb-6');
|
||||
|
||||
// Heatmap banner at top of the scroll container
|
||||
const scroll = document.querySelector('main .immich-scrollbar')
|
||||
|| document.querySelector('main > div')
|
||||
|| document.querySelector('main');
|
||||
if (scroll) injectHeatmapBanner(scroll);
|
||||
|
||||
if (peopleSection) enhanceSection(peopleSection, 'people');
|
||||
if (placesSection) enhanceSection(placesSection, 'places');
|
||||
}
|
||||
|
||||
// Initial + observe SPA navigation + re-render
|
||||
runOnExplore();
|
||||
const mo = new MutationObserver(() => runOnExplore());
|
||||
mo.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Svelte route changes via pushState don't always mutate enough to trigger
|
||||
// the observer; hook history too.
|
||||
const _push = history.pushState;
|
||||
history.pushState = function () {
|
||||
const r = _push.apply(this, arguments);
|
||||
setTimeout(runOnExplore, 150);
|
||||
return r;
|
||||
};
|
||||
window.addEventListener('popstate', () => setTimeout(runOnExplore, 150));
|
||||
})();
|
||||
|
||||
// --- PWA auto-update detection ---
|
||||
// The proxy bakes a version hash into window.__LS_VERSION on each HTML
|
||||
// response. We poll /api/custom/inject-version and, if the server reports a
|
||||
|
|
|
|||
|
|
@ -140,6 +140,11 @@ const server = http.createServer((req, res) => {
|
|||
resHeaders['cache-control'] = 'no-store, no-cache, must-revalidate';
|
||||
delete resHeaders['etag'];
|
||||
delete resHeaders['last-modified'];
|
||||
// Drop CSP/XFO so we can iframe heatmap.jeffemmett.com and
|
||||
// inject arbitrary elements into pages.
|
||||
delete resHeaders['content-security-policy'];
|
||||
delete resHeaders['content-security-policy-report-only'];
|
||||
delete resHeaders['x-frame-options'];
|
||||
|
||||
res.writeHead(proxyRes.statusCode, resHeaders);
|
||||
res.end(html);
|
||||
|
|
|
|||
Loading…
Reference in New Issue