Skip to content

Commit 30e7d87

Browse files
authored
Merge pull request #9 from hothsys/batch-updates
Batch Updates
2 parents 87634e4 + 3647b79 commit 30e7d87

11 files changed

Lines changed: 289 additions & 123 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,4 @@ Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/N
136136

137137
---
138138

139-
Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 (Anthropic).
139+
Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6

css/styles.css

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ input,textarea,select{font-family:var(--font)}
5555
#countries-bar .cb-flags span{cursor:default;transition:transform .1s}
5656
#countries-bar .cb-flags span:hover{transform:scale(1.25)}
5757
#countries-bar .cb-flags.collapsed{max-height:2.8em}
58-
#countries-bar .cb-toggle{background:none;border:none;color:var(--muted);font-size:.58rem;cursor:pointer;padding:2px 0 0;transition:color .15s}
58+
#countries-bar .cb-flags{transition:max-height .3s ease}
59+
#countries-bar .cb-toggle{background:none;border:none;color:var(--muted);font-size:1rem;cursor:pointer;padding:0;margin:6px 0 -4px;transition:color .15s, transform .3s ease;display:block;width:100%;text-align:center;line-height:1}
5960
#countries-bar .cb-toggle:hover{color:var(--text)}
61+
#countries-bar .cb-toggle.expanded{transform:rotate(180deg)}
6062
.stat{flex:1;padding:7px 4px;text-align:center}
6163
.stat+.stat{border-left:1px solid var(--border)}
6264
.stat .val{font-size:.9rem;font-weight:600;color:var(--text)}
@@ -91,7 +93,7 @@ input,textarea,select{font-family:var(--font)}
9193
.badge-nogps{background:rgba(122,130,156,.1);color:var(--muted)}
9294
.badge-date{background:rgba(78,201,138,.12);color:var(--green)}
9395
.no-gps-row .badge-date{background:rgba(122,130,156,.08);color:var(--muted2)}
94-
.place-label{font-size:.62rem;color:var(--muted);margin-top:6px;padding-left:5px}
96+
.place-label{font-size:.62rem;color:var(--muted);margin-top:6px}
9597
.card-actions{display:flex;gap:1px;opacity:0;transition:opacity .15s;flex-shrink:0}
9698
.photo-card:hover .card-actions{opacity:1}
9799
.card-btn{background:none;border:none;color:var(--muted);font-size:.72rem;padding:4px 5px;border-radius:5px;transition:color .15s}
@@ -145,7 +147,7 @@ input,textarea,select{font-family:var(--font)}
145147
.alb-detail-edit:hover{background:var(--surface2);color:var(--text)}
146148
.alb-detail-body{overflow-y:auto;flex:1;padding:8px 14px 12px}
147149
.alb-detail-desc{font-size:.76rem;color:var(--muted);margin-bottom:10px;line-height:1.5}
148-
.alb-detail-meta{font-size:.66rem;color:var(--muted2);margin-bottom:10px;display:flex;gap:10px;flex-wrap:wrap}
150+
.alb-detail-meta{font-size:.66rem;color:var(--muted2);margin-bottom:10px;display:flex;gap:20px;flex-wrap:wrap}
149151
.alb-detail-meta span{display:flex;align-items:center;gap:3px}
150152
.alb-add-photos-btn{display:flex;align-items:center;justify-content:center;gap:5px;padding:8px;background:var(--surface2);border:1px dashed var(--border2);border-radius:8px;font-size:.72rem;color:var(--accent);margin-bottom:10px;transition:all .15s}
151153
.alb-add-photos-btn:hover{background:var(--accent-dim);border-color:var(--accent)}
@@ -193,6 +195,9 @@ input,textarea,select{font-family:var(--font)}
193195
.tile-spinner{width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;opacity:0;transition:opacity .2s;flex-shrink:0}
194196
.tile-spinner.active{opacity:1}
195197
@keyframes spin{to{transform:rotate(360deg)}}
198+
#map-loading{position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;background:var(--bg);opacity:1;transition:opacity .4s ease}
199+
#map-loading.done{opacity:0;pointer-events:none}
200+
.map-loading-spinner{width:64px;height:64px;border:4px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
196201

197202
/* MAP STYLE DROPDOWN */
198203
.style-menu{display:none;position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow);z-index:50;overflow:hidden;min-width:120px}
@@ -240,7 +245,7 @@ input,textarea,select{font-family:var(--font)}
240245
.gallery-cell{position:relative;aspect-ratio:1;overflow:hidden;background:var(--surface2);cursor:pointer;border-radius:8px;border:2px solid transparent;transition:border-color .15s}
241246
.gallery-cell img{width:100%;height:100%;object-fit:cover;display:block;border-radius:6px}
242247
.gallery-cell:hover{border-color:var(--accent)}
243-
.gallery-rm{position:absolute;top:4px;right:4px;background:rgba(232,119,74,.88);border:none;color:#fff;border-radius:50%;width:17px;height:17px;font-size:.55rem;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s;line-height:1;cursor:pointer}
248+
.gallery-rm{position:absolute;top:4px;right:4px;background:var(--accent2);border:none;color:#fff;border-radius:50%;width:17px;height:17px;font-size:.55rem;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s;line-height:1;cursor:pointer}
244249
.gallery-cell:hover .gallery-rm{opacity:1}
245250
.gallery-empty-msg{padding:20px 12px;text-align:center;font-size:.74rem;color:var(--muted);line-height:1.6}
246251
.gallery-add-row{display:flex;align-items:center;justify-content:center;gap:6px;margin:7px 10px;padding:7px;background:var(--surface2);border:1px dashed var(--border2);border-radius:8px;font-size:.71rem;color:var(--accent);cursor:pointer;transition:all .15s;flex-shrink:0}

index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
<div id="countries-bar">
6060
<div class="cb-label">Countries Visited</div>
6161
<div class="cb-flags collapsed" id="countries-flags"></div>
62-
<button class="cb-toggle" id="countries-toggle" style="display:none" onclick="toggleCountriesBar()"></button>
62+
<button class="cb-toggle" id="countries-toggle" style="display:none" onclick="toggleCountriesBar()" aria-label="Toggle countries"></button>
6363
<div class="cb-status" id="cb-status">&nbsp;</div>
6464
</div>
6565

@@ -137,6 +137,7 @@
137137

138138
</div>
139139
<div id="map"></div>
140+
<div id="map-loading"><div class="map-loading-spinner"></div></div>
140141
<div id="esri-attribution">&copy; <a href="https://www.esri.com/" target="_blank">Esri</a></div>
141142
</div>
142143
</div>

js/data.js

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,31 @@ function updateCountriesBar() {
5656
const isOverflowing = flagsEl.scrollHeight > flagsEl.clientHeight + 2;
5757
toggle.style.display = isOverflowing || !flagsEl.classList.contains('collapsed') ? 'block' : 'none';
5858
if (toggle.style.display === 'block') {
59-
const collapsed = flagsEl.classList.contains('collapsed');
60-
toggle.textContent = collapsed ? `Show all ${sorted.length} countries` : 'Show less';
59+
toggle.classList.toggle('expanded', !flagsEl.classList.contains('collapsed'));
6160
}
6261
});
6362
}
6463

