diff --git a/core/template_filters.py b/core/template_filters.py new file mode 100644 index 0000000..85a879b --- /dev/null +++ b/core/template_filters.py @@ -0,0 +1,49 @@ +"""Jinja2 custom template filters.""" + +import re +from markupsafe import Markup, escape + +# Match http(s):// URLs and bare www. URLs +_URL_RE = re.compile( + r'(https?://[^\s<>\"\'\]]+|www\.[^\s<>\"\'\]]+)', + re.IGNORECASE, +) + +# Trailing punctuation that usually isn't part of the URL +_TRAILING_PUNCT = re.compile(r'[.,;:!?\)]+$') + + +def autolink(text): + """Detect URLs in plain text and wrap them in clickable tags. + + Escapes all content first (XSS-safe), then replaces URL patterns. + Returns Markup so Jinja2 won't double-escape. + """ + if not text: + return text + + escaped = str(escape(text)) + + def _replace(match): + raw = match.group(0) + # Strip trailing punctuation that got captured + trail = '' + m = _TRAILING_PUNCT.search(raw) + if m: + trail = m.group(0) + raw = raw[:m.start()] + + href = raw + if raw.lower().startswith('www.'): + href = 'https://' + raw + + # Truncate display text for readability + display = raw if len(raw) <= 60 else raw[:57] + '...' + + return ( + f'{display}{trail}' + ) + + result = _URL_RE.sub(_replace, escaped) + return Markup(result) diff --git a/routers/focus.py b/routers/focus.py index 75c9457..867970d 100644 --- a/routers/focus.py +++ b/routers/focus.py @@ -11,9 +11,11 @@ from datetime import date, datetime, timezone from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data +from core.template_filters import autolink router = APIRouter(prefix="/focus", tags=["focus"]) templates = Jinja2Templates(directory="templates") +templates.env.filters["autolink"] = autolink @router.get("/") diff --git a/routers/lists.py b/routers/lists.py index 7fdb8f4..8be02a0 100644 --- a/routers/lists.py +++ b/routers/lists.py @@ -11,9 +11,11 @@ from datetime import datetime, timezone from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data +from core.template_filters import autolink router = APIRouter(prefix="/lists", tags=["lists"]) templates = Jinja2Templates(directory="templates") +templates.env.filters["autolink"] = autolink @router.get("/") @@ -187,11 +189,19 @@ async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends """)) all_contacts = [dict(r._mapping) for r in result] + # All links for insert-into-content picker + 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("list_detail.html", { "request": request, "sidebar": sidebar, "item": item, "domain": domain, "project": project, "list_items": top_items, "child_map": child_map, "contacts": contacts, "all_contacts": all_contacts, + "all_links": all_links, "page_title": item["name"], "active_nav": "lists", }) diff --git a/static/style.css b/static/style.css index b30a62e..6eae41a 100644 --- a/static/style.css +++ b/static/style.css @@ -1144,6 +1144,53 @@ a:hover { color: var(--accent-hover); } } .grip-btn:hover { color: var(--accent); } +/* ---- Link Picker & Inline Edit ---- */ +.link-picker { + padding: 4px 8px; + font-size: 0.8rem; + background: var(--surface2); + color: var(--muted); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; +} +.link-picker:hover { border-color: var(--accent); color: var(--text); } + +.inline-edit-form { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.inline-edit-form input[type="text"] { + flex: 1; + min-width: 0; + padding: 4px 8px; + font-size: 0.85rem; + background: var(--surface2); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.inline-edit-form input[type="text"]:focus { + border-color: var(--accent); + outline: none; +} + +/* ---- Autolinked URLs in list items ---- */ +.autolink { + color: var(--accent); + text-decoration: underline; + text-decoration-color: var(--accent-soft); + text-underline-offset: 2px; + transition: text-decoration-color var(--transition); +} +.autolink:hover { + text-decoration-color: var(--accent); + color: var(--accent); +} + /* ---- Scrollbar ---- */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } diff --git a/templates/focus.html b/templates/focus.html index bc843da..68e798f 100644 --- a/templates/focus.html +++ b/templates/focus.html @@ -139,7 +139,7 @@ {% for li in available_list_items %}
- {{ li.content }} + {{ li.content|autolink }} {% if li.list_name %}{{ li.list_name }}{% endif %}
diff --git a/templates/list_detail.html b/templates/list_detail.html index 94986f3..ab34539 100644 --- a/templates/list_detail.html +++ b/templates/list_detail.html @@ -37,7 +37,15 @@ - + + {% if all_links %} + + {% endif %}
@@ -58,14 +66,29 @@
{% endif %} - - {{ li.content }} + + {{ li.content|autolink }} -
+
+
+ +
@@ -83,14 +106,29 @@ {% endif %} - - {{ child.content }} + + {{ child.content|autolink }} -
+
+
+ +
{% endfor %} {% endfor %} @@ -135,4 +173,49 @@
No contacts linked
{% endfor %} + {% endblock %}