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:
@@ -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 & 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()
|
||||||
Reference in New Issue
Block a user