feat: add links to contacts with role (combo box with suggestions)
New contact_links junction table. Contact detail page shows linked links with add form (link picker + role datalist combo) and unlink/edit actions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -71,8 +71,27 @@ async def contact_detail(contact_id: str, request: Request, db: AsyncSession = D
|
|||||||
item = await repo.get(contact_id)
|
item = await repo.get(contact_id)
|
||||||
if not item:
|
if not item:
|
||||||
return RedirectResponse(url="/contacts", status_code=303)
|
return RedirectResponse(url="/contacts", status_code=303)
|
||||||
|
|
||||||
|
# Linked links
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT l.*, cl.role, cl.created_at as linked_at
|
||||||
|
FROM links l
|
||||||
|
JOIN contact_links cl ON cl.link_id = l.id
|
||||||
|
WHERE cl.contact_id = :cid AND l.is_deleted = false
|
||||||
|
ORDER BY l.label
|
||||||
|
"""), {"cid": contact_id})
|
||||||
|
links = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# All links for add dropdown
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, label, url FROM links
|
||||||
|
WHERE is_deleted = false ORDER BY label
|
||||||
|
"""))
|
||||||
|
all_links = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("contact_detail.html", {
|
return templates.TemplateResponse("contact_detail.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
|
"links": links, "all_links": all_links,
|
||||||
"page_title": f"{item['first_name']} {item.get('last_name', '')}".strip(),
|
"page_title": f"{item['first_name']} {item.get('last_name', '')}".strip(),
|
||||||
"active_nav": "contacts",
|
"active_nav": "contacts",
|
||||||
})
|
})
|
||||||
@@ -126,6 +145,33 @@ async def delete_contact(contact_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
return RedirectResponse(url="/contacts", status_code=303)
|
return RedirectResponse(url="/contacts", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Link linking ----
|
||||||
|
|
||||||
|
@router.post("/{contact_id}/links/add")
|
||||||
|
async def add_link(
|
||||||
|
contact_id: str,
|
||||||
|
link_id: str = Form(...),
|
||||||
|
role: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO contact_links (contact_id, link_id, role)
|
||||||
|
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"cid": contact_id, "lid": link_id, "role": role if role and role.strip() else None})
|
||||||
|
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{contact_id}/links/{link_id}/remove")
|
||||||
|
async def remove_link(
|
||||||
|
contact_id: str, link_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text(
|
||||||
|
"DELETE FROM contact_links WHERE contact_id = :cid AND link_id = :lid"
|
||||||
|
), {"cid": contact_id, "lid": link_id})
|
||||||
|
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reorder")
|
@router.post("/reorder")
|
||||||
async def reorder_contact(
|
async def reorder_contact(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -22,4 +22,51 @@
|
|||||||
{% if item.tags %}<div class="form-group full-width"><div class="form-label">Tags</div><div class="flex gap-2">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div></div>{% endif %}
|
{% if item.tags %}<div class="form-group full-width"><div class="form-label">Tags</div><div class="flex gap-2">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div></div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Links -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Links</h2>
|
||||||
|
</div>
|
||||||
|
<form action="/contacts/{{ 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. website, portfolio...">
|
||||||
|
<datalist id="link-roles">
|
||||||
|
<option value="website">
|
||||||
|
<option value="portfolio">
|
||||||
|
<option value="social">
|
||||||
|
<option value="documentation">
|
||||||
|
<option value="reference">
|
||||||
|
<option value="profile">
|
||||||
|
<option value="repository">
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
{% for l in links %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="{{ l.url }}" target="_blank">{{ l.label }}</a></span>
|
||||||
|
<span class="row-meta">{{ l.url[:50] }}{% if l.url|length > 50 %}...{% endif %}</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="/contacts/{{ 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>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user