mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
feat(template links): Sketch-style visual parameter builder
- link_form: template builder under the URL field — preset param chips
(q/id/page/category/keyword/lang, CN labels) click to insert ?q={q} with
smart ?/& separators; auto-parsed parameter rows (name + default input +
remove); custom params (name + default -> append); live dark preview
filling defaults (green) and flagging required (red); chips grey out
when used; hidden on Custom/Action tabs; edits existing templates
- redirect_to_original: map GET query params (?q=weather) by name into
template placeholders, keep path-param backward compat (go/alias/x
fills every unfilled param), defaults otherwise; query wins over path
- tests: +5 template redirect cases (query map, multi-param, defaults,
path compat, query-over-path)
This commit is contained in:
@@ -71,6 +71,74 @@
|
||||
padding: 0 .3rem; font-weight: 600;
|
||||
}
|
||||
.msg-preview-hint { font-size: .72rem; color: #9ca3af; margin-top: .45rem; }
|
||||
/* ── Sketch-style template parameter builder ── */
|
||||
.template-builder {
|
||||
margin-top: .8rem;
|
||||
border: 1px solid #e9e5f5; border-radius: 14px;
|
||||
background: linear-gradient(180deg, #faf9ff 0%, #f7f5fc 100%);
|
||||
padding: .9rem 1rem;
|
||||
}
|
||||
.tpl-label { font-size: .7rem; font-weight: 600; color: #7c6ba6; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.tpl-hint { font-size: .72rem; color: #9ca3af; margin-top: .2rem; line-height: 1.4; }
|
||||
.tpl-chip {
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
border: 1px solid #ddd6fe; background: #fff;
|
||||
color: #6d28d9; border-radius: 980px;
|
||||
padding: .38rem 1rem; font-size: .88rem; font-weight: 600;
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.tpl-chip:hover { background: #ede9fe; border-color: #a78bfa; box-shadow: 0 1px 4px rgba(109,40,217,.15); }
|
||||
.tpl-chip:active { transform: scale(.96); }
|
||||
.tpl-chip .tpl-chip-sub { font-weight: 400; color: #a78bfa; font-size: .78rem; }
|
||||
.tpl-chip .tpl-chip-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.tpl-chip.used { opacity: .45; pointer-events: none; }
|
||||
.tpl-param-list { margin-top: .7rem; display: flex; flex-direction: column; gap: .4rem; }
|
||||
.tpl-param-row { display: flex; align-items: center; gap: .55rem; }
|
||||
.tpl-param-name {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: #ede9fe; color: #6d28d9; border-radius: 7px;
|
||||
padding: .2rem .55rem; font-size: .82rem; font-weight: 600;
|
||||
min-width: 4.5rem; text-align: center; flex-shrink: 0;
|
||||
}
|
||||
.tpl-param-default {
|
||||
flex: 1; max-width: 320px;
|
||||
border: 1px solid #d1d5db; border-radius: 8px;
|
||||
padding: .35rem .6rem; font-size: .85rem; color: #374151;
|
||||
background: #fff; outline: none;
|
||||
}
|
||||
.tpl-param-default:focus { border-color: #8b5cf6; box-shadow: 0 0 0 3px rgba(139,92,246,.12); }
|
||||
.tpl-param-label { font-size: .72rem; color: #9ca3af; width: 3.2rem; flex-shrink: 0; }
|
||||
.tpl-param-del {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: #c4b5fd; font-size: .8rem; padding: .3rem .45rem; border-radius: 6px;
|
||||
}
|
||||
.tpl-param-del:hover { color: #ef4444; background: #fef2f2; }
|
||||
.tpl-add-row { display: flex; align-items: center; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; }
|
||||
.tpl-add-row input {
|
||||
border: 1px solid #d1d5db; border-radius: 8px;
|
||||
padding: .38rem .65rem; font-size: .85rem; outline: none; background: #fff;
|
||||
}
|
||||
.tpl-add-row input:focus { border-color: #8b5cf6; box-shadow: 0 0 0 3px rgba(139,92,246,.12); }
|
||||
.tpl-add-name { width: 8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.tpl-add-def { width: 10rem; }
|
||||
.tpl-add-btn {
|
||||
border: none; background: #7c3aed; color: #fff;
|
||||
border-radius: 8px; padding: .4rem .9rem; font-size: .82rem; font-weight: 600;
|
||||
cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.tpl-add-btn:hover { background: #6d28d9; }
|
||||
.tpl-preview {
|
||||
margin-top: .75rem; border-radius: 10px;
|
||||
background: #23252b; padding: .6rem .85rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: .8rem; color: #d1d5db; word-break: break-all; line-height: 1.5;
|
||||
}
|
||||
.tpl-preview-label { font-size: .7rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .35rem; }
|
||||
.tpl-preview-url { white-space: pre-wrap; }
|
||||
.tpl-preview-val { color: #34d399; font-weight: 600; }
|
||||
.tpl-preview-req { color: #f87171; font-weight: 600; }
|
||||
.tpl-preview-note { font-size: .7rem; color: #9ca3af; margin-top: .3rem; }
|
||||
.tpl-empty { font-size: .78rem; color: #9ca3af; padding: .3rem 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -156,6 +224,29 @@
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
class="shadow appearance-none rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
{% if field.field.required %}required{% endif %}>
|
||||
<!-- Sketch-style template builder: click a chip to insert {param} -->
|
||||
<div id="template-builder" class="template-builder {% if is_action %}hidden{% endif %}">
|
||||
<div class="tpl-label">🔧 {% trans "Template parameters" %}</div>
|
||||
<div class="tpl-hint">{% trans "Click a parameter to insert it into the URL. The text after go/alias/… fills the parameter when the link is opened." %}</div>
|
||||
<div class="tpl-chips" id="tpl-chips">
|
||||
<button type="button" class="tpl-chip" data-param="q"><span class="tpl-chip-sub">{</span>q<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">搜索词</span></button>
|
||||
<button type="button" class="tpl-chip" data-param="id" data-def=""><span class="tpl-chip-sub">{</span>id<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">编号</span></button>
|
||||
<button type="button" class="tpl-chip" data-param="page" data-def="1"><span class="tpl-chip-sub">{</span>page<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">页码</span></button>
|
||||
<button type="button" class="tpl-chip" data-param="category"><span class="tpl-chip-sub">{</span>category<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">分类</span></button>
|
||||
<button type="button" class="tpl-chip" data-param="keyword"><span class="tpl-chip-sub">{</span>keyword<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">关键词</span></button>
|
||||
<button type="button" class="tpl-chip" data-param="lang"><span class="tpl-chip-sub">{</span>lang<span class="tpl-chip-sub">}</span><span class="tpl-chip-sub">语言</span></button>
|
||||
</div>
|
||||
<div class="tpl-param-list" id="tpl-param-list"></div>
|
||||
<div class="tpl-add-row">
|
||||
<input type="text" id="tpl-new-name" class="tpl-add-name" placeholder="{% trans 'param name' %}" aria-label="{% trans 'New parameter name' %}">
|
||||
<input type="text" id="tpl-new-default" class="tpl-add-def" placeholder="{% trans 'default value (optional)' %}" aria-label="{% trans 'New parameter default' %}">
|
||||
<button type="button" class="tpl-add-btn" id="tpl-add-param">+ {% trans "Add" %}</button>
|
||||
</div>
|
||||
<div class="tpl-preview">
|
||||
<div class="tpl-preview-label">{% trans "Live preview" %}</div>
|
||||
<div class="tpl-preview-url" id="tpl-preview-url"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif field.name == 'action_type' %}
|
||||
<!-- Apple-style action card picker -->
|
||||
@@ -308,6 +399,8 @@
|
||||
actionDescription.classList.add('hidden');
|
||||
linkTypeInput.value = 'LINK';
|
||||
textField.classList.add('hidden');
|
||||
const tplBuilder = document.getElementById('template-builder');
|
||||
if (tplBuilder) tplBuilder.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showCustomFields() {
|
||||
@@ -338,6 +431,8 @@
|
||||
actionDescription.classList.add('hidden');
|
||||
linkTypeInput.value = 'CUSTOM';
|
||||
textField.classList.remove('hidden');
|
||||
const tplBuilder = document.getElementById('template-builder');
|
||||
if (tplBuilder) tplBuilder.classList.add('hidden');
|
||||
initializeSimpleMDE();
|
||||
}
|
||||
|
||||
@@ -369,6 +464,8 @@
|
||||
customDescription.classList.add('hidden');
|
||||
linkTypeInput.value = 'ACTION';
|
||||
textField.classList.add('hidden');
|
||||
const tplBuilder = document.getElementById('template-builder');
|
||||
if (tplBuilder) tplBuilder.classList.add('hidden');
|
||||
}
|
||||
|
||||
function initializeSimpleMDE() {
|
||||
@@ -460,6 +557,208 @@
|
||||
if (msgInput) msgInput.addEventListener('input', renderPreview);
|
||||
renderPreview();
|
||||
|
||||
/* ── Sketch-style template builder ── */
|
||||
const urlInput = document.getElementById('id_original_url');
|
||||
const builder = document.getElementById('template-builder');
|
||||
const paramListEl = document.getElementById('tpl-param-list');
|
||||
const previewEl = document.getElementById('tpl-preview-url');
|
||||
const newNameInput = document.getElementById('tpl-new-name');
|
||||
const newDefInput = document.getElementById('tpl-new-default');
|
||||
const addParamBtn = document.getElementById('tpl-add-param');
|
||||
|
||||
function parseTemplateParams(url) {
|
||||
const result = [];
|
||||
const re = /\{([^{}]*)\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(url)) !== null) {
|
||||
const inner = m[1].trim();
|
||||
const parts = inner.split(',');
|
||||
const name = parts[0].trim();
|
||||
let def = null;
|
||||
if (parts.length > 1) {
|
||||
const dp = parts.slice(1).join(',').trim();
|
||||
if (dp.startsWith('default=')) {
|
||||
def = dp.slice(8).trim().replace(/^["']|["']$/g, '');
|
||||
} else {
|
||||
def = dp;
|
||||
}
|
||||
}
|
||||
result.push({ name: name, def: def, match: m[0] });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function buildPlaceholderToken(name, def) {
|
||||
// Pure placeholder: {name} or {name,default=def}
|
||||
return '{' + name + ((def !== null && def !== '') ? ',default=' + def : '') + '}';
|
||||
}
|
||||
function buildQueryToken(name, def) {
|
||||
// Query-style full token: name={name,default=def} — matches the
|
||||
// common ?q={q} usage; pure path params can be typed manually.
|
||||
return name + '=' + buildPlaceholderToken(name, def);
|
||||
}
|
||||
function escapeHtmlTpl(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function insertAtCursor(input, text) {
|
||||
const start = input.selectionStart != null ? input.selectionStart : input.value.length;
|
||||
const end = input.selectionEnd != null ? input.selectionEnd : input.value.length;
|
||||
input.value = input.value.slice(0, start) + text + input.value.slice(end);
|
||||
const pos = start + text.length;
|
||||
input.setSelectionRange(pos, pos);
|
||||
input.focus();
|
||||
}
|
||||
function appendToken(input, token) {
|
||||
const v = input.value;
|
||||
if (!v) { input.value = token; return; }
|
||||
// Cursor in the middle of the URL: insert raw (user controls position)
|
||||
if (document.activeElement === input && input.selectionStart != null &&
|
||||
input.selectionStart === input.selectionEnd && input.selectionEnd < v.length) {
|
||||
insertAtCursor(input, token);
|
||||
return;
|
||||
}
|
||||
// Append at the end with a smart separator
|
||||
const hasQuery = v.indexOf('?') > -1;
|
||||
const sep = hasQuery ? '&' : '?';
|
||||
const endsSep = /[?&]$/.test(v);
|
||||
input.value = v + (endsSep ? '' : sep) + token;
|
||||
const pos = input.value.length;
|
||||
input.setSelectionRange(pos, pos);
|
||||
input.focus();
|
||||
}
|
||||
function removeTokenFromUrl(url, token) {
|
||||
// token is {name} or {name,default=...}; also drop the `name=` key
|
||||
const m = token.match(/^\{(.+?)(?:,default=.*)?\}$/);
|
||||
const name = m ? m[1] : null;
|
||||
let u = url;
|
||||
if (name) {
|
||||
u = u.replace(new RegExp('(^|[?&])' + name + '=\\{' + name + '(?:,default=[^}]*)?\\}(?=&|$)', 'g'), '$1');
|
||||
}
|
||||
u = u.split(token).join('');
|
||||
u = u.replace(/[?&]{2,}/g, '&').replace(/^&/, '').replace(/[?&]$/, '');
|
||||
return u;
|
||||
}
|
||||
|
||||
function renderTemplateBuilder() {
|
||||
if (!urlInput || !paramListEl) return;
|
||||
const url = urlInput.value || '';
|
||||
const params = parseTemplateParams(url);
|
||||
|
||||
paramListEl.innerHTML = '';
|
||||
if (!params.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'tpl-empty';
|
||||
empty.textContent = '{% trans "No parameters yet — click one above or type {name,default=value} in the URL." %}';
|
||||
paramListEl.appendChild(empty);
|
||||
}
|
||||
params.forEach(function (p) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tpl-param-row';
|
||||
|
||||
const nameEl = document.createElement('code');
|
||||
nameEl.className = 'tpl-param-name';
|
||||
nameEl.textContent = p.name;
|
||||
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'tpl-param-label';
|
||||
labelEl.textContent = '{% trans "default" %}';
|
||||
|
||||
const defInput = document.createElement('input');
|
||||
defInput.className = 'tpl-param-default';
|
||||
defInput.placeholder = '{% trans "default value" %}';
|
||||
defInput.value = p.def || '';
|
||||
defInput.addEventListener('input', function () {
|
||||
const v = defInput.value.trim();
|
||||
const token = buildPlaceholderToken(p.name, v);
|
||||
urlInput.value = urlInput.value.split(p.match).join(token);
|
||||
p.match = token;
|
||||
renderTemplateBuilder();
|
||||
});
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'tpl-param-del';
|
||||
delBtn.textContent = '✕';
|
||||
delBtn.title = '{% trans "Remove parameter" %}';
|
||||
delBtn.setAttribute('aria-label', '{% trans "Remove parameter" %} ' + p.name);
|
||||
delBtn.addEventListener('click', function () {
|
||||
urlInput.value = removeTokenFromUrl(urlInput.value, p.match);
|
||||
renderTemplateBuilder();
|
||||
urlInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
row.appendChild(nameEl);
|
||||
row.appendChild(labelEl);
|
||||
row.appendChild(defInput);
|
||||
row.appendChild(delBtn);
|
||||
paramListEl.appendChild(row);
|
||||
});
|
||||
|
||||
// grey out preset chips already in use
|
||||
document.querySelectorAll('#tpl-chips .tpl-chip').forEach(function (chip) {
|
||||
const used = params.some(function (p) { return p.name === chip.dataset.param; });
|
||||
chip.classList.toggle('used', used);
|
||||
});
|
||||
|
||||
// live preview: fill defaults, flag missing ones
|
||||
if (previewEl) {
|
||||
if (!url) {
|
||||
previewEl.innerHTML = '<span class="tpl-preview-note">{% trans "Enter an original URL to see the live preview here." %}</span>';
|
||||
} else {
|
||||
let html = escapeHtmlTpl(url);
|
||||
params.forEach(function (p) {
|
||||
const escMatch = escapeHtmlTpl(p.match);
|
||||
const valHtml = (p.def !== null && p.def !== '')
|
||||
? '<span class="tpl-preview-val">' + escapeHtmlTpl(p.def) + '</span>'
|
||||
: '<span class="tpl-preview-req">' + escapeHtmlTpl(p.name) + '</span>';
|
||||
html = html.split(escMatch).join(valHtml);
|
||||
});
|
||||
previewEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// preset chips: click to insert
|
||||
document.querySelectorAll('#tpl-chips .tpl-chip').forEach(function (chip) {
|
||||
chip.addEventListener('click', function () {
|
||||
const name = chip.dataset.param;
|
||||
const def = chip.dataset.def || '';
|
||||
const token = buildQueryToken(name, def);
|
||||
appendToken(urlInput, token);
|
||||
renderTemplateBuilder();
|
||||
urlInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
|
||||
// custom param: name + default -> append
|
||||
if (addParamBtn) {
|
||||
addParamBtn.addEventListener('click', function () {
|
||||
const name = (newNameInput.value || '').trim();
|
||||
if (!name || !/^[A-Za-z0-9_]+$/.test(name)) {
|
||||
newNameInput.focus();
|
||||
newNameInput.setAttribute('style', 'border-color:#ef4444');
|
||||
setTimeout(function () { newNameInput.removeAttribute('style'); }, 1200);
|
||||
return;
|
||||
}
|
||||
const def = (newDefInput.value || '').trim();
|
||||
const token = buildQueryToken(name, def);
|
||||
appendToken(urlInput, token);
|
||||
newNameInput.value = '';
|
||||
newDefInput.value = '';
|
||||
renderTemplateBuilder();
|
||||
urlInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
newNameInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addParamBtn.click(); }
|
||||
});
|
||||
newDefInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addParamBtn.click(); }
|
||||
});
|
||||
}
|
||||
|
||||
// sync on manual URL editing
|
||||
if (urlInput) urlInput.addEventListener('input', renderTemplateBuilder);
|
||||
renderTemplateBuilder();
|
||||
|
||||
// Initialize form state
|
||||
if (linkTypeInput.value === 'CUSTOM') {
|
||||
showCustomFields();
|
||||
|
||||
+18
-1
@@ -361,7 +361,24 @@ def redirect_to_original(request, alias, param=None):
|
||||
'link': link,
|
||||
})
|
||||
|
||||
# 如果是模板 URL 并且提供了参数
|
||||
# 模板 URL:query 参数按名映射(?q=xxx&page=2),路径参数兼容旧行为
|
||||
# (go/alias/xxx → 所有未提供值的参数都用 xxx),都没有则用默认值
|
||||
template_params = link.get_template_parameters()
|
||||
if template_params:
|
||||
url_kwargs = {k: v for k, v in request.GET.items() if k in template_params}
|
||||
if param:
|
||||
for name in template_params:
|
||||
if name not in url_kwargs:
|
||||
url_kwargs[name] = param
|
||||
if url_kwargs:
|
||||
url = link.get_processed_url(**url_kwargs)
|
||||
else:
|
||||
url = link.get_processed_url()
|
||||
if url is None:
|
||||
url = link.original_url
|
||||
return redirect(url)
|
||||
|
||||
# 非模板链接:仅兼容旧版路径参数行为
|
||||
if param:
|
||||
# 从 URL 中提取参数名
|
||||
pattern = r'\{([^{}]*)\}'
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for template-link redirects: query-param mapping, path-param
|
||||
compat, and default-value fallback (the Sketch-style template builder
|
||||
produces ?q={q,default=…} style URLs, so the redirect must honor GET)."""
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from links.models import Link
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tpl_link(db):
|
||||
return Link.objects.create(
|
||||
alias="tpltest",
|
||||
original_url="https://www.google.com/search?q={q,default=hello}&page={page,default=1}",
|
||||
link_type=Link.LinkType.LINK,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_template_query_param_mapping(client, tpl_link):
|
||||
resp = client.get(reverse("redirect_to_original", args=["tpltest"]), {"q": "weather"})
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"] == "https://www.google.com/search?q=weather&page=1"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_template_multiple_query_params(client, tpl_link):
|
||||
resp = client.get(reverse("redirect_to_original", args=["tpltest"]), {"q": "stocks", "page": "3"})
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"] == "https://www.google.com/search?q=stocks&page=3"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_template_defaults_when_no_params(client, tpl_link):
|
||||
resp = client.get(reverse("redirect_to_original", args=["tpltest"]))
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"] == "https://www.google.com/search?q=hello&page=1"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_template_path_param_backward_compat(client, tpl_link):
|
||||
"""Old style go/alias/foo fills every unfilled param with foo."""
|
||||
resp = client.get(reverse("redirect_to_original_with_param", args=["tpltest", "weather"]))
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"] == "https://www.google.com/search?q=weather&page=weather"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_template_path_plus_query_query_wins(client, tpl_link):
|
||||
resp = client.get(reverse("redirect_to_original_with_param", args=["tpltest", "foo"]), {"q": "weather"})
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"] == "https://www.google.com/search?q=weather&page=foo"
|
||||
Reference in New Issue
Block a user