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:
@@ -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,6 +25,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── 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>
|
||||
@@ -53,11 +58,57 @@
|
||||
</div>
|
||||
</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">← 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">✓</div>
|
||||
<p class="text-lg font-semibold">All tagged!</p>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('done-btn').click()">
|
||||
Done triaging →
|
||||
</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')">✕</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>
|
||||
|
||||
</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 %}
|
||||
|
||||
Reference in New Issue
Block a user