feat: Phase 2 triage with keyboard shortcuts J/S/X

Implement /triage GET/POST routes in triage.py blueprint; render
phase2.html with day-grouped photo grid, Alpine.js keyboard tagging
(J=journal, S=story, X/Space=skip), and done-button gated on all-tagged.
Remove stub from albums.py; register triage.bp in __init__.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 16:26:53 +02:00
parent 7b7810cc59
commit a6a2b31c43
5 changed files with 206 additions and 20 deletions
@@ -0,0 +1,113 @@
{% 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')">
<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 id="done-btn"
class="btn btn-primary btn-sm"
{% if not all_tagged %}disabled{% endif %}
@click="done()">
Done triaging &rarr;
</button>
</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] }}
</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>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function triageApp(albumId) {
return {
focused: null,
select(el) {
this.focused = el;
},
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(' ')
.filter(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');
}
this.updateCount();
},
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 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;
},
};
}
</script>
{% endblock %}