feat: create new links directly from contact pages

Contact detail: + New Link button redirects to link form with contact context.
Contact create/edit form: inline new link rows (label/url/role) created on submit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:44:54 +00:00
parent e334e0e9db
commit fbdf986fa8
5 changed files with 68 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ from typing import Optional
from core.database import get_db from core.database import get_db
from core.base_repository import BaseRepository from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data from core.sidebar import get_sidebar_data
from routers.weblinks import get_default_folder_id
router = APIRouter(prefix="/contacts", tags=["contacts"]) router = APIRouter(prefix="/contacts", tags=["contacts"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -77,6 +78,27 @@ async def create_contact(
INSERT INTO contact_links (contact_id, link_id, role) INSERT INTO contact_links (contact_id, link_id, role)
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact["id"], "lid": lid, "role": lr if lr and lr.strip() else None}) """), {"cid": contact["id"], "lid": lid, "role": lr if lr and lr.strip() else None})
# Process inline new link creation
new_labels = form_data.getlist("new_link_labels")
new_urls = form_data.getlist("new_link_urls")
new_roles = form_data.getlist("new_link_roles")
link_repo = BaseRepository("links", db)
default_fid = await get_default_folder_id(db) if new_labels else None
for i, label in enumerate(new_labels):
url = new_urls[i] if i < len(new_urls) else ""
if label and label.strip() and url and url.strip():
new_link = await link_repo.create({"label": label.strip(), "url": url.strip()})
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": new_link["id"]})
nr = new_roles[i] if i < len(new_roles) else None
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": new_link["id"], "role": nr if nr and nr.strip() else None})
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)
@@ -179,6 +201,27 @@ async def update_contact(
INSERT INTO contact_links (contact_id, link_id, role) INSERT INTO contact_links (contact_id, link_id, role)
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "lid": lid, "role": lr if lr and lr.strip() else None}) """), {"cid": contact_id, "lid": lid, "role": lr if lr and lr.strip() else None})
# Process inline new link creation
new_labels = form_data.getlist("new_link_labels")
new_urls = form_data.getlist("new_link_urls")
new_roles = form_data.getlist("new_link_roles")
link_repo = BaseRepository("links", db)
default_fid = await get_default_folder_id(db) if new_labels else None
for i, label in enumerate(new_labels):
url = new_urls[i] if i < len(new_urls) else ""
if label and label.strip() and url and url.strip():
new_link = await link_repo.create({"label": label.strip(), "url": url.strip()})
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": new_link["id"]})
nr = new_roles[i] if i < len(new_roles) else None
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": new_link["id"], "role": nr if nr and nr.strip() else None})
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)

View File

@@ -52,6 +52,7 @@ async def create_form(
project_id: Optional[str] = None, project_id: Optional[str] = None,
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,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
@@ -67,6 +68,7 @@ async def create_form(
"prefill_project_id": project_id or "", "prefill_project_id": project_id or "",
"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 "",
}) })
@@ -75,6 +77,7 @@ async def create_link(
request: Request, label: str = Form(...), url: str = Form(...), request: Request, label: str = Form(...), url: str = Form(...),
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),
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),
): ):
@@ -99,6 +102,15 @@ async def create_link(
VALUES (:fid, :lid) ON CONFLICT DO NOTHING VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": link["id"]}) """), {"fid": default_fid, "lid": link["id"]})
# Attach to contact if created from contact context
if contact_id and contact_id.strip():
await db.execute(text("""
INSERT INTO contact_links (contact_id, link_id)
VALUES (:cid, :lid) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "lid": link["id"]})
await db.commit()
return RedirectResponse(url=f"/contacts/{contact_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

@@ -52,6 +52,7 @@
</datalist> </datalist>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm">Add</button> <button type="submit" class="btn btn-primary btn-sm">Add</button>
<a href="/links/create?contact_id={{ item.id }}" class="btn btn-ghost btn-sm">+ New Link</a>
</form> </form>
{% for l in links %} {% for l in links %}
<div class="list-row"> <div class="list-row">

View File

@@ -34,6 +34,7 @@
{% endif %} {% endif %}
</div> </div>
<button type="button" class="btn btn-ghost btn-sm" id="add-link-btn">+ Add Link</button> <button type="button" class="btn btn-ghost btn-sm" id="add-link-btn">+ Add Link</button>
<button type="button" class="btn btn-ghost btn-sm" id="add-new-link-btn">+ Create New Link</button>
<datalist id="link-roles"> <datalist id="link-roles">
<option value="website"> <option value="website">
<option value="portfolio"> <option value="portfolio">
@@ -65,6 +66,15 @@
'<button type="button" class="btn btn-ghost btn-xs" style="color:var(--red);" onclick="this.closest(\'.contact-link-row\').remove()">&times;</button>'; '<button type="button" class="btn btn-ghost btn-xs" style="color:var(--red);" onclick="this.closest(\'.contact-link-row\').remove()">&times;</button>';
document.getElementById('contact-links-list').appendChild(row); document.getElementById('contact-links-list').appendChild(row);
}); });
document.getElementById('add-new-link-btn').addEventListener('click', function() {
var row = document.createElement('div');
row.className = 'flex gap-2 items-center mb-2 contact-link-row';
row.innerHTML = '<input type="text" name="new_link_labels" class="form-input" placeholder="Label *" required style="flex:1;">' +
'<input type="url" name="new_link_urls" class="form-input" placeholder="URL *" required style="flex:2;">' +
'<input type="text" name="new_link_roles" class="form-input" list="link-roles" placeholder="Role..." style="flex:1;">' +
'<button type="button" class="btn btn-ghost btn-xs" style="color:var(--red);" onclick="this.closest(\'.contact-link-row\').remove()">&times;</button>';
document.getElementById('contact-links-list').appendChild(row);
});
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -15,7 +15,8 @@
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div> <div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
{% 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 %}
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/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> {% if prefill_contact_id is defined and prefill_contact_id %}<input type="hidden" name="contact_id" value="{{ prefill_contact_id }}">{% endif %}
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/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 %}