feat: autolink URLs in list items, link picker, and inline edit
- Add autolink Jinja2 filter to detect URLs and make them clickable - Add link picker dropdown to insert existing link URLs into list item content - Add inline edit with link picker on each list item row - Apply autolink filter on list detail and focus available list items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
49
core/template_filters.py
Normal file
49
core/template_filters.py
Normal file
@@ -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 <a> 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'<a href="{href}" target="_blank" rel="noopener noreferrer" '
|
||||||
|
f'class="autolink">{display}</a>{trail}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _URL_RE.sub(_replace, escaped)
|
||||||
|
return Markup(result)
|
||||||
@@ -11,9 +11,11 @@ from datetime import date, datetime, timezone
|
|||||||
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 core.template_filters import autolink
|
||||||
|
|
||||||
router = APIRouter(prefix="/focus", tags=["focus"])
|
router = APIRouter(prefix="/focus", tags=["focus"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
templates.env.filters["autolink"] = autolink
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from datetime import datetime, timezone
|
|||||||
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 core.template_filters import autolink
|
||||||
|
|
||||||
router = APIRouter(prefix="/lists", tags=["lists"])
|
router = APIRouter(prefix="/lists", tags=["lists"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
templates.env.filters["autolink"] = autolink
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@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_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", {
|
return templates.TemplateResponse("list_detail.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
"domain": domain, "project": project,
|
"domain": domain, "project": project,
|
||||||
"list_items": top_items, "child_map": child_map,
|
"list_items": top_items, "child_map": child_map,
|
||||||
"contacts": contacts, "all_contacts": all_contacts,
|
"contacts": contacts, "all_contacts": all_contacts,
|
||||||
|
"all_links": all_links,
|
||||||
"page_title": item["name"], "active_nav": "lists",
|
"page_title": item["name"], "active_nav": "lists",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1144,6 +1144,53 @@ a:hover { color: var(--accent-hover); }
|
|||||||
}
|
}
|
||||||
.grip-btn:hover { color: var(--accent); }
|
.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 ---- */
|
/* ---- Scrollbar ---- */
|
||||||
::-webkit-scrollbar { width: 6px; }
|
::-webkit-scrollbar { width: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
{% for li in available_list_items %}
|
{% for li in available_list_items %}
|
||||||
<div class="list-row{% if loop.index > 25 %} focus-hidden-item hidden{% endif %}" style="padding:3px 8px;min-height:0;">
|
<div class="list-row{% if loop.index > 25 %} focus-hidden-item hidden{% endif %}" style="padding:3px 8px;min-height:0;">
|
||||||
<span style="color:var(--muted);font-size:0.85rem;margin-right:4px">☰</span>
|
<span style="color:var(--muted);font-size:0.85rem;margin-right:4px">☰</span>
|
||||||
<span class="row-title">{{ li.content }}</span>
|
<span class="row-title">{{ li.content|autolink }}</span>
|
||||||
{% if li.list_name %}<span class="row-tag" style="background:var(--purple);color:#fff">{{ li.list_name }}</span>{% endif %}
|
{% if li.list_name %}<span class="row-tag" style="background:var(--purple);color:#fff">{{ li.list_name }}</span>{% endif %}
|
||||||
<form action="/focus/add" method="post" style="display:inline">
|
<form action="/focus/add" method="post" style="display:inline">
|
||||||
<input type="hidden" name="list_item_id" value="{{ li.id }}">
|
<input type="hidden" name="list_item_id" value="{{ li.id }}">
|
||||||
|
|||||||
@@ -37,7 +37,15 @@
|
|||||||
|
|
||||||
<!-- Add item form -->
|
<!-- Add item form -->
|
||||||
<form class="quick-add mt-3" action="/lists/{{ item.id }}/items/add" method="post">
|
<form class="quick-add mt-3" action="/lists/{{ item.id }}/items/add" method="post">
|
||||||
<input type="text" name="content" placeholder="Add item..." required>
|
<input type="text" name="content" id="add-item-content" placeholder="Add item..." required style="flex:1">
|
||||||
|
{% if all_links %}
|
||||||
|
<select class="link-picker" data-target="add-item-content" style="max-width:180px">
|
||||||
|
<option value="">Insert link...</option>
|
||||||
|
{% for lnk in all_links %}
|
||||||
|
<option value="{{ lnk.url }}">{{ lnk.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -58,14 +66,29 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if li.completed }}">
|
<span class="row-title" data-display-for="{{ li.id }}" style="{{ 'text-decoration: line-through; opacity: 0.6;' if li.completed }}">
|
||||||
{{ li.content }}
|
{{ li.content|autolink }}
|
||||||
</span>
|
</span>
|
||||||
<div class="row-actions">
|
<div class="row-actions" data-display-for="{{ li.id }}">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs edit-item-btn" data-item-id="{{ li.id }}">Edit</button>
|
||||||
<form action="/lists/{{ item.id }}/items/{{ li.id }}/delete" method="post" style="display:inline">
|
<form action="/lists/{{ item.id }}/items/{{ li.id }}/delete" method="post" style="display:inline">
|
||||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<form class="inline-edit-form" data-edit-for="{{ li.id }}" action="/lists/{{ item.id }}/items/{{ li.id }}/edit" method="post" style="display:none;">
|
||||||
|
<input type="text" name="content" id="edit-content-{{ li.id }}" value="{{ li.content }}" required style="flex:1">
|
||||||
|
{% if all_links %}
|
||||||
|
<select class="link-picker" data-target="edit-content-{{ li.id }}" style="max-width:160px">
|
||||||
|
<option value="">Insert link...</option>
|
||||||
|
{% for lnk in all_links %}
|
||||||
|
<option value="{{ lnk.url }}">{{ lnk.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Save</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs cancel-edit-btn" data-item-id="{{ li.id }}">Cancel</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Child items -->
|
<!-- Child items -->
|
||||||
@@ -83,14 +106,29 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if child.completed }}">
|
<span class="row-title" data-display-for="{{ child.id }}" style="{{ 'text-decoration: line-through; opacity: 0.6;' if child.completed }}">
|
||||||
{{ child.content }}
|
{{ child.content|autolink }}
|
||||||
</span>
|
</span>
|
||||||
<div class="row-actions">
|
<div class="row-actions" data-display-for="{{ child.id }}">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs edit-item-btn" data-item-id="{{ child.id }}">Edit</button>
|
||||||
<form action="/lists/{{ item.id }}/items/{{ child.id }}/delete" method="post" style="display:inline">
|
<form action="/lists/{{ item.id }}/items/{{ child.id }}/delete" method="post" style="display:inline">
|
||||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<form class="inline-edit-form" data-edit-for="{{ child.id }}" action="/lists/{{ item.id }}/items/{{ child.id }}/edit" method="post" style="display:none;">
|
||||||
|
<input type="text" name="content" id="edit-content-{{ child.id }}" value="{{ child.content }}" required style="flex:1">
|
||||||
|
{% if all_links %}
|
||||||
|
<select class="link-picker" data-target="edit-content-{{ child.id }}" style="max-width:160px">
|
||||||
|
<option value="">Insert link...</option>
|
||||||
|
{% for lnk in all_links %}
|
||||||
|
<option value="{{ lnk.url }}">{{ lnk.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Save</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs cancel-edit-btn" data-item-id="{{ child.id }}">Cancel</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -135,4 +173,49 @@
|
|||||||
<div style="padding: 12px; color: var(--muted); font-size: 0.85rem;">No contacts linked</div>
|
<div style="padding: 12px; color: var(--muted); font-size: 0.85rem;">No contacts linked</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// Link picker: insert URL at cursor position in target input
|
||||||
|
document.querySelectorAll('.link-picker').forEach(function(sel) {
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
var url = sel.value;
|
||||||
|
if (!url) return;
|
||||||
|
var targetId = sel.getAttribute('data-target');
|
||||||
|
var input = document.getElementById(targetId);
|
||||||
|
if (!input) return;
|
||||||
|
var start = input.selectionStart || input.value.length;
|
||||||
|
var end = input.selectionEnd || input.value.length;
|
||||||
|
var before = input.value.substring(0, start);
|
||||||
|
var after = input.value.substring(end);
|
||||||
|
// Add space padding if needed
|
||||||
|
if (before.length > 0 && before[before.length - 1] !== ' ') before += ' ';
|
||||||
|
if (after.length > 0 && after[0] !== ' ') url += ' ';
|
||||||
|
input.value = before + url + after;
|
||||||
|
input.focus();
|
||||||
|
var pos = before.length + url.length;
|
||||||
|
input.setSelectionRange(pos, pos);
|
||||||
|
sel.selectedIndex = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline edit: toggle edit form
|
||||||
|
document.querySelectorAll('.edit-item-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.getAttribute('data-item-id');
|
||||||
|
document.querySelectorAll('[data-display-for="' + id + '"]').forEach(function(el) { el.style.display = 'none'; });
|
||||||
|
var form = document.querySelector('[data-edit-for="' + id + '"]');
|
||||||
|
if (form) { form.style.display = 'flex'; form.querySelector('input[name="content"]').focus(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.cancel-edit-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.getAttribute('data-item-id');
|
||||||
|
document.querySelectorAll('[data-display-for="' + id + '"]').forEach(function(el) { el.style.display = ''; });
|
||||||
|
var form = document.querySelector('[data-edit-for="' + id + '"]');
|
||||||
|
if (form) form.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user