diff --git a/search-app/live-search.js b/search-app/live-search.js index 9eacb7d..fd9fb3c 100644 --- a/search-app/live-search.js +++ b/search-app/live-search.js @@ -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 = '
Nothing yet.
'; + 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 = + `` + + `
${escapeHtml(p.name || '')}
`; + 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 = + `` + + `
${escapeHtml(a.exifInfo.city)}
`; + 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 = ` +
+ 📍 Photo Locations Heatmap + +
+ + `; + 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 = '
Loading…
'; + 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 = '
Failed to load.
'; + } + } + + 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 diff --git a/search-app/server.js b/search-app/server.js index 0eb6023..7ad5c33 100644 --- a/search-app/server.js +++ b/search-app/server.js @@ -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);