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:
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;">📋</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;">✎</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">×</button>
|
<button class="btn btn-ghost btn-xs" style="color:var(--red);padding:0 4px;" title="Remove">×</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 = '✓';
|
||||||
|
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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">☰</div>
|
<div class="empty-state-icon">☰</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 }}';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user