feat: Phase 3 curate with remove, retag, drag reorder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,13 @@ 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, triage, proxy, notes, nav
|
from .routes import albums, triage, proxy, notes, nav, curate
|
||||||
app.register_blueprint(albums.bp)
|
app.register_blueprint(albums.bp)
|
||||||
app.register_blueprint(triage.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)
|
||||||
|
app.register_blueprint(curate.bp)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||||
|
from app.state import load_state, save_state
|
||||||
|
|
||||||
|
bp = Blueprint("curate", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/curate")
|
||||||
|
def curate():
|
||||||
|
album_id = request.args["album_id"]
|
||||||
|
state = load_state(album_id, current_app)
|
||||||
|
kept = [p for p in state.photos if p.tag in ("journal", "story")]
|
||||||
|
photos_by_day = {}
|
||||||
|
for p in kept:
|
||||||
|
day = p.local_datetime[:10]
|
||||||
|
photos_by_day.setdefault(day, []).append(p)
|
||||||
|
return render_template(
|
||||||
|
"phase3.html",
|
||||||
|
state=state,
|
||||||
|
photos_by_day=photos_by_day,
|
||||||
|
current_phase="curate",
|
||||||
|
album_id=album_id,
|
||||||
|
phase_stale=state.phase_stale,
|
||||||
|
notes_content=state.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/curate/remove")
|
||||||
|
def remove():
|
||||||
|
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 = "skip"
|
||||||
|
break
|
||||||
|
save_state(state, current_app)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/curate/retag")
|
||||||
|
def retag():
|
||||||
|
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 = "story" if p.tag == "journal" else "journal"
|
||||||
|
break
|
||||||
|
save_state(state, current_app)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/curate/reorder")
|
||||||
|
def reorder():
|
||||||
|
body = request.get_json()
|
||||||
|
state = load_state(body["album_id"], current_app)
|
||||||
|
order_map = {aid: i for i, aid in enumerate(body["ordered_ids"])}
|
||||||
|
for p in state.photos:
|
||||||
|
if p.id in order_map:
|
||||||
|
p.order = order_map[p.id]
|
||||||
|
save_state(state, current_app)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/curate/done")
|
||||||
|
def done():
|
||||||
|
body = request.get_json()
|
||||||
|
state = load_state(body["album_id"], current_app)
|
||||||
|
if "curate" not in state.phases_completed:
|
||||||
|
state.phases_completed.append("curate")
|
||||||
|
state.phase = "group"
|
||||||
|
save_state(state, current_app)
|
||||||
|
return jsonify({"ok": True, "redirect": f"/group?album_id={body['album_id']}"})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="p-4 max-w-6xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h1 class="text-xl font-bold">Curate</h1>
|
||||||
|
<button id="done-btn" class="btn btn-primary btn-sm" onclick="done()">
|
||||||
|
Curate done →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% for day, photos in photos_by_day.items() %}
|
||||||
|
<div class="day-group mb-6">
|
||||||
|
<h2 class="sticky top-16 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2" id="day-{{ day }}">
|
||||||
|
{% for photo in photos %}
|
||||||
|
<div class="photo-card relative w-32 h-32 rounded-lg overflow-hidden border-4
|
||||||
|
{% if photo.tag == 'story' %}border-info{% else %}border-success{% endif %}"
|
||||||
|
data-asset-id="{{ photo.id }}">
|
||||||
|
<img src="/proxy/thumb/{{ photo.id }}" class="w-full h-full object-cover" alt="">
|
||||||
|
<div class="absolute top-1 left-1 flex gap-1">
|
||||||
|
<button class="retag-btn btn btn-xs btn-ghost bg-black/40 text-white"
|
||||||
|
onclick="retag('{{ album_id }}', '{{ photo.id }}', this.closest('.photo-card'))">
|
||||||
|
{% if photo.tag == 'journal' %}→S{% else %}→J{% endif %}
|
||||||
|
</button>
|
||||||
|
<button class="remove-btn btn btn-xs btn-ghost bg-black/40 text-white"
|
||||||
|
onclick="removeFn('{{ album_id }}', '{{ photo.id }}', this.closest('.photo-card'))">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('[id^="day-"]').forEach(function(el) {
|
||||||
|
var albumId = new URLSearchParams(location.search).get('album_id');
|
||||||
|
Sortable.create(el, {
|
||||||
|
onEnd: function(e) {
|
||||||
|
reorder(albumId, e.to);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function removeFn(albumId, assetId, el) {
|
||||||
|
await fetch('/curate/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({album_id: albumId, asset_id: assetId})
|
||||||
|
});
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retag(albumId, assetId, el) {
|
||||||
|
await fetch('/curate/retag', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({album_id: albumId, asset_id: assetId})
|
||||||
|
});
|
||||||
|
el.classList.toggle('border-info');
|
||||||
|
el.classList.toggle('border-success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorder(albumId, container) {
|
||||||
|
var ids = Array.from(container.querySelectorAll('.photo-card')).map(function(e) {
|
||||||
|
return e.dataset.assetId;
|
||||||
|
});
|
||||||
|
await fetch('/curate/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({album_id: albumId, ordered_ids: ids})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function done() {
|
||||||
|
var albumId = new URLSearchParams(location.search).get('album_id');
|
||||||
|
var res = await fetch('/curate/done', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({album_id: albumId})
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (data.redirect) window.location = data.redirect;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_kept_photos_shown(base_url, page, seed_state):
|
||||||
|
album_id = seed_state("phase3_state")
|
||||||
|
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||||
|
# phase3_state has 2 kept (journal+story) and 1 skipped
|
||||||
|
assert page.locator(".photo-card").count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_reverts_to_skip(base_url, page, seed_state, flask_app):
|
||||||
|
album_id = seed_state("phase3_state")
|
||||||
|
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||||
|
page.locator(".remove-btn").first.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
assert page.locator(".photo-card").count() == 1
|
||||||
|
with flask_app.app_context():
|
||||||
|
from app.state import load_state
|
||||||
|
state = load_state(album_id, flask_app)
|
||||||
|
removed = next(p for p in state.photos if p.id == "asset-1")
|
||||||
|
assert removed.tag == "skip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retag_journal_to_story(base_url, page, seed_state, flask_app):
|
||||||
|
album_id = seed_state("phase3_state")
|
||||||
|
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||||
|
page.locator(".retag-btn").first.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
with flask_app.app_context():
|
||||||
|
from app.state import load_state
|
||||||
|
state = load_state(album_id, flask_app)
|
||||||
|
p = next(p for p in state.photos if p.id == "asset-1")
|
||||||
|
assert p.tag == "story"
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_advances_to_group(base_url, page, seed_state):
|
||||||
|
album_id = seed_state("phase3_state")
|
||||||
|
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||||
|
page.locator("#done-btn").click()
|
||||||
|
page.wait_for_url("**/group**")
|
||||||
Reference in New Issue
Block a user