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:
@@ -8,8 +8,9 @@ def create_app(state_dir=None, pages_dir=None):
|
|||||||
app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "")
|
app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "")
|
||||||
app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "")
|
app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "")
|
||||||
|
|
||||||
from .routes import albums, proxy, notes, nav
|
from .routes import albums, triage, proxy, notes, nav
|
||||||
app.register_blueprint(albums.bp)
|
app.register_blueprint(albums.bp)
|
||||||
|
app.register_blueprint(triage.bp)
|
||||||
app.register_blueprint(proxy.bp)
|
app.register_blueprint(proxy.bp)
|
||||||
app.register_blueprint(notes.bp)
|
app.register_blueprint(notes.bp)
|
||||||
app.register_blueprint(nav.bp)
|
app.register_blueprint(nav.bp)
|
||||||
|
|||||||
@@ -70,22 +70,3 @@ def select():
|
|||||||
save_state(state, current_app)
|
save_state(state, current_app)
|
||||||
return redirect(f"/triage?album_id={primary_id}")
|
return redirect(f"/triage?album_id={primary_id}")
|
||||||
|
|
||||||
|
|
||||||
# TODO(task-6): replace this stub with the real triage route
|
|
||||||
@bp.get("/triage")
|
|
||||||
def triage():
|
|
||||||
album_id = request.args.get("album_id", "")
|
|
||||||
notes_content = ""
|
|
||||||
phase_stale = []
|
|
||||||
if album_id:
|
|
||||||
state = load_state(album_id, current_app)
|
|
||||||
if state:
|
|
||||||
notes_content = state.notes
|
|
||||||
phase_stale = state.phase_stale
|
|
||||||
return render_template(
|
|
||||||
"base.html",
|
|
||||||
current_phase="triage",
|
|
||||||
album_id=album_id,
|
|
||||||
notes_content=notes_content,
|
|
||||||
phase_stale=phase_stale,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
|
||||||
|
from app.state import load_state, save_state
|
||||||
|
|
||||||
|
bp = Blueprint("triage", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/triage")
|
||||||
|
def triage():
|
||||||
|
album_id = request.args["album_id"]
|
||||||
|
state = load_state(album_id, current_app)
|
||||||
|
photos_by_day = {}
|
||||||
|
for p in state.photos:
|
||||||
|
day = p.local_datetime[:10]
|
||||||
|
photos_by_day.setdefault(day, []).append(p)
|
||||||
|
all_tagged = all(p.tag != "untagged" for p in state.photos)
|
||||||
|
return render_template(
|
||||||
|
"phase2.html",
|
||||||
|
state=state,
|
||||||
|
photos_by_day=photos_by_day,
|
||||||
|
all_tagged=all_tagged,
|
||||||
|
current_phase="triage",
|
||||||
|
album_id=album_id,
|
||||||
|
phase_stale=state.phase_stale,
|
||||||
|
notes_content=state.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/triage/tag")
|
||||||
|
def tag():
|
||||||
|
body = request.get_json()
|
||||||
|
state = load_state(body["album_id"], current_app)
|
||||||
|
for p in state.photos:
|
||||||
|
if p.id == body["asset_id"]:
|
||||||
|
p.tag = body["tag"]
|
||||||
|
break
|
||||||
|
save_state(state, current_app)
|
||||||
|
tagged_count = sum(1 for p in state.photos if p.tag != "untagged")
|
||||||
|
return jsonify({"ok": True, "tagged_count": tagged_count, "total": len(state.photos)})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/triage/done")
|
||||||
|
def done():
|
||||||
|
body = request.get_json()
|
||||||
|
state = load_state(body["album_id"], current_app)
|
||||||
|
if not all(p.tag != "untagged" for p in state.photos):
|
||||||
|
return jsonify({"error": "not all tagged"}), 400
|
||||||
|
if "triage" not in state.phases_completed:
|
||||||
|
state.phases_completed.append("triage")
|
||||||
|
state.phase = "curate"
|
||||||
|
save_state(state, current_app)
|
||||||
|
return jsonify({"ok": True, "redirect": f"/curate?album_id={body['album_id']}"})
|
||||||
@@ -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 →
|
||||||
|
</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 %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def test_photos_render_in_day_groups(base_url, page, seed_state):
|
||||||
|
album_id = seed_state("phase2_state")
|
||||||
|
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||||
|
assert page.locator(".day-group").count() >= 1
|
||||||
|
assert page.locator(".photo-card").count() == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_j_tags_journal(base_url, page, seed_state):
|
||||||
|
album_id = seed_state("phase2_state")
|
||||||
|
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||||
|
page.locator(".photo-card").first.click()
|
||||||
|
page.keyboard.press("j")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
card = page.locator(".photo-card").first
|
||||||
|
assert "border-success" in card.get_attribute("class")
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_s_tags_story(base_url, page, seed_state):
|
||||||
|
album_id = seed_state("phase2_state")
|
||||||
|
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||||
|
page.locator(".photo-card").first.click()
|
||||||
|
page.keyboard.press("s")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
assert "border-info" in page.locator(".photo-card").first.get_attribute("class")
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_button_disabled_until_all_tagged(base_url, page, seed_state):
|
||||||
|
album_id = seed_state("phase2_state")
|
||||||
|
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||||
|
assert page.locator("#done-btn").is_disabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_advances_to_curate(base_url, page, seed_state):
|
||||||
|
album_id = seed_state("phase3_state") # all tagged
|
||||||
|
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||||
|
page.locator("#done-btn").click()
|
||||||
|
page.wait_for_url("**/curate**")
|
||||||
Reference in New Issue
Block a user