feat: mobile swipe triage UI + tag visualization

- HammerJS swipe cards (right=journal, left=skip, up=story) with tilt/color feedback
- Three tap buttons as swipe alternative (J/S/X)
- Undo stack (max 10) with Back button
- Progress bar + header counter sync
- Thumbnail strip (all photos, colored dots, tap to jump)
- Desktop: J/S/X badges on all tagged photos including skip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:10:31 +02:00
parent 1159b9cba6
commit f260e2ff76
@@ -47,11 +47,12 @@
<div class="absolute bottom-0 left-0 right-0 text-[10px] text-white bg-black/40 px-1">
{{ photo.local_datetime[11:16] }}
</div>
{% if photo.tag != 'untagged' and photo.tag != 'skip' %}
<div class="absolute top-1 right-1 badge badge-xs
{% if photo.tag == 'journal' %}badge-success{% else %}badge-info{% endif %}">
{{ photo.tag[0] | upper }}
</div>
{% if photo.tag == 'journal' %}
<div class="absolute top-1 right-1 badge badge-xs badge-success">J</div>
{% elif photo.tag == 'story' %}
<div class="absolute top-1 right-1 badge badge-xs badge-info">S</div>
{% elif photo.tag == 'skip' %}
<div class="absolute top-1 right-1 badge badge-xs badge-ghost opacity-60">X</div>
{% endif %}
</div>
{% endfor %}
@@ -100,6 +101,11 @@
class="btn btn-circle btn-lg btn-ghost border-2 border-info text-2xl"
onclick="mobileApp && mobileApp.doTag('story')">S</button>
</div>
{# Thumbnail strip — all photos, colored dot per tag, tap to jump #}
<div id="m-thumb-strip"
class="mt-3 flex gap-1.5 overflow-x-auto pb-2"
style="scrollbar-width:thin;-webkit-overflow-scrolling:touch"></div>
</div>
</div>
@@ -274,6 +280,7 @@ function mobileTriageApp(albumId, photos) {
if (queue.length === 0) {
showCompletion();
updateProgress();
updateThumbStrip();
return;
}
@@ -382,6 +389,7 @@ function mobileTriageApp(albumId, photos) {
updateProgress();
showCard();
updateThumbStrip();
}
// ── Undo ─────────────────────────────────────────────────────────────
@@ -406,10 +414,90 @@ function mobileTriageApp(albumId, photos) {
updateProgress();
showCard();
updateThumbStrip();
}
// ── Thumbnail strip ──────────────────────────────────────────────────
const thumbStrip = document.getElementById('m-thumb-strip');
function buildThumbStrip() {
thumbStrip.innerHTML = '';
photos.forEach(photo => {
const wrap = document.createElement('div');
wrap.className = 'relative flex-none cursor-pointer';
wrap.style.cssText = 'width:44px;height:44px;';
wrap.dataset.thumbId = photo.id;
wrap.addEventListener('click', () => jumpToPhoto(photo));
const img = document.createElement('img');
img.src = `/proxy/thumb/${photo.id}`;
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:4px;border:2px solid transparent;transition:border-color 0.15s;display:block;';
img.draggable = false;
wrap.appendChild(img);
const dot = document.createElement('div');
dot.style.cssText = 'position:absolute;bottom:2px;left:2px;width:7px;height:7px;border-radius:50%;display:none;border:1px solid rgba(0,0,0,0.3);';
wrap.appendChild(dot);
thumbStrip.appendChild(wrap);
});
updateThumbStrip();
}
function updateThumbStrip() {
const currentId = queue.length > 0 ? queue[0].id : null;
photos.forEach(photo => {
const wrap = thumbStrip.querySelector(`[data-thumb-id="${photo.id}"]`);
if (!wrap) return;
const img = wrap.querySelector('img');
const dot = wrap.querySelector('div');
img.style.borderColor = photo.id === currentId ? '#fff' : 'transparent';
img.style.boxShadow = photo.id === currentId ? '0 0 0 1px rgba(0,0,0,0.4)' : 'none';
if (photo.tag === 'journal') {
dot.style.display = '';
dot.style.background = '#4ade80';
} else if (photo.tag === 'story') {
dot.style.display = '';
dot.style.background = '#38bdf8';
} else if (photo.tag === 'skip') {
dot.style.display = '';
dot.style.background = '#64748b';
} else {
dot.style.display = 'none';
}
});
if (currentId) {
const currentWrap = thumbStrip.querySelector(`[data-thumb-id="${currentId}"]`);
if (currentWrap) currentWrap.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}
async function jumpToPhoto(photo) {
const queueIdx = queue.findIndex(p => p.id === photo.id);
if (queueIdx !== -1) queue.splice(queueIdx, 1);
if (photo.tag !== 'untagged') taggedCount--;
const prevTag = photo.tag;
photo.tag = 'untagged';
queue.unshift(photo);
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag: 'untagged' }),
});
updateProgress();
updateThumbStrip();
showCard();
}
// ── Public API ───────────────────────────────────────────────────────
return { doTag, undo, showCard };
return { doTag, undo, showCard, buildThumbStrip, updateThumbStrip };
}
// ── View switching on load ───────────────────────────────────────────────────
@@ -427,6 +515,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('m-progress-text').textContent = `${taggedCount} / ${photos.length} tagged`;
document.getElementById('m-progress-bar').style.width =
photos.length > 0 ? `${(taggedCount / photos.length) * 100}%` : '0%';
mobileApp.buildThumbStrip();
mobileApp.showCard();
}
// Desktop: nothing extra needed — Alpine handles it