feat: Phase 5 write with autosave, journal/story modes, skip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 16:54:15 +02:00
parent b5c90a1e81
commit 02c772f321
4 changed files with 339 additions and 1 deletions
+2 -1
View File
@@ -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_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, curate, group from .routes import albums, triage, proxy, notes, nav, curate, group, write
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)
@@ -16,6 +16,7 @@ def create_app(state_dir=None, pages_dir=None):
app.register_blueprint(nav.bp) app.register_blueprint(nav.bp)
app.register_blueprint(curate.bp) app.register_blueprint(curate.bp)
app.register_blueprint(group.bp) app.register_blueprint(group.bp)
app.register_blueprint(write.bp)
@app.get("/health") @app.get("/health")
def health(): def health():
@@ -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}"})
@@ -0,0 +1,196 @@
{% 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">Write</h1>
<span class="text-sm opacity-60">{{ done_count }} / {{ total }} done</span>
</div>
{% if not group %}
<div class="alert alert-success">All groups written or skipped. <a href="/export?album_id={{ album_id }}" class="link">Continue to export →</a></div>
{% else %}
<div class="flex gap-4">
<!-- Photos panel -->
<div class="group-photos w-64 flex-shrink-0 space-y-2 overflow-y-auto max-h-[80vh]">
{% for photo in photos %}
<img src="/proxy/thumb/{{ photo.id }}"
id="photo-{{ photo.id }}"
class="w-full rounded cursor-pointer border-4 border-transparent transition"
onclick="setHero('{{ photo.id }}')"
alt="">
{% endfor %}
</div>
<!-- Form -->
<div class="flex-1 space-y-4">
<!-- Mode switch -->
<div class="tabs">
<button id="mode-journal" class="tab tab-bordered tab-active"
onclick="setMode('journal')">Journal</button>
<button id="mode-story" class="tab tab-bordered"
onclick="setMode('story')">Story</button>
</div>
<div class="form-control">
<label class="label text-sm">Title</label>
<input id="title-field" type="text" class="input input-bordered"
oninput="scheduleAutosave()"
value="{{ group.title | e }}">
</div>
<div class="form-control">
<label class="label text-sm">Date</label>
<input id="date-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.date | e }}">
</div>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label text-sm">City</label>
<input id="city-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.location_city | e }}">
</div>
<div class="form-control">
<label class="label text-sm">Country</label>
<input id="country-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.location_country | e }}">
</div>
</div>
<div class="form-control" id="mode-journal-fields">
<label class="label text-sm">Body</label>
<textarea id="body-field" class="textarea textarea-bordered h-40"
oninput="scheduleAutosave()">{{ group.body | e }}</textarea>
</div>
<!-- Story-only fields (hidden by default if mode is journal) -->
<div id="hero-picker" class="form-control" style="display:{% if group.entry_type == 'story' %}block{% else %}none{% endif %}">
<label class="label text-sm">Hero photo: <span id="hero-label">{{ group.hero_photo_id or 'none' }}</span></label>
<p class="text-xs opacity-60">Click a photo on the left to set it as the hero.</p>
</div>
<div id="shortcode-field-wrap" class="form-control" style="display:{% if group.entry_type == 'story' %}block{% else %}none{% endif %}">
<label class="label text-sm">Shortcode hints</label>
<input id="shortcode-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
placeholder="e.g. gallery block, pull quote"
value="{{ group.shortcode_hints | e }}">
</div>
<div class="flex gap-2 mt-4">
{% if group_idx > 0 %}
<a href="/write?album_id={{ album_id }}&group_idx={{ group_idx - 1 }}" class="btn btn-ghost btn-sm">← Prev</a>
{% endif %}
<button id="skip-btn" class="btn btn-ghost btn-sm" onclick="skipGroup()">Skip for now</button>
<button class="btn btn-primary btn-sm ml-auto" onclick="saveAndNext()">Save &amp; next</button>
</div>
</div>
<!-- Inline notes -->
<div id="inline-notes" class="w-64 flex-shrink-0 bg-base-100 rounded p-3">
<h3 class="font-semibold text-sm mb-2">Your notes</h3>
<p class="text-xs opacity-70 whitespace-pre-wrap">{{ state.notes or 'No notes yet.' }}</p>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function() {
var albumId = {{ album_id | tojson }};
var groupId = {{ group.id | tojson }};
var mode = {{ group.entry_type | tojson }};
var heroId = {{ group.hero_photo_id | tojson }};
var autosaveTimer = null;
window.setMode = function(m) {
mode = m;
var storyFields = ['hero-picker', 'shortcode-field-wrap'];
storyFields.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.style.display = (m === 'story') ? 'block' : 'none';
});
document.getElementById('mode-journal').classList.toggle('tab-active', m === 'journal');
document.getElementById('mode-story').classList.toggle('tab-active', m === 'story');
scheduleAutosave();
};
window.setHero = function(id) {
heroId = id;
// Update border highlight
document.querySelectorAll('.group-photos img').forEach(function(img) {
img.classList.remove('border-primary');
img.classList.add('border-transparent');
});
var el = document.getElementById('photo-' + id);
if (el) { el.classList.remove('border-transparent'); el.classList.add('border-primary'); }
var label = document.getElementById('hero-label');
if (label) label.textContent = id;
scheduleAutosave();
};
window.scheduleAutosave = function() {
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(doAutosave, 500);
};
function getFormData() {
return {
album_id: albumId,
group_id: groupId,
entry_type: mode,
hero_photo_id: heroId,
title: document.getElementById('title-field') ? document.getElementById('title-field').value : '',
body: document.getElementById('body-field') ? document.getElementById('body-field').value : '',
location_city: document.getElementById('city-field') ? document.getElementById('city-field').value : '',
location_country: document.getElementById('country-field') ? document.getElementById('country-field').value : '',
date: document.getElementById('date-field') ? document.getElementById('date-field').value : '',
shortcode_hints: document.getElementById('shortcode-field') ? document.getElementById('shortcode-field').value : '',
};
}
function doAutosave() {
fetch('/write/autosave', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getFormData()),
});
}
window.skipGroup = function() {
fetch('/write/skip', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, group_id: groupId}),
}).then(function() {
window.location.reload();
});
};
window.saveAndNext = function() {
fetch('/write/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getFormData()),
}).then(function() {
var url = new URL(window.location.href);
var idx = parseInt(url.searchParams.get('group_idx') || '0');
url.searchParams.set('group_idx', idx + 1);
window.location.href = url.toString();
});
};
// Initialize mode display
if (mode === 'story') {
document.getElementById('mode-story') && document.getElementById('mode-story').classList.add('tab-active');
document.getElementById('mode-journal') && document.getElementById('mode-journal').classList.remove('tab-active');
}
})();
</script>
{% endblock %}
@@ -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()