Add status filtering and segmented progress v0.3

This commit is contained in:
dmo committed 2026-07-23 01:13:15 +02:00
1 parent e2f5c91138
commit cbf0daf062
7 files changed
+123 -28

No files matched your search

+61 -15
View File
@@ -20,9 +20,13 @@ const elements = {
folderName: $('#folder-name'),
folderAccess: $('#folder-access'),
progressCount: $('#progress-count'),
progressBar: $('#progress-bar'),
progressSaved: $('#progress-saved'),
progressSkipped: $('#progress-skipped'),
progressFailed: $('#progress-failed'),
photoCount: $('#photo-count'),
filter: $('#filter-input'),
statusFilter: $('#status-filter'),
sortSelect: $('#sort-select'),
fileList: $('#file-list'),
viewerEmpty: $('#viewer-empty'),
viewerContent: $('#viewer-content'),
@@ -163,6 +167,8 @@ async function scanFolder() {
elements.folderName.textContent = state.directoryHandle.name;
elements.folderAccess.textContent = 'Read/write access granted';
elements.filter.disabled = !photos.length;
elements.statusFilter.disabled = !photos.length;
elements.sortSelect.disabled = !photos.length;
elements.rescan.disabled = false;
elements.photoCount.textContent = String(photos.length);
@@ -202,15 +208,33 @@ function statusSymbol(status) {
return '';
}
function renderFileList() {
const filter = elements.filter.value.trim().toLowerCase();
const matching = state.photos
const statusSortOrder = { pending: 0, failed: 1, skipped: 2, saved: 3 };
function visiblePhotoEntries() {
const filenameFilter = elements.filter.value.trim().toLowerCase();
const statusFilter = elements.statusFilter.value;
const entries = state.photos
.map((photo, index) => ({ photo, index }))
.filter(({ photo }) => !filter || photo.name.toLowerCase().includes(filter));
.filter(({ photo }) => !filenameFilter || photo.name.toLowerCase().includes(filenameFilter))
.filter(({ photo }) => statusFilter === 'all' || photo.status === statusFilter);
if (elements.sortSelect.value === 'status') {
entries.sort((a, b) => {
const statusDifference = statusSortOrder[a.photo.status] - statusSortOrder[b.photo.status];
return statusDifference || naturalCompare(a.photo.name, b.photo.name);
});
} else {
entries.sort((a, b) => naturalCompare(a.photo.name, b.photo.name));
}
return entries;
}
function renderFileList() {
const matching = visiblePhotoEntries();
elements.fileList.innerHTML = '';
if (!matching.length) {
elements.fileList.innerHTML = '<div class="empty-list">No matching photos.</div>';
elements.fileList.innerHTML = '<div class="empty-list">No photos match the current filters.</div>';
return;
}
@@ -330,9 +354,18 @@ async function selectPhoto(index, force = false) {
}
}
function adjacentVisibleIndex(delta) {
const visible = visiblePhotoEntries();
if (!visible.length) return null;
const position = visible.findIndex(({ index }) => index === state.currentIndex);
if (position === -1) return delta > 0 ? visible[0].index : visible[visible.length - 1].index;
const target = position + delta;
return target >= 0 && target < visible.length ? visible[target].index : null;
}
function updateNavigation() {
const hasPhoto = state.currentIndex >= 0;
elements.previous.disabled = !hasPhoto || state.currentIndex === 0 || state.processing;
elements.previous.disabled = !hasPhoto || adjacentVisibleIndex(-1) === null || state.processing;
elements.skip.disabled = !hasPhoto || state.processing;
elements.save.disabled = !hasPhoto || state.processing;
elements.saveNext.disabled = !hasPhoto || state.processing;
@@ -409,6 +442,7 @@ async function savePhoto(moveNext) {
catch (error) { showToast(error.message, true); return; }
const photo = state.photos[state.currentIndex];
const nextVisibleIndex = moveNext ? adjacentVisibleIndex(1) : null;
state.processing = true;
updateNavigation();
setSaveStatus('saving', 'Saving metadata…', photo.name);
@@ -464,8 +498,8 @@ async function savePhoto(moveNext) {
if (oldThumb) URL.revokeObjectURL(oldThumb);
state.thumbUrls.delete(photo.name);
if (moveNext && state.currentIndex < state.photos.length - 1) {
await selectPhoto(state.currentIndex + 1, true);
if (moveNext && nextVisibleIndex !== null) {
await selectPhoto(nextVisibleIndex, true);
} else {
await selectPhoto(state.currentIndex, true);
}
@@ -485,29 +519,39 @@ async function savePhoto(moveNext) {
function skipCurrentPhoto() {
if (state.processing || state.currentIndex < 0) return;
const photo = state.photos[state.currentIndex];
const nextVisibleIndex = adjacentVisibleIndex(1);
captureCurrentValues();
photo.status = 'skipped';
saveFolderState();
updateProgress();
renderFileList();
showToast(`Skipped ${photo.name}`);
if (state.currentIndex < state.photos.length - 1) goRelative(1);
if (nextVisibleIndex !== null) selectPhoto(nextVisibleIndex);
else updatePhotoStatus(photo);
}
function updateProgress() {
const saved = state.photos.filter((photo) => photo.status === 'saved').length;
const skipped = state.photos.filter((photo) => photo.status === 'skipped').length;
const failed = state.photos.filter((photo) => photo.status === 'failed').length;
const pending = state.photos.filter((photo) => photo.status === 'pending').length;
const processed = saved + skipped;
const total = state.photos.length;
const percentage = (count) => total ? `${(count / total) * 100}%` : '0%';
elements.progressCount.textContent = `${processed} / ${total}`;
elements.progressBar.style.width = total ? `${(processed / total) * 100}%` : '0%';
elements.progressCount.title = `${saved} saved, ${skipped} skipped`;
elements.progressSaved.style.width = percentage(saved);
elements.progressSkipped.style.width = percentage(skipped);
elements.progressFailed.style.width = percentage(failed);
elements.progressCount.title = `${saved} saved, ${skipped} skipped, ${failed} failed, ${pending} pending`;
const track = elements.progressSaved.parentElement;
track.setAttribute('aria-valuenow', total ? String(Math.round((processed / total) * 100)) : '0');
track.setAttribute('aria-valuetext', `${saved} saved, ${skipped} skipped, ${failed} failed, ${pending} pending`);
}
function goRelative(delta) {
const target = state.currentIndex + delta;
if (target >= 0 && target < state.photos.length) selectPhoto(target);
const target = adjacentVisibleIndex(delta);
if (target !== null) selectPhoto(target);
}
function copyPreviousValues() {
@@ -527,7 +571,9 @@ function resetView() { state.zoom = 1; state.rotation = 0; applyView(); }
for (const button of elements.openButtons) button.addEventListener('click', openFolder);
elements.rescan.addEventListener('click', scanFolder);
elements.filter.addEventListener('input', renderFileList);
elements.filter.addEventListener('input', () => { renderFileList(); updateNavigation(); });
elements.statusFilter.addEventListener('change', () => { renderFileList(); updateNavigation(); });
elements.sortSelect.addEventListener('change', () => { renderFileList(); updateNavigation(); });
elements.previous.addEventListener('click', () => goRelative(-1));
elements.skip.addEventListener('click', skipCurrentPhoto);
elements.save.addEventListener('click', () => savePhoto(false));
+19 -2
View File
@@ -42,7 +42,11 @@
<span>Progress</span>
<span id="progress-count">0 / 0</span>
</div>
<div class="progress-track"><div id="progress-bar" class="progress-bar"></div></div>
<div class="progress-track" role="progressbar" aria-label="Photo processing progress" aria-valuemin="0" aria-valuemax="100">
<div id="progress-saved" class="progress-segment saved" title="Saved"></div>
<div id="progress-skipped" class="progress-segment skipped" title="Skipped"></div>
<div id="progress-failed" class="progress-segment failed" title="Failed"></div>
</div>
<div class="status-legend">
<span><i class="dot saved"></i>Saved</span>
<span><i class="dot skipped"></i>Skipped</span>
@@ -53,7 +57,20 @@
<section class="panel-section file-section">
<div class="section-heading"><span>Photos</span><span id="photo-count">0</span></div>
<input id="filter-input" class="input filter" type="search" placeholder="Filter filenames…" disabled>
<div class="file-controls">
<input id="filter-input" class="input filter filename-filter" type="search" placeholder="Filter filenames…" aria-label="Filter photos by filename" disabled>
<select id="status-filter" class="input filter" aria-label="Filter photos by status" disabled>
<option value="all">All statuses</option>
<option value="pending">Pending</option>
<option value="saved">Saved</option>
<option value="skipped">Skipped</option>
<option value="failed">Failed</option>
</select>
<select id="sort-select" class="input filter" aria-label="Sort photos" disabled>
<option value="filename">Sort: Filename</option>
<option value="status">Sort: Status</option>
</select>
</div>
<div id="file-list" class="file-list" aria-live="polite">
<div class="empty-list">Choose a folder containing JPG or JPEG photos.</div>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v2';
const CACHE_NAME = 'photo-date-editor-v3';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
+7 -2
View File
@@ -41,12 +41,17 @@ button { color: inherit; }
.folder-row strong, .folder-row span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder-row strong { max-width: 185px; }
.folder-row span { color: var(--muted); font-size: 12px; margin-top: 4px; max-width: 185px; }
.progress-track { height: 7px; background: #2a3440; border-radius: 99px; overflow: hidden; }
.progress-bar { width: 0; height: 100%; background: var(--primary); transition: width .2s ease; }
.progress-track { height: 7px; display: flex; background: #2a3440; border-radius: 99px; overflow: hidden; }
.progress-segment { width: 0; height: 100%; flex: 0 0 auto; transition: width .2s ease; }
.progress-segment.saved { background: var(--success); }
.progress-segment.skipped { background: var(--warning); }
.progress-segment.failed { background: var(--danger); }
.status-legend { display: flex; gap: 12px; margin-top: 11px; color: var(--muted); font-size: 11px; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
.dot.saved { background: var(--success); }.dot.skipped { background: #c49a5a; }.dot.pending { background: #748294; }.dot.failed { background: var(--danger); }
.file-section { flex: 1; display: flex; min-height: 0; flex-direction: column; }
.file-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.filename-filter { grid-column: 1 / -1; }
.file-list { flex: 1; min-height: 120px; overflow: auto; margin-top: 10px; padding-right: 3px; }
.empty-list { color: var(--muted); font-size: 13px; line-height: 1.55; padding: 15px 5px; }
.file-row { width: 100%; border: 0; border-radius: 8px; background: transparent; display: grid; grid-template-columns: 50px minmax(0, 1fr) 22px; align-items: center; gap: 10px; padding: 8px; text-align: left; cursor: pointer; }