1176 lines
42 KiB
JavaScript
1176 lines
42 KiB
JavaScript
(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 = `
|
|
<div class="ls-header">
|
|
<div class="ls-status"></div>
|
|
<div class="ls-controls">
|
|
<button class="ls-toggle" data-view="grid" title="Grid view">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
|
</button>
|
|
<button class="ls-toggle" data-view="map" title="Map view">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21c-4-4-8-7.5-8-11a8 8 0 1 1 16 0c0 3.5-4 7-8 11z"/><circle cx="12" cy="10" r="3"/></svg>
|
|
</button>
|
|
<button class="ls-close" title="Close">×</button>
|
|
</div>
|
|
</div>
|
|
<div class="ls-body">
|
|
<div class="ls-grid"></div>
|
|
<div class="ls-map-wrap" style="display:none"><div class="ls-map" id="ls-map"></div></div>
|
|
</div>
|
|
`;
|
|
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 = '<span class="ls-spinner"></span> 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 = '<div class="ls-map-loading">No location data available for these results.<br>Try a location-specific search like a city name.</div>';
|
|
return;
|
|
}
|
|
|
|
// Lazy-load Leaflet
|
|
if (!leafletReady) {
|
|
mapEl.innerHTML = '<div class="ls-map-loading"><span class="ls-spinner"></span> Loading map...</div>';
|
|
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 = `
|
|
<label class="ls-place-label">Location search</label>
|
|
<div class="ls-place-row">
|
|
<input type="search" class="ls-place-input"
|
|
placeholder="Search a place (city, country, landmark)…"
|
|
autocomplete="off" />
|
|
<button type="button" class="ls-place-go">Map it</button>
|
|
</div>
|
|
<div class="ls-place-hint">Results open on a map — pan and zoom to explore nearby spots.</div>
|
|
`;
|
|
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 = '<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]));
|
|
}
|
|
|
|
// 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 = `
|
|
<div class="ls-heatmap-header">
|
|
<span>📍 Photo Locations Heatmap</span>
|
|
<div style="display:flex;gap:6px;">
|
|
<button type="button" data-act="reset" title="Regenerate API key">Reset</button>
|
|
<button type="button" data-act="toggle">Collapse</button>
|
|
</div>
|
|
</div>
|
|
<iframe class="ls-heatmap-frame"
|
|
referrerpolicy="no-referrer"
|
|
allow="geolocation"></iframe>
|
|
`;
|
|
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 = '<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
|
|
// 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 =
|
|
'<span>New version available</span>' +
|
|
'<button id="ls-update-btn">Refresh</button>';
|
|
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);
|
|
})();
|
|
})();
|