feat: add mobile swipe triage UI with HammerJS and undo stack

On viewports < 768px, show a Tinder-style card UI instead of the photo
grid. Cards show one untagged photo at a time with swipe gestures
(right=journal, left=skip, up=story), colour overlays during drag, tap
buttons as alternatives, a progress bar, and a 10-deep undo stack.
Desktop grid is unchanged, wrapped in #desktop-view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:07:50 +02:00
parent ab159d3a93
commit 1159b9cba6
@@ -13,6 +13,9 @@
{{ state.photos | selectattr('tag', 'ne', 'untagged') | list | length }}
/ {{ state.photos | length }} tagged
</span>
<button class="btn btn-ghost btn-sm" @click="skipUntagged()">
Skip untagged
</button>
<button id="done-btn"
class="btn btn-primary btn-sm"
{% if not all_tagged %}disabled{% endif %}
@@ -22,42 +25,90 @@
</div>
</div>
{% for day, photos in photos_by_day.items() %}
<div class="day-group mb-6">
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
{% for photo in photos %}
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
{% if photo.tag == 'journal' %}border-success
{% elif photo.tag == 'story' %}border-info
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
{% else %}border-transparent{% endif %}"
data-asset-id="{{ photo.id }}"
data-tag="{{ photo.tag }}"
tabindex="0"
@click="select($el)"
@focus="select($el)">
<img src="/proxy/thumb/{{ photo.id }}"
class="w-full aspect-square object-cover" loading="lazy" alt="">
<div class="absolute bottom-0 left-0 right-0 text-[10px] text-white bg-black/40 px-1">
{{ photo.local_datetime[11:16] }}
{# ── Desktop grid (hidden on mobile) ── #}
<div id="desktop-view">
{% for day, photos in photos_by_day.items() %}
<div class="day-group mb-6">
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
{% for photo in photos %}
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
{% if photo.tag == 'journal' %}border-success
{% elif photo.tag == 'story' %}border-info
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
{% else %}border-transparent{% endif %}"
data-asset-id="{{ photo.id }}"
data-tag="{{ photo.tag }}"
tabindex="0"
@click="select($el)"
@focus="select($el)">
<img src="/proxy/thumb/{{ photo.id }}"
class="w-full aspect-square object-cover" loading="lazy" alt="">
<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>
{% endif %}
</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>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{# ── Mobile card UI (hidden on desktop) ── #}
<div id="mobile-view" style="display:none">
{# Progress bar #}
<div class="mb-3">
<div class="flex justify-between text-xs opacity-60 mb-1">
<span id="m-progress-text">0 / {{ state.photos | length }} tagged</span>
<span id="m-undo-btn-wrap" style="display:none">
<button id="m-undo-btn" class="btn btn-ghost btn-xs">&#8592; Back</button>
</span>
</div>
<div class="w-full bg-base-300 rounded-full h-1.5">
<div id="m-progress-bar" class="bg-primary h-1.5 rounded-full transition-all" style="width:0%"></div>
</div>
</div>
{# Card stack #}
<div id="m-card-area" class="relative w-full" style="height:70vh">
{# Card is injected by JS #}
<div id="m-completion" style="display:none"
class="flex flex-col items-center justify-center h-full gap-4 text-center">
<div class="text-4xl">&#10003;</div>
<p class="text-lg font-semibold">All tagged!</p>
<button class="btn btn-primary" onclick="document.getElementById('done-btn').click()">
Done triaging &rarr;
</button>
</div>
</div>
{# Action buttons #}
<div id="m-buttons" class="flex justify-center gap-6 mt-4">
<button id="m-btn-skip"
class="btn btn-circle btn-lg btn-ghost border-2 border-base-300 text-2xl"
onclick="mobileApp && mobileApp.doTag('skip')">&#10005;</button>
<button id="m-btn-journal"
class="btn btn-circle btn-lg btn-ghost border-2 border-success text-2xl"
onclick="mobileApp && mobileApp.doTag('journal')">J</button>
<button id="m-btn-story"
class="btn btn-circle btn-lg btn-ghost border-2 border-info text-2xl"
onclick="mobileApp && mobileApp.doTag('story')">S</button>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script>
// ── Desktop Alpine app (unchanged) ──────────────────────────────────────────
function triageApp(albumId) {
return {
focused: null,
@@ -98,6 +149,23 @@ function triageApp(albumId) {
document.getElementById('done-btn').disabled = tagged < total;
},
async skipUntagged() {
await fetch('/triage/skip-untagged', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId }),
});
document.querySelectorAll('.photo-card[data-tag="untagged"]').forEach(el => {
el.dataset.tag = 'skip';
el.className = el.className
.split(' ')
.filter(c => !c.startsWith('border-') && c !== 'opacity-40')
.join(' ');
el.classList.add('border-base-300', 'opacity-40');
});
this.updateCount();
},
async done() {
const res = await fetch('/triage/done', {
method: 'POST',
@@ -109,5 +177,259 @@ function triageApp(albumId) {
},
};
}
// ── Mobile swipe triage app ──────────────────────────────────────────────────
let mobileApp = null;
function mobileTriageApp(albumId, photos) {
// Build queue: only untagged photos, in their original order
let queue = photos
.filter(p => p.tag === 'untagged')
.slice(); // shallow copy
const total = photos.length;
let taggedCount = photos.filter(p => p.tag !== 'untagged').length;
// Undo stack: [{asset_id, previous_tag}, ...] (max 10)
const undoStack = [];
// DOM refs
const cardArea = document.getElementById('m-card-area');
const completion = document.getElementById('m-completion');
const progressBar = document.getElementById('m-progress-bar');
const progressText = document.getElementById('m-progress-text');
const undoBtnWrap = document.getElementById('m-undo-btn-wrap');
const undoBtn = document.getElementById('m-undo-btn');
undoBtn.addEventListener('click', undo);
// ── helpers ─────────────────────────────────────────────────────────────
function updateProgress() {
progressText.textContent = `${taggedCount} / ${total} tagged`;
progressBar.style.width = total > 0 ? `${(taggedCount / total) * 100}%` : '0%';
undoBtnWrap.style.display = undoStack.length > 0 ? '' : 'none';
// Sync the shared header counter / done button
document.getElementById('tagged-count').textContent = `${taggedCount} / ${total} tagged`;
document.getElementById('done-btn').disabled = taggedCount < total;
}
function showCompletion() {
completion.style.display = '';
document.getElementById('m-buttons').style.display = 'none';
}
function makeCard(photo) {
const card = document.createElement('div');
card.id = 'm-card';
card.style.cssText = `
position: absolute; inset: 0;
border-radius: 16px; overflow: hidden;
background: #000;
touch-action: none;
user-select: none;
will-change: transform;
cursor: grab;
`;
const img = document.createElement('img');
img.src = `/proxy/thumb/${photo.id}`;
img.style.cssText = 'width:100%; height:100%; object-fit:cover; display:block;';
img.draggable = false;
card.appendChild(img);
// Date overlay
const dateOverlay = document.createElement('div');
dateOverlay.style.cssText = `
position: absolute; bottom: 0; left: 0; right: 0;
padding: 12px 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.6));
color: #fff; font-size: 14px;
`;
dateOverlay.textContent = photo.local_datetime
? photo.local_datetime.slice(0, 16).replace('T', ' ')
: '';
card.appendChild(dateOverlay);
// Colour overlay (shown during drag)
const colorOverlay = document.createElement('div');
colorOverlay.id = 'm-color-overlay';
colorOverlay.style.cssText = `
position: absolute; inset: 0;
opacity: 0;
transition: opacity 0.1s;
pointer-events: none;
border-radius: 16px;
`;
card.appendChild(colorOverlay);
return { card, colorOverlay };
}
function showCard() {
// Remove existing card if any
const old = document.getElementById('m-card');
if (old) old.remove();
if (queue.length === 0) {
showCompletion();
updateProgress();
return;
}
completion.style.display = 'none';
document.getElementById('m-buttons').style.display = '';
const photo = queue[0];
const { card, colorOverlay } = makeCard(photo);
cardArea.appendChild(card);
// ── HammerJS gestures ─────────────────────────────────────────────
const hammer = new Hammer(card, { recognizers: [[Hammer.Pan, { direction: Hammer.DIRECTION_ALL, threshold: 5 }]] });
// Also enable swipe (velocity-based)
hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL });
let startX = 0, startY = 0;
hammer.on('panstart', () => {
card.style.transition = 'none';
});
hammer.on('panmove', (ev) => {
const dx = ev.deltaX;
const dy = ev.deltaY;
const tilt = dx * 0.08; // degrees of rotation
card.style.transform = `translate(${dx}px, ${dy}px) rotate(${tilt}deg)`;
// Determine dominant direction for colour overlay
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDy > absDx && dy < -30) {
// swipe up → story (blue)
colorOverlay.style.background = 'rgba(56,189,248,0.35)';
colorOverlay.style.opacity = Math.min(absDy / 150, 0.8);
} else if (dx > 30) {
// swipe right → journal (green)
colorOverlay.style.background = 'rgba(74,222,128,0.35)';
colorOverlay.style.opacity = Math.min(absDx / 150, 0.8);
} else if (dx < -30) {
// swipe left → skip (grey)
colorOverlay.style.background = 'rgba(100,116,139,0.35)';
colorOverlay.style.opacity = Math.min(absDx / 150, 0.8);
} else {
colorOverlay.style.opacity = 0;
}
});
hammer.on('panend', (ev) => {
const dx = ev.deltaX;
const dy = ev.deltaY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
const THRESHOLD = 50;
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
if (absDy > absDx && dy < -THRESHOLD) {
// Swipe up → story
flyOut(card, 0, -window.innerHeight, () => doTag('story'));
} else if (dx > THRESHOLD) {
// Swipe right → journal
flyOut(card, window.innerWidth, 0, () => doTag('journal'));
} else if (dx < -THRESHOLD) {
// Swipe left → skip
flyOut(card, -window.innerWidth, 0, () => doTag('skip'));
} else {
// Snap back
card.style.transform = 'translate(0,0) rotate(0deg)';
colorOverlay.style.opacity = 0;
}
});
}
function flyOut(card, toX, toY, callback) {
card.style.transform = `translate(${toX}px, ${toY}px) rotate(${toX * 0.1}deg)`;
card.style.opacity = '0';
setTimeout(() => {
callback();
}, 300);
}
// ── Tag action ───────────────────────────────────────────────────────
async function doTag(tag) {
if (queue.length === 0) return;
const photo = queue.shift();
const previousTag = photo.tag;
// Push to undo stack (max 10)
undoStack.push({ photo, previousTag });
if (undoStack.length > 10) undoStack.shift();
// Update local photo tag
photo.tag = tag;
// Increment tagged count only if previously untagged
if (previousTag === 'untagged') taggedCount++;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag }),
});
updateProgress();
showCard();
}
// ── Undo ─────────────────────────────────────────────────────────────
async function undo() {
if (undoStack.length === 0) return;
const { photo, previousTag } = undoStack.pop();
// Re-insert at front of queue
queue.unshift(photo);
// Revert tagged count
if (previousTag === 'untagged' && photo.tag !== 'untagged') taggedCount--;
photo.tag = previousTag;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag: previousTag }),
});
updateProgress();
showCard();
}
// ── Public API ───────────────────────────────────────────────────────
return { doTag, undo, showCard };
}
// ── View switching on load ───────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (window.innerWidth < 768) {
document.getElementById('desktop-view').style.display = 'none';
document.getElementById('mobile-view').style.display = '';
const albumId = '{{ album_id }}';
const photos = {{ state.photos | tojson }};
mobileApp = mobileTriageApp(albumId, photos);
// Seed initial progress
const taggedCount = photos.filter(p => p.tag !== 'untagged').length;
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.showCard();
}
// Desktop: nothing extra needed — Alpine handles it
});
</script>
{% endblock %}