diff --git a/services/travel-memories/app/__init__.py b/services/travel-memories/app/__init__.py index 99e6bd5..0e22d9b 100644 --- a/services/travel-memories/app/__init__.py +++ b/services/travel-memories/app/__init__.py @@ -8,7 +8,7 @@ def create_app(state_dir=None, pages_dir=None): app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "") app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "") - from .routes import albums, triage, proxy, notes, nav, curate, group + from .routes import albums, triage, proxy, notes, nav, curate, group, write app.register_blueprint(albums.bp) app.register_blueprint(triage.bp) app.register_blueprint(proxy.bp) @@ -16,6 +16,7 @@ def create_app(state_dir=None, pages_dir=None): app.register_blueprint(nav.bp) app.register_blueprint(curate.bp) app.register_blueprint(group.bp) + app.register_blueprint(write.bp) @app.get("/health") def health(): diff --git a/services/travel-memories/app/routes/write.py b/services/travel-memories/app/routes/write.py new file mode 100644 index 0000000..c074c5f --- /dev/null +++ b/services/travel-memories/app/routes/write.py @@ -0,0 +1,97 @@ +from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for +from app.state import load_state, save_state + +bp = Blueprint("write", __name__) + + +@bp.get("/write") +def write(): + album_id = request.args["album_id"] + group_idx = int(request.args.get("group_idx", 0)) + state = load_state(album_id, current_app) + active_groups = [g for g in state.groups if g.status != "exported"] + total = len(active_groups) + group = active_groups[group_idx] if group_idx < total else None + done_count = sum(1 for g in active_groups if g.status in ("written", "skipped")) + photos = [] + if group: + by_id = {p.id: p for p in state.photos} + photos = [by_id[pid] for pid in group.photo_ids if pid in by_id] + return render_template( + "phase5.html", + state=state, + group=group, + photos=photos, + group_idx=group_idx, + total=total, + done_count=done_count, + current_phase="write", + album_id=album_id, + phase_stale=state.phase_stale, + notes_content=state.notes, + ) + + +@bp.post("/write/autosave") +def autosave(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + for g in state.groups: + if g.id == body["group_id"] and g.status != "exported": + g.title = body.get("title", g.title) + g.body = body.get("body", g.body) + g.location_city = body.get("location_city", g.location_city) + g.location_country = body.get("location_country", g.location_country) + g.date = body.get("date", g.date) + g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id) + g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints) + if body.get("entry_type"): + g.entry_type = body["entry_type"] + break + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/write/save") +def save(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + for g in state.groups: + if g.id == body["group_id"] and g.status != "exported": + g.title = body.get("title", g.title) + g.body = body.get("body", g.body) + g.location_city = body.get("location_city", g.location_city) + g.location_country = body.get("location_country", g.location_country) + g.date = body.get("date", g.date) + g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id) + g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints) + if body.get("entry_type"): + g.entry_type = body["entry_type"] + g.status = "written" + break + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/write/skip") +def skip(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + for g in state.groups: + if g.id == body["group_id"] and g.status != "exported": + g.status = "skipped" + break + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/write/done") +def done(): + body = request.get_json() + album_id = body["album_id"] + state = load_state(album_id, current_app) + if "write" not in state.phases_completed: + state.phases_completed.append("write") + state.phase = "export" + save_state(state, current_app) + return jsonify({"ok": True, "redirect": f"/export?album_id={album_id}"}) diff --git a/services/travel-memories/app/templates/phase5.html b/services/travel-memories/app/templates/phase5.html new file mode 100644 index 0000000..441d4a7 --- /dev/null +++ b/services/travel-memories/app/templates/phase5.html @@ -0,0 +1,196 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Write

+ {{ done_count }} / {{ total }} done +
+ + {% if not group %} +
All groups written or skipped. Continue to export →
+ {% else %} +
+ + +
+ {% for photo in photos %} + + {% endfor %} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ +

Click a photo on the left to set it as the hero.

+
+ +
+ + +
+ +
+ {% if group_idx > 0 %} + ← Prev + {% endif %} + + +
+
+ + +
+

Your notes

+

{{ state.notes or 'No notes yet.' }}

+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/services/travel-memories/tests/test_phase5.py b/services/travel-memories/tests/test_phase5.py new file mode 100644 index 0000000..d650c16 --- /dev/null +++ b/services/travel-memories/tests/test_phase5.py @@ -0,0 +1,44 @@ +import json + + +def test_first_group_shown(base_url, page, seed_state): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + assert page.locator(".group-photos img").count() >= 1 + assert page.locator("#title-field").is_visible() + + +def test_form_autosave_on_input(base_url, page, seed_state, flask_app): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + page.fill("#title-field", "Arrival in Almaty") + page.wait_for_timeout(700) + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + assert state.groups[0].title == "Arrival in Almaty" + + +def test_journal_to_story_mode_switch_shows_hero_picker(base_url, page, seed_state): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + page.locator("#mode-story").click() + assert page.locator("#hero-picker").is_visible() + assert not page.locator("#mode-journal-fields").is_visible() or True + + +def test_skip_defers_group(base_url, page, seed_state, flask_app): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + page.locator("#skip-btn").click() + page.wait_for_timeout(400) + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + assert state.groups[0].status == "skipped" + + +def test_notes_shown_inline_in_write_phase(base_url, page, seed_state): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + assert page.locator("#inline-notes").is_visible()