(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-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') {
gridEl.style.display = '';
mapWrap.style.display = 'none';
bodyEl.style.overflowY = 'auto';
renderGridItems(currentItems);
} else {
gridEl.style.display = 'none';
mapWrap.style.display = '';
bodyEl.style.overflowY = 'hidden';
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) {
if (abortCtrl) abortCtrl.abort();
abortCtrl = new AbortController();
currentQuery = query;
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);
// 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();
// 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);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// 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');
})();