Files
intotheeast-com/services/travel-memories/app/templates/phase2.html
T

629 lines
24 KiB
HTML

{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-6xl mx-auto" x-data="triageApp('{{ album_id }}')"
@keydown.j.window="tagFocused('journal')"
@keydown.s.window="tagFocused('story')"
@keydown.x.window="tagFocused('skip')"
@keydown.space.prevent.window="tagFocused('skip')"
@keydown.left.prevent.window="navigate(-1)"
@keydown.right.prevent.window="navigate(1)"
@keydown.escape.window="closeLightbox()"
@keydown.enter.window="focused && openLightbox(focused)">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Triage</h1>
<div class="flex items-center gap-3">
<span class="text-sm opacity-60" id="tagged-count">
{{ 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 %}
@click="done()">
Done triaging &rarr;
</button>
</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>
<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="openLightbox($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 == '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 %}
</div>
</div>
{% endfor %}
</div>
{# ── Lightbox overlay (desktop) ── #}
<div id="lb" class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center" style="display:none">
<button class="absolute top-4 right-4 btn btn-circle btn-sm btn-ghost text-white opacity-60 hover:opacity-100 text-lg"
@click="closeLightbox()">&#10005;</button>
<button class="absolute left-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl opacity-60 hover:opacity-100"
@click="navigate(-1)">&#8249;</button>
<button class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl opacity-60 hover:opacity-100"
@click="navigate(1)">&#8250;</button>
<div class="flex flex-col items-center gap-3 px-16 max-w-full">
<img id="lb-img" src="" class="max-h-[82vh] max-w-[88vw] object-contain rounded-lg shadow-2xl" alt="">
<div class="flex items-center gap-4 text-white/60 text-sm">
<span id="lb-date"></span>
<span id="lb-filename" class="opacity-40"></span>
<span id="lb-tag-badge" class="badge badge-sm"></span>
<span class="opacity-30 text-xs">J journal · S story · X skip · ← → navigate · Esc close</span>
</div>
</div>
</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>
{# 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>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script>
// ── Shared badge helper ──────────────────────────────────────────────────────
function updateBadge(cardEl, tag) {
let badge = cardEl.querySelector('.badge');
if (!badge) {
badge = document.createElement('div');
cardEl.appendChild(badge);
}
const MAP = {
journal: ['badge-xs badge-success', 'J'],
story: ['badge-xs badge-info', 'S'],
skip: ['badge-xs badge-ghost opacity-60', 'X'],
};
if (MAP[tag]) {
badge.className = `absolute top-1 right-1 badge ${MAP[tag][0]}`;
badge.textContent = MAP[tag][1];
} else {
badge.remove();
}
}
// ── Desktop Alpine app ───────────────────────────────────────────────────────
function triageApp(albumId) {
return {
focused: null,
lightboxOpen: false,
init() {
const first = document.querySelector('.photo-card');
if (first) this.select(first);
},
select(el) {
if (this.focused) this.focused.classList.remove('ring-4', 'ring-white', 'ring-offset-2', 'z-10');
this.focused = el;
if (el) {
el.classList.add('ring-4', 'ring-white', 'ring-offset-2', 'z-10');
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (this.lightboxOpen) this.updateLightbox();
},
openLightbox(el) {
this.select(el);
this.lightboxOpen = true;
document.getElementById('lb').style.display = '';
this.updateLightbox();
},
closeLightbox() {
if (!this.lightboxOpen) return;
this.lightboxOpen = false;
document.getElementById('lb').style.display = 'none';
},
updateLightbox() {
const el = this.focused;
if (!el) return;
const assetId = el.dataset.assetId;
const tag = el.dataset.tag;
document.getElementById('lb-img').src = `/proxy/thumb/${assetId}`;
const timeEl = el.querySelector('div');
document.getElementById('lb-date').textContent = timeEl ? timeEl.textContent.trim() : '';
document.getElementById('lb-filename').textContent = el.dataset.filename || '';
const badgeEl = document.getElementById('lb-tag-badge');
const MAP = {
journal: ['badge-success', 'Journal'],
story: ['badge-info', 'Story'],
skip: ['badge-ghost opacity-60', 'Skip'],
};
if (MAP[tag]) {
badgeEl.className = `badge badge-sm ${MAP[tag][0]}`;
badgeEl.textContent = MAP[tag][1];
} else {
badgeEl.className = 'badge badge-sm badge-outline opacity-30';
badgeEl.textContent = 'Untagged';
}
},
navigate(dir) {
const cards = [...document.querySelectorAll('.photo-card')];
if (!cards.length) return;
const idx = this.focused ? cards.indexOf(this.focused) : -1;
const next = cards[Math.max(0, Math.min(cards.length - 1, idx + dir))];
if (next) this.select(next);
},
async tagFocused(tag) {
const el = this.focused || document.querySelector('.photo-card');
if (!el) return;
const assetId = el.dataset.assetId;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: assetId, tag }),
});
el.dataset.tag = tag;
// Remove any existing border/opacity classes before adding new ones
el.className = el.className
.split(/\s+/)
.filter(c => c && !c.startsWith('border-') && c !== 'opacity-40')
.join(' ');
if (tag === 'journal') {
el.classList.add('border-4', 'border-success');
} else if (tag === 'story') {
el.classList.add('border-4', 'border-info');
} else {
el.classList.add('border-4', 'border-base-300', 'opacity-40');
}
updateBadge(el, tag);
this.updateCount();
if (this.lightboxOpen) this.updateLightbox();
},
updateCount() {
const total = document.querySelectorAll('.photo-card').length;
const tagged = document.querySelectorAll('.photo-card:not([data-tag="untagged"])').length;
document.getElementById('tagged-count').textContent = `${tagged} / ${total} tagged`;
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-4', 'border-base-300', 'opacity-40');
updateBadge(el, 'skip');
});
this.updateCount();
},
async done() {
const res = await fetch('/triage/done', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId }),
});
const data = await res.json();
if (data.redirect) window.location = data.redirect;
},
};
}
// ── 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();
updateThumbStrip();
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();
updateThumbStrip();
}
// ── 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();
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, buildThumbStrip, updateThumbStrip };
}
// ── 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.buildThumbStrip();
mobileApp.showCard();
}
// Desktop: nothing extra needed — Alpine handles it
});
</script>
{% endblock %}