a8804547e7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
629 lines
23 KiB
HTML
629 lines
23 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 →
|
|
</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()">✕</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)">‹</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)">›</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">← 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>
|
|
|
|
{# 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-success');
|
|
} else if (tag === 'story') {
|
|
el.classList.add('border-info');
|
|
} else {
|
|
el.classList.add('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-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 %}
|