feat: drag-and-drop reorder for daily focus items

Add HTML5 drag-and-drop within domain groups on the focus page.
Items can be dragged to reorder within their domain; cross-domain
drag is prevented. Uses hidden form POST (no fetch/XHR). Arrow
buttons kept as fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 15:58:05 +00:00
parent 4c072beec0
commit 1a6b3fac1d
3 changed files with 94 additions and 2 deletions

View File

@@ -222,6 +222,20 @@ async def reorder_focus(
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/reorder-all")
async def reorder_all_focus(
request: Request,
item_ids: str = Form(...),
focus_date: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
ids = [i.strip() for i in item_ids.split(",") if i.strip()]
if ids:
await repo.reorder(ids)
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/{focus_id}/toggle")
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)

View File

@@ -1107,6 +1107,11 @@ a:hover { color: var(--accent-hover); }
transition: width 0.3s;
}
/* ---- Focus Drag-and-Drop ---- */
.focus-drag-row { cursor: grab; }
.focus-drag-row.dragging { opacity: 0.4; background: var(--accent-soft); }
.focus-drag-row.drag-over { box-shadow: 0 -2px 0 0 var(--accent) inset; }
/* ---- Reorder Grip Handle ---- */
.reorder-grip {
display: inline-flex;

View File

@@ -15,20 +15,23 @@
<!-- Focus items grouped by domain -->
{% if items %}
<div class="card">
<table style="width:100%;border-collapse:collapse;font-size:0.8rem;">
<table id="focus-table" style="width:100%;border-collapse:collapse;font-size:0.8rem;">
<colgroup>
<col style="width:24px"><col style="width:20px"><col style="width:74px"><col style="width:10px">
<col><col style="width:110px"><col style="width:120px"><col style="width:50px"><col style="width:24px">
</colgroup>
{% for domain in hierarchy %}
<tbody>
<tr><td colspan="9" style="padding:2px 8px;border-bottom:1px solid var(--border);background:var(--surface2);">
<span style="display:inline-flex;align-items:center;gap:6px;">
<span style="width:8px;height:8px;border-radius:50%;background:{{ domain.color or 'var(--accent)' }};flex-shrink:0;"></span>
<span style="font-weight:700;font-size:0.8rem;letter-spacing:0.03em;text-transform:uppercase;color:var(--text)">{{ domain.label }}</span>
</span>
</td></tr>
</tbody>
<tbody class="focus-drag-group">
{% for item in domain.rows %}
<tr style="border-bottom:1px solid var(--border);{{ 'opacity:0.6;' if item.completed }}">
<tr class="focus-drag-row" draggable="true" data-id="{{ item.id }}" style="border-bottom:1px solid var(--border);{{ 'opacity:0.6;' if item.completed }}">
<td style="padding:1px 1px;vertical-align:middle;">{% with reorder_url="/focus/reorder", item_id=item.id, extra_fields={"focus_date": focus_date|string} %}{% include 'partials/reorder_arrows.html' %}{% endwith %}</td>
<td style="padding:1px 1px;vertical-align:middle;">
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
@@ -48,8 +51,13 @@
</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
<form id="focus-reorder-form" action="/focus/reorder-all" method="post" style="display:none;">
<input type="hidden" name="item_ids" id="focus-reorder-ids">
<input type="hidden" name="focus_date" value="{{ focus_date }}">
</form>
</div>
{% else %}
<div class="empty-state mb-4"><div class="empty-state-text">No focus items for this day</div></div>
@@ -182,6 +190,71 @@
document.getElementById('focus-show-more-wrap').style.display = 'none';
});
}
// Drag-and-drop reorder within domain groups
var dragRow = null;
var dragGroup = null;
document.querySelectorAll('.focus-drag-row').forEach(function(row) {
row.addEventListener('dragstart', function(e) {
dragRow = row;
dragGroup = row.closest('.focus-drag-group');
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', row.dataset.id);
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
document.querySelectorAll('.focus-drag-row.drag-over').forEach(function(el) {
el.classList.remove('drag-over');
});
// Collect all IDs across all groups in DOM order and submit
if (dragRow) {
var allIds = [];
document.querySelectorAll('#focus-table .focus-drag-row').forEach(function(r) {
allIds.push(r.dataset.id);
});
document.getElementById('focus-reorder-ids').value = allIds.join(',');
document.getElementById('focus-reorder-form').submit();
}
dragRow = null;
dragGroup = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
if (!dragRow || row === dragRow) return;
// Constrain to same domain group
if (row.closest('.focus-drag-group') !== dragGroup) return;
e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.focus-drag-row.drag-over').forEach(function(el) {
el.classList.remove('drag-over');
});
row.classList.add('drag-over');
});
row.addEventListener('dragleave', function() {
row.classList.remove('drag-over');
});
row.addEventListener('drop', function(e) {
e.preventDefault();
if (!dragRow || row === dragRow) return;
if (row.closest('.focus-drag-group') !== dragGroup) return;
row.classList.remove('drag-over');
// Insert dragged row before or after target based on position
var tbody = dragGroup;
var rows = Array.from(tbody.querySelectorAll('.focus-drag-row'));
var dragIdx = rows.indexOf(dragRow);
var targetIdx = rows.indexOf(row);
if (dragIdx < targetIdx) {
tbody.insertBefore(dragRow, row.nextSibling);
} else {
tbody.insertBefore(dragRow, row);
}
});
});
})();
</script>
{% endblock %}