6564
function toggleCountriesBar() {
6665
const flagsEl = document.getElementById('countries-flags');
6766
const toggle = document.getElementById('countries-toggle');
68-
flagsEl.classList.toggle('collapsed');
6967
const collapsed = flagsEl.classList.contains('collapsed');
70-
const count = flagsEl.querySelectorAll('span[data-name]').length;
71-
toggle.textContent = collapsed ? `Show all ${count} countries` : 'Show less';
68+
if (collapsed) {
69+
// Expand: set max-height to actual content height for smooth animation
70+
flagsEl.style.maxHeight = flagsEl.scrollHeight + 'px';
71+
flagsEl.classList.remove('collapsed');
72+
toggle.classList.add('expanded');
73+
// After transition, remove inline max-height so it can grow if flags change
74+
setTimeout(() => { flagsEl.style.maxHeight = ''; }, 300);
75+
} else {
76+
// Collapse: set current height first, then animate to collapsed height
77+
flagsEl.style.maxHeight = flagsEl.scrollHeight + 'px';
78+
requestAnimationFrame(() => {
79+
flagsEl.classList.add('collapsed');
80+
flagsEl.style.maxHeight = '';
81+
toggle.classList.remove('expanded');
82+
});
83+
}
7284
}
7385
// Country flag hover → show name in status bar
7486
(function() {
@@ -135,13 +147,44 @@ async function _decompressGzip(blob) {
135147
// ═══════════════════════════════════════
136148
// EXPORT DATA
137149
// ═══════════════════════════════════════
138-
async function exportData() {
150+
function exportData() {
139151
document.getElementById('settings-dropdown').classList.remove('open');
140152
if (!photos.length && !albums.length) { showToast('No data to export','error'); return; }
153+
showProg(true);
154+
updProg(5, 'Preparing data...');
155+
// Defer the heavy work so the progress bar renders immediately
156+
setTimeout(() => _doExport(), 50);
157+
}
158+
async function _doExport() {
141159
const payload = { version: 1, exportedAt: Date.now(), photos, albums, geoCodeCache: {..._geoCodeCache}, geoCountryCache: {..._geoCountryCache} };
142160
const json = JSON.stringify(payload);
143-
// Compress with gzip to save disk space
144-
const blob = await _compressGzip(json);
161+
// Compress with gzip in chunks so we can report real progress
162+
const encoder = new TextEncoder();
163+
const totalBytes = json.length;
164+
const chunkSize = 256 * 1024;
165+
const cs = new CompressionStream('gzip');
166+
const writer = cs.writable.getWriter();
167+
const reader = cs.readable.getReader();
168+
const compressed = [];
169+
const readAll = (async () => {
170+
while (true) {
171+
const { done, value } = await reader.read();
172+
if (done) break;
173+
compressed.push(value);
174+
}
175+
})();
176+
let written = 0;
177+
for (let i = 0; i < totalBytes; i += chunkSize) {
178+
await writer.write(encoder.encode(json.slice(i, i + chunkSize)));
179+
written = Math.min(i + chunkSize, totalBytes);
180+
updProg(10 + Math.round(written / totalBytes * 85), `Compressing... ${Math.round(written / totalBytes * 100)}%`);
181+
}
182+
await writer.close();
183+
await readAll;
184+
const blob = new Blob(compressed);
185+
updProg(100, 'Done');
186+
await new Promise(r => setTimeout(r, 2000));
187+
showProg(false);
145188
const url = URL.createObjectURL(blob);
146189
const a = document.createElement('a');
147190
const d = new Date();
@@ -184,6 +227,11 @@ _importInput.addEventListener('change', async e => {
184227
showToast('Invalid backup file — missing photos or albums','error');
185228
return;
186229
}
230+
const totalPhotos = data.photos.length;
231+
const pinnedPhotos = data.photos.filter(p => p.lat !== null).length;
232+
const totalAlbums = data.albums.length;
233+
const summary = `Import summary:\n• ${totalPhotos} photo${totalPhotos !== 1 ? 's' : ''} (${pinnedPhotos} pinned)\n• ${totalAlbums} album${totalAlbums !== 1 ? 's' : ''}\n\nProceed with import?`;
234+
if (!confirm(summary)) return;
187235
await doImport(data);
188236
} catch(err) {
189237
showToast('Failed to read backup file','error');
@@ -375,7 +423,7 @@ async function checkAutoRestore() {
375423
if (_geoCodeCache[key]) { p.countryCode = _geoCodeCache[key]; dbPut('photos', p); ccFilled++; }
376424
}
377425
}
378-
if (ccFilled) updateCountriesBar();
426+
if (ccFilled) { updateCountriesBar(); buildClusterIndex(); }
379427
// Mark photos as already on disk so auto-save doesn't re-upload them
380428
photos.forEach(p => _savedPhotoDisk.add(p.id));
381429

@@ -480,7 +528,7 @@ async function init() {
480528
const key = `${p.lat.toFixed(4)}_${p.lng.toFixed(4)}`;
481529
if (_geoCodeCache[key]) { p.countryCode = _geoCodeCache[key]; dbPut('photos', p); filled++; }
482530
}
483-
if (filled) { updateCountriesBar(); scheduleAutoSave(); }
531+
if (filled) { updateCountriesBar(); scheduleAutoSave(); buildClusterIndex(); }
484532
}, 500);
485533
// Proactive tile caching — start after a short delay so it doesn't compete with initial load
486534
setTimeout(() => cacheMapTiles(), 10000);

js/map.js

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ function _patchStyleWater(styleObj) {
9595
styleObj.layers = styleObj.layers.filter(l => l.id !== 'natural_earth');
9696
if (styleObj.sources) delete styleObj.sources['ne2_shaded'];
9797
}
98+
// Apple Maps Dark palette — neutral dark land, distinctly blue water.
99+
// Colors pre-compensated for canvas filter brightness(1.8) contrast(0.9).
100+
if (_mapStyle === 'dark') {
101+
for (const layer of styleObj.layers) {
102+
if (!layer.paint) layer.paint = {};
103+
if (layer.type === 'fill' && /^water/.test(layer.id)) {
104+
layer.paint['fill-color'] = '#131619';
105+
}
106+
}
107+
}
98108
const color = _mapStyle === 'dark' ? '#6a9fd8' : '#2c5f8a';
99109
let hasPointLabel = false;
100110
for (const layer of styleObj.layers) {
@@ -108,10 +118,15 @@ function _patchStyleWater(styleObj) {
108118
}
109119
// Hide major city labels at zoom 10+ so they don't mislead when right-clicking returns a sub-area
110120
const cityLayers = ['place_city', 'place_city_large', 'label_city', 'label_city_capital'];
121+
// Show state/province labels only at zoom 2.5+
122+
const stateLayers = ['place_state', 'label_state'];
111123
for (const layer of styleObj.layers) {
112124
if (cityLayers.includes(layer.id)) {
113125
layer.maxzoom = 10;
114126
}
127+
if (stateLayers.includes(layer.id)) {
128+
layer.minzoom = 2.5;
129+
}
115130
}
116131

117132
// Dark style lacks a point-based water label layer — add one for ocean names
@@ -331,7 +346,10 @@ async function _doStyleSwap(style) {
331346
styleObj = JSON.parse(JSON.stringify(_styleJsonCache[style]));
332347
} else {
333348
try {
334-
const r = await fetch(style);
349+
const ac = new AbortController();
350+
const timer = setTimeout(() => ac.abort(), 5000);
351+
const r = await fetch(style, { signal: ac.signal });
352+
clearTimeout(timer);
335353
const json = await r.json();
336354
_styleJsonCache[style] = json;
337355
styleObj = JSON.parse(JSON.stringify(json));
@@ -369,6 +387,8 @@ async function _doStyleSwap(style) {
369387
} else {
370388
buildClusterIndex();
371389
}
390+
// Update dark-map CSS class after pin icons are re-added with correct compensation
391+
document.getElementById('map')?.classList.toggle('dark-map', _mapStyle === 'dark');
372392
};
373393
map.once('styledata', () => setTimeout(restore, 100));
374394
setTimeout(restore, 600);
@@ -382,7 +402,10 @@ async function initMap() {
382402
const styleUrl = _styleUrl();
383403
let initStyle;
384404
try {
385-
const r = await fetch(styleUrl);
405+
const ac = new AbortController();
406+
const timer = setTimeout(() => ac.abort(), 5000);
407+
const r = await fetch(styleUrl, { signal: ac.signal });
408+
clearTimeout(timer);
386409
const json = await r.json();
387410
_styleJsonCache[styleUrl] = json; // seed cache so first style switch is instant
388411
initStyle = JSON.parse(JSON.stringify(json));
@@ -394,6 +417,24 @@ async function initMap() {
394417
fetch(otherUrl).then(r => r.json()).then(j => { _styleJsonCache[otherUrl] = j; }).catch(() => {});
395418
map = new maplibregl.Map({ container:'map', style: initStyle, center:[0,20], zoom:1.8, attributionControl:false, preserveDrawingBuffer:true });
396419
map.addControl(new maplibregl.NavigationControl({showCompass:false}), 'bottom-right');
420+
// Recover from WebGL context loss (Safari loses context after sleep or memory pressure)
421+
const canvas = map.getCanvas();
422+
canvas.addEventListener('webglcontextlost', (e) => {
423+
e.preventDefault(); // allow context to be restored
424+
console.warn('WebGL context lost — waiting for restore');
425+
});
426+
canvas.addEventListener('webglcontextrestored', () => {
427+
console.log('WebGL context restored — reinitializing map');
428+
map.triggerRepaint();
429+
});
430+
// Safety net: if map is still blank after 8 seconds, show a reload prompt
431+
setTimeout(() => {
432+
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
433+
if (!gl || gl.isContextLost()) {
434+
console.warn('Map canvas has no WebGL context — prompting reload');
435+
showToast('Map failed to load — please refresh the page', 'error');
436+
}
437+
}, 8000);
397438
// Provide a transparent 1x1 placeholder for any missing sprite images (e.g. POI icons)
398439
map.on('styleimagemissing', (e) => {
399440
if (!map.hasImage(e.id)) {
@@ -439,13 +480,18 @@ async function initMap() {
439480
// Tile loading spinner
440481
const tileSpinner = document.getElementById('tile-spinner');
441482
map.on('dataloading', () => { tileSpinner?.classList.add('active'); });
442-
map.on('idle', () => { tileSpinner?.classList.remove('active'); });
483+
map.on('idle', () => {
484+
tileSpinner?.classList.remove('active');
485+
const mapLoading = document.getElementById('map-loading');
486+
if (mapLoading) { mapLoading.classList.add('done'); setTimeout(() => mapLoading.remove(), 400); }
487+
});
443488
});
444489
map.on('movestart', () => { _mapBusy = true; });
445490
map.on('moveend', () => { _mapBusy = false; });
446491

447492
// Right-click on map to pin a location
448493
map.on('contextmenu', async (e) => {
494+
try {
449495
e.preventDefault();
450496
// Detect water vs land early — water clicks are allowed at any zoom,
451497
// land clicks require zoom >= 7 for meaningful Nominatim results.
@@ -513,6 +559,7 @@ async function initMap() {
513559
popup.on('close', () => { if (destMarkerObj) { destMarkerObj.marker.remove(); destMarkerObj = null; } });
514560

515561
destMarkerObj = { marker, popup };
562+
} catch(err) { console.error('[right-click] ERROR in handler:', err); }
516563
});
517564

518565
// Window-level capture handler — fires before anything else can intercept.
@@ -783,13 +830,15 @@ function toggleStyleMenu(e) {
783830
}
784831

785832
function setMapStyle(mode) {
833+
const wasDark = _mapStyle === 'dark';
786834
_mapStyle = mode;
787835
// Persist style preference (satellite resets to previous on reload)
788836
if (mode !== 'satellite') localStorage.setItem('matrix-theme', mode);
789837

790-
// Update CSS classes
838+
// Defer dark-map CSS class removal until pin icons are re-added with correct
839+
// compensation (otherwise pre-darkened images render without the CSS filter)
791840
const mapEl = document.getElementById('map');
792-
mapEl.classList.toggle('dark-map', _mapStyle === 'dark');
841+
if (_mapStyle === 'dark') mapEl.classList.add('dark-map');
793842
mapEl.classList.toggle('sat-mode', _mapStyle === 'satellite');
794843

795844
// Labels toggle visibility

js/modals.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,13 @@ async function savePhotoMeta() {
185185
async function saveNewAlbum() {
186186
const name = v('alb-name').trim();
187187
if (!name) { showToast('Please enter an album name','error'); return; }
188+
const startDate = getDatePickerValue('alb-start-date');
189+
const endDate = getDatePickerValue('alb-end-date');
190+
if (startDate && endDate && startDate > endDate) { showToast('Start date cannot be after end date','error'); return; }
188191
const album = {
189192
id: `a_${Date.now()}_${Math.random().toString(36).slice(2)}`,
190193
name, description: v('alb-desc').trim(),
191-
startDate: getDatePickerValue('alb-start-date'),
192-
endDate: getDatePickerValue('alb-end-date'),
194+
startDate, endDate,
193195
coverPhotoId: null, photoIds: [], createdAt: Date.now()
194196
};
195197
albums.push(album);
@@ -207,10 +209,13 @@ async function saveEditAlbum() {
207209
if (!album) return;
208210
const name = v('alb-name').trim();
209211
if (!name) { showToast('Album name is required','error'); return; }
212+
const startDate = getDatePickerValue('alb-start-date');
213+
const endDate = getDatePickerValue('alb-end-date');
214+
if (startDate && endDate && startDate > endDate) { showToast('Start date cannot be after end date','error'); return; }
210215
album.name = name;
211216
album.description = v('alb-desc').trim();
212-
album.startDate = getDatePickerValue('alb-start-date');
213-
album.endDate = getDatePickerValue('alb-end-date');
217+
album.startDate = startDate;
218+
album.endDate = endDate;
214219
await dbPut('albums', album);
215220
closeMetaModal();
216221
renderAlbumDetail(album.id);

0 commit comments

Comments
 (0)