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 | selectattr('tag', 'ne', 'untagged') | list | length }}
|
||||||
/ {{ state.photos | length }} tagged
|
/ {{ state.photos | length }} tagged
|
||||||
</span>
|
</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="skipUntagged()">
|
||||||
|
Skip untagged
|
||||||
|
</button>
|
||||||
<button id="done-btn"
|
<button id="done-btn"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
{% if not all_tagged %}disabled{% endif %}
|
{% if not all_tagged %}disabled{% endif %}
|
||||||
@@ -22,42 +25,90 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for day, photos in photos_by_day.items() %}
|
{# ── Desktop grid (hidden on mobile) ── #}
|
||||||
<div class="day-group mb-6">
|
<div id="desktop-view">
|
||||||
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
|
{% for day, photos in photos_by_day.items() %}
|
||||||
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
|
<div class="day-group mb-6">
|
||||||
{% for photo in photos %}
|
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
|
||||||
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
|
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
|
||||||
{% if photo.tag == 'journal' %}border-success
|
{% for photo in photos %}
|
||||||
{% elif photo.tag == 'story' %}border-info
|
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
|
||||||
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
|
{% if photo.tag == 'journal' %}border-success
|
||||||
{% else %}border-transparent{% endif %}"
|
{% elif photo.tag == 'story' %}border-info
|
||||||
data-asset-id="{{ photo.id }}"
|
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
|
||||||
data-tag="{{ photo.tag }}"
|
{% else %}border-transparent{% endif %}"
|
||||||
tabindex="0"
|
data-asset-id="{{ photo.id }}"
|
||||||
@click="select($el)"
|
data-tag="{{ photo.tag }}"
|
||||||
@focus="select($el)">
|
tabindex="0"
|
||||||
<img src="/proxy/thumb/{{ photo.id }}"
|
@click="select($el)"
|
||||||
class="w-full aspect-square object-cover" loading="lazy" alt="">
|
@focus="select($el)">
|
||||||
<div class="absolute bottom-0 left-0 right-0 text-[10px] text-white bg-black/40 px-1">
|
<img src="/proxy/thumb/{{ photo.id }}"
|
||||||
{{ photo.local_datetime[11:16] }}
|
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>
|
</div>
|
||||||
{% if photo.tag != 'untagged' and photo.tag != 'skip' %}
|
{% endfor %}
|
||||||
<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>
|
</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">← 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>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// ── Desktop Alpine app (unchanged) ──────────────────────────────────────────
|
||||||
function triageApp(albumId) {
|
function triageApp(albumId) {
|
||||||
return {
|
return {
|
||||||
focused: null,
|
focused: null,
|
||||||
@@ -98,6 +149,23 @@ function triageApp(albumId) {
|
|||||||
document.getElementById('done-btn').disabled = tagged < total;
|
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() {
|
async done() {
|
||||||
const res = await fetch('/triage/done', {
|
const res = await fetch('/triage/done', {
|
||||||
method: 'POST',
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user