feat: Phase 4 grouping with entry-break dividers

Add group.py route, phase4.html template, and supporting state changes.
Photos are shown as a flat stream; clicking divider zones inserts
entry-break boundaries that split photos into labelled groups. Labels
persist via group_labels dict. Done materialises groups into state.groups
and advances to write phase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 16:44:54 +02:00
parent 23b68d845b
commit b5c90a1e81
10 changed files with 286 additions and 10 deletions
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-3xl mx-auto" x-data="groupApp('{{ album_id }}')">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Group</h1>
<button id="done-btn" class="btn btn-primary btn-sm" @click="done()">Grouping done &rarr;</button>
</div>
<div class="space-y-1">
{% for grp in groups %}
<div class="group-block border border-base-300 rounded-lg p-2 space-y-1">
{% if grp.label %}
<div class="text-xs font-semibold opacity-70 px-1">{{ grp.label }}</div>
{% endif %}
{% for photo in grp.photos %}
<div class="stream-photo flex items-center gap-3 bg-base-100 rounded p-1"
data-order="{{ photo.order }}">
<img src="/proxy/thumb/{{ photo.id }}" class="w-16 h-16 object-cover rounded">
<span class="text-xs opacity-60">{{ photo.local_datetime[11:16] }}</span>
<span class="badge badge-xs {% if photo.tag == 'story' %}badge-info{% else %}badge-success{% endif %}">
{{ photo.tag }}
</span>
</div>
{% if not loop.last %}
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
data-after-order="{{ photo.order }}">
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
@click="addDivider({{ photo.order }})">&#x2702; cut here</button>
</div>
{% endif %}
{% endfor %}
</div>
{% if grp.divider_id %}
<div class="flex items-center gap-2 my-1 px-1">
<input class="group-label input input-sm input-bordered flex-1"
value="{{ grp.label }}"
placeholder="Label this entry&#x2026;"
@change="setLabel('{{ grp.divider_id }}', $el.value)"
@keydown.enter="$el.blur()">
<button class="remove-divider-btn btn btn-xs btn-ghost opacity-60"
@click="removeDivider('{{ grp.divider_id }}')">&#x2715;</button>
</div>
{% endif %}
{% if not loop.last and not grp.divider_id %}
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
data-after-order="{{ grp.photos[-1].order }}">
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
@click="addDivider({{ grp.photos[-1].order }})">&#x2702; cut here</button>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function groupApp(albumId) {
return {
async addDivider(afterOrder) {
await fetch('/group/divider', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, after_order: afterOrder})
});
window.location.reload();
},
async removeDivider(dividerId) {
await fetch('/group/remove-divider', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, divider_id: dividerId})
});
window.location.reload();
},
async setLabel(dividerId, label) {
await fetch('/group/label', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, divider_id: dividerId, label: label})
});
},
async done() {
var res = await fetch('/group/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 %}