feat: focus priority, focus links, task list assignment, lists drag-and-drop, URL display fixes

- Focus page: turn sequence number into persistent editable priority (focus_priority column)
- Focus detail: add links section (add existing, create new, unlink) via focus_links junction table
- Focus detail: add copy and inline edit for checklist items
- Task detail lists tab: add existing list assignment and unlink actions
- Lists page: add drag-and-drop reorder support
- Links/bookmarks pages: remove artificial URL truncation, use CSS ellipsis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:19:06 +00:00
parent 30cff10150
commit 590f019ca7
12 changed files with 291 additions and 19 deletions

View File

@@ -162,7 +162,7 @@ class BaseRepository:
"contact_id", "started_at", "contact_id", "started_at",
"weekly_hours", "effective_from", "weekly_hours", "effective_from",
"task_id", "meeting_id", "list_item_id", "task_id", "meeting_id", "list_item_id",
"domain_id", "title", "domain_id", "title", "focus_priority",
} }
clean_data = {} clean_data = {}
for k, v in data.items(): for k, v in data.items():

View File

@@ -63,7 +63,7 @@ async def focus_view(
WHERE df.is_deleted = false WHERE df.is_deleted = false
AND (t.id IS NULL OR t.is_deleted = false) AND (t.id IS NULL OR t.is_deleted = false)
AND (li.id IS NULL OR li.is_deleted = false) AND (li.id IS NULL OR li.is_deleted = false)
ORDER BY df.sort_order, df.created_at ORDER BY df.focus_priority ASC NULLS LAST, df.sort_order, df.created_at
""")) """))
items = [dict(r._mapping) for r in result] items = [dict(r._mapping) for r in result]
@@ -344,10 +344,23 @@ async def focus_detail(
"""), {"lid": list_id}) """), {"lid": list_id})
list_items = [dict(r._mapping) for r in result] list_items = [dict(r._mapping) for r in result]
# Load linked links
result = await db.execute(text("""
SELECT l.*, fl.role
FROM links l JOIN focus_links fl ON fl.link_id = l.id
WHERE fl.focus_id = :fid AND l.is_deleted = false
ORDER BY fl.created_at
"""), {"fid": focus_id})
linked_links = [dict(r._mapping) for r in result]
# All links for the "add existing" dropdown
all_links = await BaseRepository("links", db).list()
return templates.TemplateResponse("focus_detail.html", { return templates.TemplateResponse("focus_detail.html", {
"request": request, "sidebar": sidebar, "item": item, "request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "all_lists": all_lists, "domain": domain, "project": project, "all_lists": all_lists,
"note": note, "list_id": list_id, "list_items": list_items, "note": note, "list_id": list_id, "list_items": list_items,
"linked_links": linked_links, "all_links": all_links,
"page_title": item.get("title", "Focus Item"), "active_nav": "focus", "page_title": item.get("title", "Focus Item"), "active_nav": "focus",
}) })
@@ -392,6 +405,17 @@ async def toggle_focus_list_item(
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303) return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/list-item/{item_id}/edit")
async def edit_focus_list_item(
focus_id: str, item_id: str, request: Request,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
):
li_repo = BaseRepository("list_items", db)
await li_repo.update(item_id, {"content": content.strip()})
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/list-item/{item_id}/delete") @router.post("/{focus_id}/list-item/{item_id}/delete")
async def delete_focus_list_item( async def delete_focus_list_item(
focus_id: str, item_id: str, request: Request, focus_id: str, item_id: str, request: Request,
@@ -600,6 +624,44 @@ async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depen
return RedirectResponse(url=referer, status_code=303) return RedirectResponse(url=referer, status_code=303)
@router.post("/{focus_id}/set-priority")
async def set_focus_priority(focus_id: str, request: Request, focus_priority: Optional[str] = Form(None), db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
val = None
if focus_priority and focus_priority.strip():
try:
val = int(focus_priority.strip())
except ValueError:
pass
await repo.update(focus_id, {"focus_priority": val})
return RedirectResponse(url="/focus", status_code=303)
@router.post("/{focus_id}/links/add")
async def add_focus_link(
focus_id: str,
link_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO focus_links (focus_id, link_id, role)
VALUES (:fid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"fid": focus_id, "lid": link_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/links/{link_id}/remove")
async def remove_focus_link(
focus_id: str, link_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM focus_links WHERE focus_id = :fid AND link_id = :lid"
), {"fid": focus_id, "lid": link_id})
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/toggle-critical") @router.post("/{focus_id}/toggle-critical")
async def toggle_critical(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)): async def toggle_critical(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db) repo = BaseRepository("daily_focus", db)

View File

@@ -53,6 +53,7 @@ async def create_form(
task_id: Optional[str] = None, task_id: Optional[str] = None,
meeting_id: Optional[str] = None, meeting_id: Optional[str] = None,
contact_id: Optional[str] = None, contact_id: Optional[str] = None,
focus_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
@@ -69,6 +70,7 @@ async def create_form(
"prefill_task_id": task_id or "", "prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "", "prefill_meeting_id": meeting_id or "",
"prefill_contact_id": contact_id or "", "prefill_contact_id": contact_id or "",
"prefill_focus_id": focus_id or "",
}) })
@@ -78,6 +80,7 @@ async def create_link(
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None), domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None), task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None), contact_id: Optional[str] = Form(None),
focus_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), tags: Optional[str] = Form(None), description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -111,6 +114,15 @@ async def create_link(
await db.commit() await db.commit()
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303) return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
# Attach to focus item if created from focus context
if focus_id and focus_id.strip():
await db.execute(text("""
INSERT INTO focus_links (focus_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": focus_id, "lid": link["id"]})
await db.commit()
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
# Redirect back to context if created from task/meeting/project # Redirect back to context if created from task/meeting/project
if task_id and task_id.strip(): if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303) return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)

View File

@@ -375,6 +375,19 @@ async def reorder_list(
return RedirectResponse(url=request.headers.get("referer", "/lists"), status_code=303) return RedirectResponse(url=request.headers.get("referer", "/lists"), status_code=303)
@router.post("/reorder-all")
async def reorder_all_lists(
request: Request,
item_ids: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("lists", db)
ids = [i.strip() for i in item_ids.split(",") if i.strip()]
if ids:
await repo.reorder(ids)
return RedirectResponse(url=request.headers.get("referer", "/lists"), status_code=303)
@router.post("/{list_id}/items/reorder") @router.post("/{list_id}/items/reorder")
async def reorder_list_item( async def reorder_list_item(
list_id: str, list_id: str,

View File

@@ -235,6 +235,7 @@ async def task_detail(
# Tab-specific data # Tab-specific data
tab_data = [] tab_data = []
all_contacts = [] all_contacts = []
all_lists = []
if tab == "notes": if tab == "notes":
result = await db.execute(text(""" result = await db.execute(text("""
@@ -268,6 +269,13 @@ async def task_detail(
ORDER BY l.sort_order, l.created_at DESC ORDER BY l.sort_order, l.created_at DESC
"""), {"tid": task_id}) """), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result] tab_data = [dict(r._mapping) for r in result]
# Available lists for add dropdown (not already assigned to this task)
result = await db.execute(text("""
SELECT id, name FROM lists
WHERE is_deleted = false AND (task_id IS NULL OR task_id != :tid)
ORDER BY name
"""), {"tid": task_id})
all_lists = [dict(r._mapping) for r in result]
elif tab == "decisions": elif tab == "decisions":
result = await db.execute(text(""" result = await db.execute(text("""
@@ -315,7 +323,7 @@ async def task_detail(
"request": request, "sidebar": sidebar, "item": item, "request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent, "domain": domain, "project": project, "parent": parent,
"subtasks": subtasks, "tab": tab, "tab_data": tab_data, "subtasks": subtasks, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "counts": counts, "all_contacts": all_contacts, "all_lists": all_lists, "counts": counts,
"running_task_id": running_task_id, "running_task_id": running_task_id,
"page_title": item["title"], "active_nav": "tasks", "page_title": item["title"], "active_nav": "tasks",
}) })
@@ -471,6 +479,31 @@ async def quick_add(
# ---- Contact linking ---- # ---- Contact linking ----
@router.post("/{task_id}/lists/add")
async def add_list_to_task(
task_id: str,
list_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"UPDATE lists SET task_id = :tid, updated_at = now() WHERE id = :lid"
), {"tid": task_id, "lid": list_id})
await db.commit()
return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303)
@router.post("/{task_id}/lists/{list_id}/remove")
async def remove_list_from_task(
task_id: str, list_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"UPDATE lists SET task_id = NULL, updated_at = now() WHERE id = :lid AND task_id = :tid"
), {"tid": task_id, "lid": list_id})
await db.commit()
return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303)
@router.post("/{task_id}/contacts/add") @router.post("/{task_id}/contacts/add")
async def add_contact( async def add_contact(
task_id: str, task_id: str,

View File

@@ -29,7 +29,7 @@
</colgroup> </colgroup>
<thead> <thead>
<tr style="border-bottom:2px solid var(--border);background:var(--surface2);"> <tr style="border-bottom:2px solid var(--border);background:var(--surface2);">
<th class="focus-sort-head" data-col="0" style="padding:3px 2px;font-size:0.7rem;font-weight:600;color:var(--muted);text-align:center;cursor:pointer;" title="Sort by order">#</th> <th class="focus-sort-head" data-col="0" style="padding:3px 2px;font-size:0.7rem;font-weight:600;color:var(--muted);text-align:center;cursor:pointer;" title="Sort by priority">P#</th>
<th style="padding:3px 1px;"></th> <th style="padding:3px 1px;"></th>
<th class="focus-sort-head" data-col="2" style="padding:3px 1px;font-size:0.7rem;font-weight:600;color:var(--muted);cursor:pointer;" title="Sort by done">Done</th> <th class="focus-sort-head" data-col="2" style="padding:3px 1px;font-size:0.7rem;font-weight:600;color:var(--muted);cursor:pointer;" title="Sort by done">Done</th>
<th class="focus-sort-head" data-col="3" style="padding:3px 1px;font-size:0.7rem;font-weight:600;color:var(--muted);cursor:pointer;text-align:center;" title="Sort by critical">!</th> <th class="focus-sort-head" data-col="3" style="padding:3px 1px;font-size:0.7rem;font-weight:600;color:var(--muted);cursor:pointer;text-align:center;" title="Sort by critical">!</th>
@@ -46,7 +46,11 @@
<tbody class="focus-drag-group"> <tbody class="focus-drag-group">
{% for item in items %} {% for item in items %}
<tr class="focus-drag-row" draggable="true" data-id="{{ item.id }}" 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 class="focus-row-num" data-sort="{{ '%04d'|format(loop.index) }}" style="padding:1px 2px;vertical-align:middle;text-align:center;color:var(--muted);font-size:0.72rem;font-weight:600;">{{ loop.index }}</td> <td class="focus-row-num" data-sort="{{ '%04d'|format(item.focus_priority or 9999) }}" style="padding:1px 0;vertical-align:middle;text-align:center;">
<form action="/focus/{{ item.id }}/set-priority" method="post" style="display:inline;margin:0;">
<input type="number" name="focus_priority" value="{{ item.focus_priority if item.focus_priority is not none else '' }}" min="1" max="999" style="width:30px;padding:0 1px;font-size:0.72rem;font-weight:600;text-align:center;border:1px solid transparent;background:transparent;color:var(--muted);border-radius:3px;" onfocus="this.style.borderColor='var(--accent)';this.style.background='var(--surface2)'" onblur="this.style.borderColor='transparent';this.style.background='transparent';if(this.defaultValue!==this.value)this.form.submit()">
</form>
</td>
<td style="padding:1px 1px;vertical-align:middle;">{% with reorder_url="/focus/reorder", item_id=item.id %}{% include 'partials/reorder_arrows.html' %}{% endwith %}</td> <td style="padding:1px 1px;vertical-align:middle;">{% with reorder_url="/focus/reorder", item_id=item.id %}{% include 'partials/reorder_arrows.html' %}{% endwith %}</td>
<td data-sort="{{ '1' if item.completed else '0' }}" style="padding:1px 1px;vertical-align:middle;"> <td data-sort="{{ '1' if item.completed else '0' }}" style="padding:1px 1px;vertical-align:middle;">
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline"> <form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
@@ -207,13 +211,8 @@
}); });
} }
// Renumber rows
function renumberFocusRows() { function renumberFocusRows() {
var rows = document.querySelectorAll('.focus-drag-row'); // No-op: priority is user-set, not auto-numbered
rows.forEach(function(row, i) {
var numCell = row.querySelector('.focus-row-num');
if (numCell) numCell.textContent = i + 1;
});
} }
// Drag-and-drop reorder // Drag-and-drop reorder

View File

@@ -94,7 +94,14 @@
<label for="li-{{ li.id }}"></label> <label for="li-{{ li.id }}"></label>
</div> </div>
</form> </form>
<span style="flex:1;font-size:0.82rem;{{ 'text-decoration:line-through;' if li.completed }}">{{ li.content }}</span> <span class="cl-display-{{ li.id }}" style="flex:1;font-size:0.82rem;{{ 'text-decoration:line-through;' if li.completed }}">{{ li.content }}</span>
<form class="cl-edit-{{ li.id }}" action="/focus/{{ item.id }}/list-item/{{ li.id }}/edit" method="post" style="display:none;flex:1;gap:4px;align-items:center;">
<input type="text" name="content" value="{{ li.content }}" class="form-input" style="flex:1;padding:2px 6px;font-size:0.82rem;" required>
<button type="submit" class="btn btn-primary btn-xs">Save</button>
<button type="button" class="btn btn-ghost btn-xs cl-edit-cancel" data-id="{{ li.id }}">Cancel</button>
</form>
<button type="button" class="btn btn-ghost btn-xs cl-copy-btn" data-text="{{ li.content|e }}" title="Copy" style="color:var(--muted);padding:0 3px;">&#128203;</button>
<button type="button" class="btn btn-ghost btn-xs cl-edit-btn" data-id="{{ li.id }}" title="Edit" style="color:var(--muted);padding:0 3px;">&#9998;</button>
<form action="/focus/{{ item.id }}/list-item/{{ li.id }}/delete" method="post" style="display:inline"> <form action="/focus/{{ item.id }}/list-item/{{ li.id }}/delete" method="post" style="display:inline">
<button class="btn btn-ghost btn-xs" style="color:var(--red);padding:0 4px;" title="Remove">&times;</button> <button class="btn btn-ghost btn-xs" style="color:var(--red);padding:0 4px;" title="Remove">&times;</button>
</form> </form>
@@ -110,6 +117,53 @@
</div> </div>
<!-- Links -->
<div class="card mt-4">
<div class="card-header">
<h2 class="card-title">Links</h2>
</div>
<form action="/focus/{{ item.id }}/links/add" method="post" class="flex gap-2 items-end" style="padding: 12px; border-bottom: 1px solid var(--border);">
<div class="form-group" style="flex:2; margin:0;">
<label class="form-label">Link</label>
<select name="link_id" class="form-select" required>
<option value="">Select link...</option>
{% for l in all_links %}
<option value="{{ l.id }}">{{ l.label }} — {{ l.url[:40] }}{% if l.url|length > 40 %}...{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex:1; margin:0;">
<label class="form-label">Role</label>
<input type="text" name="role" class="form-input" list="link-roles" placeholder="e.g. reference, docs...">
<datalist id="link-roles">
<option value="website">
<option value="documentation">
<option value="reference">
<option value="repository">
<option value="social">
<option value="resource">
</datalist>
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
<a href="/links/create?focus_id={{ item.id }}" class="btn btn-ghost btn-sm">+ New Link</a>
</form>
{% for l in linked_links %}
<div class="list-row">
<span class="row-title"><a href="{{ l.url }}" target="_blank">{{ l.label }}</a></span>
<span class="row-meta" style="min-width:0;overflow:hidden;text-overflow:ellipsis;">{{ l.url }}</span>
{% if l.role %}<span class="row-tag">{{ l.role }}</span>{% endif %}
<div class="row-actions">
<a href="/links/{{ l.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/focus/{{ item.id }}/links/{{ l.id }}/remove" method="post" style="display:inline">
<button class="btn btn-ghost btn-xs">Unlink</button>
</form>
</div>
</div>
{% else %}
<div class="text-sm text-muted" style="padding: 12px;">No links</div>
{% endfor %}
</div>
<script> <script>
(function() { (function() {
var container = document.getElementById('focus-checklist'); var container = document.getElementById('focus-checklist');
@@ -169,5 +223,36 @@
}); });
}); });
})(); })();
// Copy list item text
document.querySelectorAll('.cl-copy-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var text = btn.getAttribute('data-text');
navigator.clipboard.writeText(text).then(function() {
var orig = btn.innerHTML;
btn.innerHTML = '&#10003;';
btn.style.color = 'var(--green)';
setTimeout(function() { btn.innerHTML = orig; btn.style.color = 'var(--muted)'; }, 1200);
});
});
});
// Edit list item inline
document.querySelectorAll('.cl-edit-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.id;
document.querySelector('.cl-display-' + id).style.display = 'none';
var form = document.querySelector('.cl-edit-' + id);
form.style.display = 'flex';
form.querySelector('input').focus();
});
});
document.querySelectorAll('.cl-edit-cancel').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.id;
document.querySelector('.cl-display-' + id).style.display = '';
document.querySelector('.cl-edit-' + id).style.display = 'none';
});
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -16,8 +16,9 @@
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %} {% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %} {% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
{% if prefill_contact_id is defined and prefill_contact_id %}<input type="hidden" name="contact_id" value="{{ prefill_contact_id }}">{% endif %} {% if prefill_contact_id is defined and prefill_contact_id %}<input type="hidden" name="contact_id" value="{{ prefill_contact_id }}">{% endif %}
{% if prefill_focus_id is defined and prefill_focus_id %}<input type="hidden" name="focus_id" value="{{ prefill_focus_id }}">{% endif %}
{% if from_project is defined and from_project %}<input type="hidden" name="from_project" value="{{ from_project }}">{% endif %} {% if from_project is defined and from_project %}<input type="hidden" name="from_project" value="{{ from_project }}">{% endif %}
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/projects/' ~ from_project ~ '?tab=links' if from_project is defined and from_project else ('/contacts/' ~ prefill_contact_id if prefill_contact_id is defined and prefill_contact_id else ('/projects/' ~ prefill_project_id ~ '?tab=links' if prefill_project_id is defined and prefill_project_id else ('/tasks/' ~ prefill_task_id ~ '?tab=links' if prefill_task_id is defined and prefill_task_id else '/links'))) }}" class="btn btn-secondary">Cancel</a></div> <div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/projects/' ~ from_project ~ '?tab=links' if from_project is defined and from_project else ('/focus/' ~ prefill_focus_id if prefill_focus_id is defined and prefill_focus_id else ('/contacts/' ~ prefill_contact_id if prefill_contact_id is defined and prefill_contact_id else ('/projects/' ~ prefill_project_id ~ '?tab=links' if prefill_project_id is defined and prefill_project_id else ('/tasks/' ~ prefill_task_id ~ '?tab=links' if prefill_task_id is defined and prefill_task_id else '/links')))) }}" class="btn btn-secondary">Cancel</a></div>
</div> </div>
</form></div> </form></div>
{% endblock %} {% endblock %}

View File

@@ -14,7 +14,7 @@
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %} {% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
<span class="row-title"><a href="{{ item.url }}" target="_blank">{{ item.label }}</a></span> <span class="row-title"><a href="{{ item.url }}" target="_blank">{{ item.label }}</a></span>
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %} {% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
<span class="row-meta">{{ item.url[:50] }}{% if item.url|length > 50 %}...{% endif %}</span> <span class="row-meta" style="min-width:0;overflow:hidden;text-overflow:ellipsis;">{{ item.url }}</span>
<div class="row-actions"> <div class="row-actions">
<a href="/links/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a> <a href="/links/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/links/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form> <form action="/links/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>

View File

@@ -24,7 +24,7 @@
{% if items %} {% if items %}
<div class="card mt-3"> <div class="card mt-3">
{% for item in items %} {% for item in items %}
<div class="list-row"> <div class="list-row lists-drag-row" draggable="true" data-id="{{ item.id }}">
{% with reorder_url="/lists/reorder", item_id=item.id %} {% with reorder_url="/lists/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %} {% include 'partials/reorder_arrows.html' %}
{% endwith %} {% endwith %}
@@ -53,6 +53,9 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<form id="lists-reorder-form" action="/lists/reorder-all" method="post" style="display:none;">
<input type="hidden" name="item_ids" id="lists-reorder-ids">
</form>
{% else %} {% else %}
<div class="empty-state mt-3"> <div class="empty-state mt-3">
<div class="empty-state-icon">&#9776;</div> <div class="empty-state-icon">&#9776;</div>
@@ -63,6 +66,52 @@
<script> <script>
(function() { (function() {
// Drag-and-drop reorder
var dragRow = null;
var container = document.querySelector('.card.mt-3');
if (container) {
container.querySelectorAll('.lists-drag-row').forEach(function(row) {
row.addEventListener('dragstart', function(e) {
dragRow = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', row.dataset.id);
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
container.querySelectorAll('.lists-drag-row.drag-over').forEach(function(el) { el.classList.remove('drag-over'); });
if (dragRow) {
var allIds = [];
container.querySelectorAll('.lists-drag-row').forEach(function(r) { allIds.push(r.dataset.id); });
document.getElementById('lists-reorder-ids').value = allIds.join(',');
document.getElementById('lists-reorder-form').submit();
}
dragRow = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
if (!dragRow || row === dragRow) return;
e.dataTransfer.dropEffect = 'move';
container.querySelectorAll('.lists-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;
row.classList.remove('drag-over');
var rows = Array.from(container.querySelectorAll('.lists-drag-row'));
var dragIdx = rows.indexOf(dragRow);
var targetIdx = rows.indexOf(row);
if (dragIdx < targetIdx) {
container.insertBefore(dragRow, row.nextSibling);
} else {
container.insertBefore(dragRow, row);
}
});
});
}
var domainSel = document.getElementById('domain-filter'); var domainSel = document.getElementById('domain-filter');
var projectSel = document.getElementById('project-filter'); var projectSel = document.getElementById('project-filter');
var currentProjectId = '{{ current_project_id }}'; var currentProjectId = '{{ current_project_id }}';

View File

@@ -133,11 +133,31 @@
{% endfor %} {% endfor %}
{% elif tab == 'lists' %} {% elif tab == 'lists' %}
<a href="/lists/create?task_id={{ item.id }}&domain_id={{ item.domain_id }}" class="btn btn-ghost btn-sm mb-3">+ New List</a> <div class="card mb-3">
<form action="/tasks/{{ item.id }}/lists/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
<div class="form-group" style="flex:1; margin:0;">
<label class="form-label">Add Existing List</label>
<select name="list_id" class="form-select" required>
<option value="">Select list...</option>
{% for l in all_lists %}
<option value="{{ l.id }}">{{ l.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
<a href="/lists/create?task_id={{ item.id }}&domain_id={{ item.domain_id }}" class="btn btn-ghost btn-sm">+ New List</a>
</form>
</div>
{% for l in tab_data %} {% for l in tab_data %}
<div class="list-row"> <div class="list-row">
<span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span> <span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span>
<span class="row-meta">{{ l.item_count }} items</span> <span class="row-meta">{{ l.item_count }} items</span>
<div class="row-actions">
<a href="/lists/{{ l.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/tasks/{{ item.id }}/lists/{{ l.id }}/remove" method="post" style="display:inline">
<button class="btn btn-ghost btn-xs" title="Unlink">Unlink</button>
</form>
</div>
</div> </div>
{% else %} {% else %}
<div class="empty-state"><div class="empty-state-text">No lists linked to this task</div></div> <div class="empty-state"><div class="empty-state-text">No lists linked to this task</div></div>

View File

@@ -64,9 +64,7 @@
<span class="row-title"> <span class="row-title">
<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.label }}</a> <a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.label }}</a>
</span> </span>
<span class="row-meta text-xs" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> <span class="row-meta" style="min-width:0;overflow:hidden;text-overflow:ellipsis;">{{ item.url }}</span>
{{ item.url }}
</span>
{% if item.tags %} {% if item.tags %}
{% for tag in item.tags %} {% for tag in item.tags %}
<span class="row-tag">{{ tag }}</span> <span class="row-tag">{{ tag }}</span>