(function () { 'use strict'; // --- Config --- const DEBOUNCE_MS = 300; const MIN_CHARS = 2; const PAGE_SIZE = 50; const LEAFLET_CSS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; const LEAFLET_JS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; const LEAFLET_HEAT_JS = 'https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js'; // --- State --- let panel = null; let debounceTimer = null; let abortCtrl = null; let currentQuery = ''; let currentItems = []; let currentView = 'grid'; // 'grid' or 'map' let mapInstance = null; let mapMarkers = null; let heatLayer = null; let leafletReady = false; let leafletLoading = false; let hookedInputs = new WeakSet(); // --- Auth --- function getAuthHeaders() { const headers = { 'Content-Type': 'application/json' }; try { const raw = localStorage.getItem('auth') || localStorage.getItem('immich-auth') || sessionStorage.getItem('auth'); if (raw) { const parsed = JSON.parse(raw); const token = parsed.accessToken || parsed.token || parsed.userToken; if (token) headers['Authorization'] = 'Bearer ' + token; } } catch {} return headers; } // --- Leaflet lazy loader --- function loadLeaflet() { if (leafletReady) return Promise.resolve(); if (leafletLoading) { return new Promise(resolve => { const check = setInterval(() => { if (leafletReady) { clearInterval(check); resolve(); } }, 100); }); } leafletLoading = true; return new Promise((resolve, reject) => { // Load CSS const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = LEAFLET_CSS; document.head.appendChild(link); // Load JS const script = document.createElement('script'); script.src = LEAFLET_JS; script.onload = () => { // Load heat plugin after Leaflet const heat = document.createElement('script'); heat.src = LEAFLET_HEAT_JS; heat.onload = () => { leafletReady = true; resolve(); }; heat.onerror = reject; document.head.appendChild(heat); }; script.onerror = reject; document.head.appendChild(script); }); } // --- Create the results panel --- function getOrCreatePanel() { if (panel) return panel; panel = document.createElement('div'); panel.id = 'live-search-panel'; panel.innerHTML = `
`; document.body.appendChild(panel); const style = document.createElement('style'); style.textContent = ` #live-search-panel { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 99999; background: rgba(0,0,0,0.88); backdrop-filter: blur(6px); display: none; flex-direction: column; } #live-search-panel.active { display: flex; } #live-search-panel .ls-header { display: flex; justify-content: space-between; align-items: center; padding: 72px 20px 8px; flex-shrink: 0; max-width: 1440px; width: 100%; margin: 0 auto; } #live-search-panel .ls-status { color: #aaa; font-size: 14px; min-height: 20px; } #live-search-panel .ls-controls { display: flex; gap: 6px; align-items: center; } #live-search-panel .ls-toggle, #live-search-panel .ls-close { background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #ccc; border-radius: 6px; padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s; } #live-search-panel .ls-toggle:hover, #live-search-panel .ls-close:hover { background: rgba(255,255,255,0.2); } #live-search-panel .ls-toggle.active { background: #e94560; border-color: #e94560; color: #fff; } #live-search-panel .ls-close { font-size: 20px; padding: 4px 10px; } #live-search-panel .ls-body { flex: 1; overflow-y: auto; padding: 0 20px 20px; max-width: 1440px; width: 100%; margin: 0 auto; } #live-search-panel .ls-body.ls-split { display: flex; flex-direction: column; gap: 12px; overflow: hidden; } #live-search-panel .ls-body.ls-split > .ls-map-wrap { flex: 1 1 50%; width: 100%; min-height: 0; } #live-search-panel .ls-body.ls-split > .ls-grid { flex: 1 1 50%; overflow-y: auto; align-content: start; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); } @media (max-width: 768px) { #live-search-panel .ls-body.ls-split > .ls-grid { grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); } } #live-search-panel .ls-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 6px; } #live-search-panel .ls-card { position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden; cursor: pointer; background: #222; transition: transform 0.12s; } #live-search-panel .ls-card:hover { transform: scale(1.04); z-index: 2; } #live-search-panel .ls-card img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.25s; } #live-search-panel .ls-card img.loaded { opacity: 1; } #live-search-panel .ls-card .ls-badge { position: absolute; top: 6px; right: 6px; background: rgba(0,0,0,0.7); color: #fff; font-size: 10px; padding: 2px 5px; border-radius: 3px; } #live-search-panel .ls-map-wrap { height: 100%; min-height: 500px; border-radius: 8px; overflow: hidden; } #live-search-panel .ls-map { height: 100%; width: 100%; } #live-search-panel .ls-map-wrap .ls-map-loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #aaa; font-size: 15px; } /* Map popup thumbnail grid */ .ls-popup-grid { display: grid; grid-template-columns: repeat(3, 60px); gap: 3px; max-height: 200px; overflow-y: auto; } .ls-popup-grid img { width: 60px; height: 60px; object-fit: cover; border-radius: 3px; cursor: pointer; } .ls-popup-grid img:hover { opacity: 0.8; } .ls-popup-title { font-weight: 600; margin-bottom: 6px; font-size: 13px; } #live-search-panel .ls-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #666; border-top-color: #e94560; border-radius: 50%; animation: lsSpin 0.5s linear infinite; vertical-align: middle; margin-right: 6px; } @keyframes lsSpin { to { transform: rotate(360deg); } } @media (max-width: 600px) { #live-search-panel .ls-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } #live-search-panel .ls-header { padding: 64px 10px 8px; } #live-search-panel .ls-body { padding: 0 10px 10px; } } `; document.head.appendChild(style); // Event listeners panel.querySelector('.ls-close').addEventListener('click', hidePanel); panel.querySelectorAll('.ls-toggle').forEach(btn => { btn.addEventListener('click', () => switchView(btn.dataset.view)); }); panel.addEventListener('click', (e) => { if (e.target === panel) hidePanel(); }); updateToggleButtons(); return panel; } function showPanel() { getOrCreatePanel().classList.add('active'); } function hidePanel() { if (panel) { panel.classList.remove('active'); panel.querySelector('.ls-grid').innerHTML = ''; panel.querySelector('.ls-status').textContent = ''; } currentQuery = ''; currentItems = []; } function switchView(view) { currentView = view; updateToggleButtons(); const gridEl = panel.querySelector('.ls-grid'); const mapWrap = panel.querySelector('.ls-map-wrap'); const bodyEl = panel.querySelector('.ls-body'); if (view === 'grid') { bodyEl.classList.remove('ls-split'); gridEl.style.display = ''; mapWrap.style.display = 'none'; bodyEl.style.overflowY = 'auto'; renderGridItems(currentItems); } else { // Map view = 50/50 split: map on one side, bounds-filtered photos on the other bodyEl.classList.add('ls-split'); gridEl.style.display = ''; mapWrap.style.display = ''; bodyEl.style.overflowY = ''; showMap(currentItems); } } function updateToggleButtons() { if (!panel) return; panel.querySelectorAll('.ls-toggle').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === currentView); }); } // --- Search --- async function doSearch(query, opts) { if (abortCtrl) abortCtrl.abort(); abortCtrl = new AbortController(); currentQuery = query; if (opts && opts.forceMap) { currentView = 'map'; updateToggleButtons(); const gridEl = panel ? panel.querySelector('.ls-grid') : null; const mapWrap = panel ? panel.querySelector('.ls-map-wrap') : null; const bodyEl = panel ? panel.querySelector('.ls-body') : null; if (gridEl && mapWrap && bodyEl) { bodyEl.classList.add('ls-split'); gridEl.style.display = ''; mapWrap.style.display = ''; bodyEl.style.overflowY = ''; } } const statusEl = getOrCreatePanel().querySelector('.ls-status'); const gridEl = getOrCreatePanel().querySelector('.ls-grid'); gridEl.innerHTML = ''; statusEl.innerHTML = ' Searching...'; showPanel(); try { const headers = getAuthHeaders(); // Run CLIP smart search and metadata location search in parallel const [smartResp, metaResp] = await Promise.all([ fetch('/api/search/smart', { method: 'POST', headers, credentials: 'include', body: JSON.stringify({ query, size: PAGE_SIZE }), signal: abortCtrl.signal }), fetch('/api/search/metadata', { method: 'POST', headers, credentials: 'include', body: JSON.stringify({ query, size: PAGE_SIZE, withExif: true }), signal: abortCtrl.signal }).catch(() => null) // metadata search is optional ]); if (!smartResp.ok) { statusEl.textContent = 'Search error (' + smartResp.status + ')'; return; } const smartData = await smartResp.json(); let items = smartData.assets?.items || []; // Merge metadata results (prioritize unique assets with GPS) if (metaResp && metaResp.ok) { const metaData = await metaResp.json(); const metaItems = metaData.assets?.items || []; const seenIds = new Set(items.map(i => i.id)); for (const mi of metaItems) { if (!seenIds.has(mi.id)) { items.push(mi); seenIds.add(mi.id); } } } currentItems = items; if (items.length === 0) { statusEl.textContent = 'No results for "' + query + '"'; return; } // Count items with GPS for status const withGps = items.filter(i => i.exifInfo?.latitude).length; let statusText = items.length + ' result' + (items.length !== 1 ? 's' : ''); if (withGps > 0) statusText += ' (' + withGps + ' with location)'; statusEl.textContent = statusText; if (currentView === 'grid') { renderGridItems(items); } else { showMap(items); } // Background: enrich smart search results with GPS data enrichWithGps(items); } catch (e) { if (e.name !== 'AbortError') { statusEl.textContent = 'Search failed'; console.error('[live-search]', e); } } } // Fetch GPS data for items that don't have exifInfo yet async function enrichWithGps(items) { const needGps = items.filter(i => !i.exifInfo).map(i => i.id); if (needGps.length === 0) return; try { const headers = getAuthHeaders(); const resp = await fetch('/api/custom/batch-exif', { method: 'POST', headers, credentials: 'include', body: JSON.stringify({ ids: needGps }) }); if (!resp.ok) return; const gpsData = await resp.json(); // Merge GPS data back into items const gpsMap = new Map(gpsData.map(g => [g.id, g])); let updated = false; for (const item of items) { if (!item.exifInfo && gpsMap.has(item.id)) { const g = gpsMap.get(item.id); item.exifInfo = { latitude: g.latitude, longitude: g.longitude, city: g.city, state: g.state, country: g.country }; if (g.latitude) updated = true; } } if (updated) { // Update status with new GPS count const withGps = items.filter(i => i.exifInfo?.latitude).length; const statusEl = panel.querySelector('.ls-status'); let statusText = items.length + ' result' + (items.length !== 1 ? 's' : ''); if (withGps > 0) statusText += ' (' + withGps + ' with location)'; statusEl.textContent = statusText; // If map is showing, refresh it if (currentView === 'map') showMap(items); } } catch (e) { console.warn('[live-search] GPS enrichment failed:', e); } } function renderGridItems(items) { const grid = panel.querySelector('.ls-grid'); grid.innerHTML = ''; for (const item of items) { const card = document.createElement('div'); card.className = 'ls-card'; const img = document.createElement('img'); img.loading = 'lazy'; img.src = '/api/assets/' + item.id + '/thumbnail?size=thumbnail'; img.alt = item.originalFileName || ''; img.onload = () => img.classList.add('loaded'); card.appendChild(img); if (item.type === 'VIDEO') { const badge = document.createElement('span'); badge.className = 'ls-badge'; badge.textContent = 'VIDEO'; card.appendChild(badge); } // Show location badge if available const city = item.exifInfo?.city; if (city) { const locBadge = document.createElement('span'); locBadge.className = 'ls-badge'; locBadge.style.cssText = 'top:auto;bottom:6px;left:6px;right:auto;max-width:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; locBadge.textContent = city; card.appendChild(locBadge); } card.addEventListener('click', () => { hidePanel(); window.location.href = '/photos/' + item.id; }); grid.appendChild(card); } } // --- Map View --- async function showMap(items) { const mapWrap = panel.querySelector('.ls-map-wrap'); const mapEl = panel.querySelector('.ls-map'); // Filter items with GPS const geoItems = items.filter(i => i.exifInfo?.latitude && i.exifInfo?.longitude); if (geoItems.length === 0) { mapEl.innerHTML = '
No location data available for these results.
Try a location-specific search like a city name.
'; return; } // Lazy-load Leaflet if (!leafletReady) { mapEl.innerHTML = '
Loading map...
'; await loadLeaflet(); } // Clear previous map if (mapInstance) { mapInstance.remove(); mapInstance = null; } mapEl.innerHTML = ''; // Create map const L = window.L; mapInstance = L.map(mapEl, { zoomControl: true }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }).addTo(mapInstance); // Group photos by location (cluster nearby within ~100m) const clusters = clusterByLocation(geoItems, 0.001); // Add markers const markers = []; for (const cluster of clusters) { const lat = cluster.center[0]; const lng = cluster.center[1]; const photos = cluster.items; const label = photos[0].exifInfo?.city || photos[0].exifInfo?.country || ''; const count = photos.length; const marker = L.circleMarker([lat, lng], { radius: Math.min(8 + Math.sqrt(count) * 3, 30), fillColor: '#e94560', fillOpacity: 0.8, color: '#fff', weight: 2 }).addTo(mapInstance); // Build popup const popupContent = document.createElement('div'); const title = document.createElement('div'); title.className = 'ls-popup-title'; title.textContent = (label ? label + ' — ' : '') + count + ' photo' + (count > 1 ? 's' : ''); popupContent.appendChild(title); const thumbGrid = document.createElement('div'); thumbGrid.className = 'ls-popup-grid'; for (const photo of photos.slice(0, 9)) { const img = document.createElement('img'); img.src = '/api/assets/' + photo.id + '/thumbnail?size=thumbnail'; img.alt = photo.originalFileName || ''; img.addEventListener('click', () => { hidePanel(); window.location.href = '/photos/' + photo.id; }); thumbGrid.appendChild(img); } if (count > 9) { const more = document.createElement('div'); more.style.cssText = 'grid-column: 1/-1; text-align:center; font-size:12px; color:#666; padding:4px;'; more.textContent = '+ ' + (count - 9) + ' more'; thumbGrid.appendChild(more); } popupContent.appendChild(thumbGrid); marker.bindPopup(popupContent, { maxWidth: 220, minWidth: 200 }); // Show count label if (count > 1) { marker.bindTooltip(String(count), { permanent: true, direction: 'center', className: 'ls-marker-label' }); } markers.push(marker); } // Add heatmap layer const heatData = geoItems.map(i => [i.exifInfo.latitude, i.exifInfo.longitude, 0.5]); if (heatData.length > 3) { heatLayer = L.heatLayer(heatData, { radius: 25, blur: 15, maxZoom: 15, gradient: { 0: 'blue', 0.25: 'cyan', 0.5: 'lime', 0.75: 'yellow', 1: 'red' } }).addTo(mapInstance); } // Fit bounds const bounds = L.latLngBounds(geoItems.map(i => [i.exifInfo.latitude, i.exifInfo.longitude])); mapInstance.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }); // Fix map sizing (Leaflet needs this after dynamic show) setTimeout(() => mapInstance.invalidateSize(), 100); // Bounds-filtered grid below the map. Updates live as the user // pans/zooms so "photos taken on that part of the map" stay in sync // with the visible viewport. Sorted by distance from map center so the // closest-to-middle photos appear first. const updateGridFromBounds = () => { if (currentView !== 'map') return; const b = mapInstance.getBounds(); const center = mapInstance.getCenter(); const visible = geoItems.filter(i => b.contains([i.exifInfo.latitude, i.exifInfo.longitude]) ); visible.sort((a, b1) => { const da = center.distanceTo([a.exifInfo.latitude, a.exifInfo.longitude]); const db = center.distanceTo([b1.exifInfo.latitude, b1.exifInfo.longitude]); return da - db; }); renderGridItems(visible); const statusEl = panel.querySelector('.ls-status'); if (statusEl) { const totalGeo = geoItems.length; statusEl.textContent = visible.length + ' of ' + totalGeo + ' in view (closest to center first)'; } }; mapInstance.on('moveend', updateGridFromBounds); mapInstance.on('zoomend', updateGridFromBounds); // Initial population once bounds settle setTimeout(updateGridFromBounds, 150); // Add custom label style if (!document.getElementById('ls-marker-styles')) { const s = document.createElement('style'); s.id = 'ls-marker-styles'; s.textContent = ` .ls-marker-label { background: rgba(0,0,0,0.7) !important; border: none !important; color: #fff !important; font-size: 11px !important; font-weight: 600 !important; padding: 2px 5px !important; border-radius: 10px !important; box-shadow: none !important; } .ls-marker-label::before { display: none !important; } `; document.head.appendChild(s); } } // Cluster nearby photos by GPS proximity function clusterByLocation(items, threshold) { const clusters = []; const used = new Set(); for (let i = 0; i < items.length; i++) { if (used.has(i)) continue; const a = items[i]; const cluster = [a]; used.add(i); for (let j = i + 1; j < items.length; j++) { if (used.has(j)) continue; const b = items[j]; if (Math.abs(a.exifInfo.latitude - b.exifInfo.latitude) < threshold && Math.abs(a.exifInfo.longitude - b.exifInfo.longitude) < threshold) { cluster.push(b); used.add(j); } } const lat = cluster.reduce((s, p) => s + p.exifInfo.latitude, 0) / cluster.length; const lng = cluster.reduce((s, p) => s + p.exifInfo.longitude, 0) / cluster.length; clusters.push({ center: [lat, lng], items: cluster }); } return clusters; } // --- Hook into search inputs --- function hookInput(input) { if (hookedInputs.has(input)) return; hookedInputs.add(input); input.addEventListener('input', () => { clearTimeout(debounceTimer); const q = input.value.trim(); if (q.length < MIN_CHARS) { hidePanel(); return; } debounceTimer = setTimeout(() => doSearch(q), DEBOUNCE_MS); }); input.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel?.classList.contains('active')) { e.stopPropagation(); hidePanel(); } }); } function isSearchInput(el) { if (el.tagName !== 'INPUT') return false; if (el.type === 'search') return true; const ph = (el.placeholder || '').toLowerCase(); if (ph.includes('search')) return true; if (el.getAttribute('role') === 'searchbox') return true; const parent = el.closest('[data-testid*="search"], [class*="search-bar"], [id*="search"]'); if (parent) return true; const aria = (el.getAttribute('aria-label') || '').toLowerCase(); if (aria.includes('search')) return true; return false; } function scanForSearchInputs(root) { const inputs = (root || document).querySelectorAll('input'); for (const input of inputs) { if (isSearchInput(input)) hookInput(input); } } // Initial scan scanForSearchInputs(); hijackLocationSection(); // Watch for dynamically added inputs (SPA navigation) const observer = new MutationObserver((mutations) => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType !== 1) continue; if (node.tagName === 'INPUT' && isSearchInput(node)) { hookInput(node); } else if (node.querySelectorAll) { scanForSearchInputs(node); } } } // Always re-check for the Place section (modal mounts/unmounts) hijackLocationSection(); }); observer.observe(document.body, { childList: true, subtree: true }); // --- Replace the search filter modal's Place section (Country/State/City // dropdowns) with a single text input that opens the live-search overlay // directly in map view. function hijackLocationSection() { const section = document.getElementById('location-selection'); if (!section || section.dataset.lsHijacked === '1') return; section.dataset.lsHijacked = '1'; // Hide every original child (heading + combobox grid) for (const child of Array.from(section.children)) { child.style.display = 'none'; } const wrap = document.createElement('div'); wrap.className = 'ls-place-hijack'; wrap.innerHTML = `
Results open on a map — pan and zoom to explore nearby spots.
`; section.appendChild(wrap); if (!document.getElementById('ls-place-styles')) { const s = document.createElement('style'); s.id = 'ls-place-styles'; s.textContent = ` .ls-place-hijack { display: flex; flex-direction: column; gap: 6px; } .ls-place-label { font-weight: 600; font-size: 14px; } .ls-place-row { display: flex; gap: 8px; } .ls-place-input { flex: 1; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--immich-ui-gray-400, #bbb); background: var(--immich-bg, transparent); color: inherit; font-size: 14px; } .ls-place-input:focus { outline: 2px solid #e94560; outline-offset: 1px; } .ls-place-go { padding: 0 14px; border-radius: 8px; border: 1px solid #e94560; background: #e94560; color: #fff; font-weight: 600; cursor: pointer; } .ls-place-go:hover { filter: brightness(1.1); } .ls-place-hint { font-size: 12px; opacity: 0.7; } `; document.head.appendChild(s); } const input = wrap.querySelector('.ls-place-input'); const btn = wrap.querySelector('.ls-place-go'); const go = () => { const q = input.value.trim(); if (q.length < MIN_CHARS) return; doSearch(q, { forceMap: true }); }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); go(); } }); btn.addEventListener('click', (e) => { e.preventDefault(); go(); }); // Autofocus once visible setTimeout(() => { try { input.focus(); } catch {} }, 50); } // Global Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel?.classList.contains('active')) { hidePanel(); } }); 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])); } // Get (or create) an Immich API key for the heatmap iframe so the user // isn't prompted to paste one. Cached in this origin's localStorage. // Uses the ['all'] permission so the heatmap's /api/search/metadata and // thumbnail fetches never 403. async function getHeatmapApiKey(force) { if (!force) { const cached = localStorage.getItem('ls-heatmap-api-key'); if (cached) return cached; } try { const r = await fetch('/api/api-keys', { method: 'POST', headers: getAuthHeaders(), credentials: 'include', body: JSON.stringify({ name: 'Heatmap iframe (auto)', permissions: ['all'] }) }); if (!r.ok) { console.warn('[live-search] api-key create failed', r.status, await r.text().catch(() => '')); return null; } const j = await r.json(); const secret = j.secret || (j.apiKey && j.apiKey.secret); if (secret) { localStorage.setItem('ls-heatmap-api-key', secret); return secret; } } catch (e) { console.warn('[live-search] api-key create error', e); } return null; } async 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 iframe = banner.querySelector('iframe'); const toggleBtn = banner.querySelector('button[data-act="toggle"]'); const resetBtn = banner.querySelector('button[data-act="reset"]'); toggleBtn.addEventListener('click', () => { banner.classList.toggle('collapsed'); toggleBtn.textContent = banner.classList.contains('collapsed') ? 'Expand' : 'Collapse'; }); resetBtn.addEventListener('click', async () => { localStorage.removeItem('ls-heatmap-api-key'); resetBtn.textContent = 'Resetting…'; const k = await getHeatmapApiKey(true); iframe.src = HEATMAP_URL + (k ? '?apiKey=' + encodeURIComponent(k) + '&t=' + Date.now() : ''); resetBtn.textContent = 'Reset'; }); // Append at the END so it lands below People + Places sections. container.appendChild(banner); // Resolve API key then set iframe src. The heatmap app reads ?apiKey // and seeds its own localStorage, skipping the login prompt. const key = await getHeatmapApiKey(); iframe.src = HEATMAP_URL + (key ? '?apiKey=' + encodeURIComponent(key) : ''); } 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 // newer hash, we nuke caches + unregister the service worker + reload so the // PWA picks up the new HTML (with the new inline script). (function setupAutoUpdate() { const myVersion = window.__LS_VERSION; if (!myVersion) return; // older HTML without version — skip console.log('[live-search] version:', myVersion); let updating = false; async function forceUpdate() { if (updating) return; updating = true; try { if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map(r => r.unregister().catch(() => null))); } if (window.caches) { const keys = await caches.keys(); await Promise.all(keys.map(k => caches.delete(k).catch(() => null))); } } catch (e) { console.warn('[live-search] cache clear failed', e); } location.reload(); } function showBanner() { if (document.getElementById('ls-update-banner')) return; const b = document.createElement('div'); b.id = 'ls-update-banner'; b.innerHTML = 'New version available' + ''; b.style.cssText = [ 'position:fixed', 'bottom:16px', 'left:50%', 'transform:translateX(-50%)', 'z-index:100000', 'background:#e94560', 'color:#fff', 'padding:10px 14px', 'border-radius:8px', 'box-shadow:0 6px 20px rgba(0,0,0,0.4)', 'display:flex', 'gap:10px', 'align-items:center', 'font-size:14px', 'font-family:system-ui,sans-serif' ].join(';'); b.querySelector('#ls-update-btn').style.cssText = 'background:#fff;color:#e94560;border:none;border-radius:6px;' + 'padding:5px 10px;font-weight:600;cursor:pointer;'; b.querySelector('#ls-update-btn').addEventListener('click', forceUpdate); document.body.appendChild(b); // Auto-apply after 8s if user ignores banner setTimeout(() => { if (document.body.contains(b)) forceUpdate(); }, 8000); } async function checkVersion() { try { const r = await fetch('/api/custom/inject-version', { cache: 'no-store' }); if (!r.ok) return; const { version } = await r.json(); if (version && version !== myVersion) { console.log('[live-search] new version detected:', version); showBanner(); } } catch {} } // Check on load, on visibility change, and every 2 minutes checkVersion(); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkVersion(); }); setInterval(checkVersion, 2 * 60 * 1000); })(); })();