Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(viewer): Guard against async race conditions in JS

- Prevent concurrent fetchFilters calls from stacking via in-flight guard
- Fix metadataLoading stuck true when server loads fast (no poll needed)
- Clean up loading state when discarding stale generation responses
- Discard stale lightbox fetches on rapid arrow key navigation

+46 -20
+46 -20
Sources/AtticCLI/Resources/viewer.html
··· 478 478 lightboxIndex: -1, 479 479 metadataLoading: true, 480 480 pollTimer: null, 481 + generation: 0, 481 482 }; 482 483 483 484 const grid = document.getElementById('grid'); ··· 500 501 return params; 501 502 } 502 503 504 + let filterFetchInFlight = false; 505 + 503 506 async function fetchFilters({ reload = false } = {}) { 504 - const params = buildFilterParams(); 505 - const res = await fetch(`/api/filters?${params}`); 506 - const data = await res.json(); 507 + if (filterFetchInFlight) return; 508 + filterFetchInFlight = true; 507 509 508 - updateYearDropdown(data.years); 509 - updateAlbumDropdown(data.albums); 510 - updateTypeDropdown(data.totalPhotos, data.totalVideos); 511 - updateStats(data); 512 - updateProgress(data); 510 + try { 511 + const params = buildFilterParams(); 512 + const res = await fetch(`/api/filters?${params}`); 513 + const data = await res.json(); 513 514 514 - if (data.isLoading && !state.pollTimer) { 515 - state.metadataLoading = true; 516 - state.pollTimer = setInterval(() => fetchFilters(), 2000); 517 - } 515 + updateYearDropdown(data.years); 516 + updateAlbumDropdown(data.albums); 517 + updateTypeDropdown(data.totalPhotos, data.totalVideos); 518 + updateStats(data); 519 + updateProgress(data); 518 520 519 - if (!data.isLoading && state.pollTimer) { 520 - state.metadataLoading = false; 521 - clearInterval(state.pollTimer); 522 - state.pollTimer = null; 523 - reload = true; 524 - } 521 + if (data.isLoading && !state.pollTimer) { 522 + state.metadataLoading = true; 523 + state.pollTimer = setInterval(() => fetchFilters(), 2000); 524 + } 525 525 526 - if (reload) resetAndLoad(); 526 + if (!data.isLoading) { 527 + state.metadataLoading = false; 528 + if (state.pollTimer) { 529 + clearInterval(state.pollTimer); 530 + state.pollTimer = null; 531 + } 532 + reload = true; 533 + } 534 + 535 + if (reload) resetAndLoad(); 536 + } finally { 537 + filterFetchInFlight = false; 538 + } 527 539 } 528 540 529 541 function updateYearDropdown(years) { ··· 643 655 }); 644 656 645 657 function resetAndLoad() { 658 + state.generation++; 646 659 state.page = 1; 647 660 state.done = false; 661 + state.loading = false; 648 662 state.assets = []; 649 663 grid.innerHTML = ''; 650 664 emptyState.style.display = 'none'; ··· 657 671 if (state.loading || state.done) return; 658 672 state.loading = true; 659 673 loadingBar.classList.add('active'); 674 + const gen = state.generation; 660 675 661 676 const params = new URLSearchParams({ page: state.page, pageSize: state.pageSize }); 662 677 if (state.filters.year) params.set('year', state.filters.year); ··· 666 681 667 682 const res = await fetch(`/api/assets?${params}`); 668 683 const data = await res.json(); 684 + 685 + // Discard response if a reset happened while the fetch was in-flight 686 + if (gen !== state.generation) { 687 + state.loading = false; 688 + loadingBar.classList.remove('active'); 689 + return; 690 + } 669 691 670 692 state.totalCount = data.totalCount; 671 693 ··· 775 797 } 776 798 777 799 async function renderLightbox() { 778 - const asset = state.assets[state.lightboxIndex]; 800 + const idx = state.lightboxIndex; 801 + const asset = state.assets[idx]; 779 802 if (!asset) return; 780 803 781 804 const content = document.getElementById('lbContent'); ··· 784 807 785 808 const res = await fetch(`/api/assets/${asset.uuid}`); 786 809 const detail = await res.json(); 810 + 811 + // Discard if user navigated away while fetch was in-flight 812 + if (state.lightboxIndex !== idx) return; 787 813 788 814 content.innerHTML = ''; 789 815 if (asset.isVideo) {