- Add generic move_in_order() to BaseRepository for reorder support - Add reusable reorder_arrows.html partial with grip dot handles - Add reorder routes to all 9 list routers (tasks, notes, links, contacts, meetings, decisions, appointments, lists, focus) - Compact row padding (6px 12px, gap 8px) on .list-row and .focus-item - Reduce font size to 0.80rem on row titles, sidebar nav, domain tree Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
6.4 KiB
Python
169 lines
6.4 KiB
Python
"""Links: URL references attached to domains/projects/tasks/meetings."""
|
|
|
|
from fastapi import APIRouter, Request, Form, Depends
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import text
|
|
from typing import Optional
|
|
|
|
from core.database import get_db
|
|
from core.base_repository import BaseRepository
|
|
from core.sidebar import get_sidebar_data
|
|
from routers.weblinks import get_default_folder_id
|
|
|
|
router = APIRouter(prefix="/links", tags=["links"])
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
|
|
@router.get("/")
|
|
async def list_links(request: Request, domain_id: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
|
sidebar = await get_sidebar_data(db)
|
|
where_clauses = ["l.is_deleted = false"]
|
|
params = {}
|
|
if domain_id:
|
|
where_clauses.append("l.domain_id = :domain_id")
|
|
params["domain_id"] = domain_id
|
|
where_sql = " AND ".join(where_clauses)
|
|
|
|
result = await db.execute(text(f"""
|
|
SELECT l.*, d.name as domain_name, d.color as domain_color, p.name as project_name
|
|
FROM links l
|
|
LEFT JOIN domains d ON l.domain_id = d.id
|
|
LEFT JOIN projects p ON l.project_id = p.id
|
|
WHERE {where_sql} ORDER BY l.sort_order, l.created_at
|
|
"""), params)
|
|
items = [dict(r._mapping) for r in result]
|
|
|
|
domains_repo = BaseRepository("domains", db)
|
|
domains = await domains_repo.list()
|
|
|
|
return templates.TemplateResponse("links.html", {
|
|
"request": request, "sidebar": sidebar, "items": items, "domains": domains,
|
|
"current_domain_id": domain_id or "",
|
|
"page_title": "Links", "active_nav": "links",
|
|
})
|
|
|
|
|
|
@router.get("/create")
|
|
async def create_form(
|
|
request: Request,
|
|
domain_id: Optional[str] = None,
|
|
project_id: Optional[str] = None,
|
|
task_id: Optional[str] = None,
|
|
meeting_id: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
sidebar = await get_sidebar_data(db)
|
|
domains_repo = BaseRepository("domains", db)
|
|
domains = await domains_repo.list()
|
|
projects_repo = BaseRepository("projects", db)
|
|
projects = await projects_repo.list()
|
|
return templates.TemplateResponse("link_form.html", {
|
|
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
|
"page_title": "New Link", "active_nav": "links",
|
|
"item": None,
|
|
"prefill_domain_id": domain_id or "",
|
|
"prefill_project_id": project_id or "",
|
|
"prefill_task_id": task_id or "",
|
|
"prefill_meeting_id": meeting_id or "",
|
|
})
|
|
|
|
|
|
@router.post("/create")
|
|
async def create_link(
|
|
request: Request, label: str = Form(...), url: str = Form(...),
|
|
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
|
|
task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None),
|
|
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("links", db)
|
|
data = {"label": label, "url": url, "description": description}
|
|
if domain_id and domain_id.strip():
|
|
data["domain_id"] = domain_id
|
|
if project_id and project_id.strip():
|
|
data["project_id"] = project_id
|
|
if task_id and task_id.strip():
|
|
data["task_id"] = task_id
|
|
if meeting_id and meeting_id.strip():
|
|
data["meeting_id"] = meeting_id
|
|
if tags and tags.strip():
|
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
|
link = await repo.create(data)
|
|
|
|
# Assign to Default folder
|
|
default_fid = await get_default_folder_id(db)
|
|
await db.execute(text("""
|
|
INSERT INTO folder_links (folder_id, link_id)
|
|
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
|
|
"""), {"fid": default_fid, "lid": link["id"]})
|
|
|
|
# Redirect back to context if created from task/meeting
|
|
if task_id and task_id.strip():
|
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
|
|
if meeting_id and meeting_id.strip():
|
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303)
|
|
return RedirectResponse(url="/links", status_code=303)
|
|
|
|
|
|
@router.get("/{link_id}/edit")
|
|
async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
|
repo = BaseRepository("links", db)
|
|
sidebar = await get_sidebar_data(db)
|
|
item = await repo.get(link_id)
|
|
if not item:
|
|
return RedirectResponse(url="/links", status_code=303)
|
|
domains_repo = BaseRepository("domains", db)
|
|
domains = await domains_repo.list()
|
|
projects_repo = BaseRepository("projects", db)
|
|
projects = await projects_repo.list()
|
|
return templates.TemplateResponse("link_form.html", {
|
|
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
|
"page_title": "Edit Link", "active_nav": "links",
|
|
"item": item,
|
|
"prefill_domain_id": "", "prefill_project_id": "",
|
|
"prefill_task_id": "", "prefill_meeting_id": "",
|
|
})
|
|
|
|
|
|
@router.post("/{link_id}/edit")
|
|
async def update_link(
|
|
link_id: str, label: str = Form(...), url: str = Form(...),
|
|
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
|
|
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("links", db)
|
|
data = {
|
|
"label": label, "url": url,
|
|
"domain_id": domain_id if domain_id and domain_id.strip() else None,
|
|
"project_id": project_id if project_id and project_id.strip() else None,
|
|
"description": description,
|
|
}
|
|
if tags and tags.strip():
|
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
|
else:
|
|
data["tags"] = None
|
|
await repo.update(link_id, data)
|
|
return RedirectResponse(url="/links", status_code=303)
|
|
|
|
|
|
@router.post("/{link_id}/delete")
|
|
async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
|
repo = BaseRepository("links", db)
|
|
await repo.soft_delete(link_id)
|
|
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
|
|
|
|
|
@router.post("/reorder")
|
|
async def reorder_link(
|
|
request: Request,
|
|
item_id: str = Form(...),
|
|
direction: str = Form(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("links", db)
|
|
await repo.move_in_order(item_id, direction)
|
|
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|