mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
feat(link form): TEMPLATE becomes its own type with dedicated tab/fields
- models: LinkType gains TEMPLATE (migration 0054, choices only) - link_form: 4 tabs — Link / 🔧 Template / Custom / ⚡ Action; each type shows only its own fields: Template tab owns the Sketch builder, Link tab is a plain URL, Custom owns the markdown editor, Action owns the Discord card + message template; per-type descriptions; tab colors blue/purple/ green/purple - Editing a legacy LINK with {param} in URL auto-switches to Template tab (and saving migrates it to TEMPLATE type) - badges (row/detail/search cards) recognize TEMPLATE type; detail page shows Original URL for TEMPLATE too - forms: TEMPLATE requires original_url
This commit is contained in:
@@ -130,6 +130,8 @@ class LinkForm(forms.ModelForm):
|
||||
|
||||
if link_type == Link.LinkType.LINK and not original_url:
|
||||
raise forms.ValidationError(_("Original URL is required for Link type."))
|
||||
elif link_type == Link.LinkType.TEMPLATE and not original_url:
|
||||
raise forms.ValidationError(_("Original URL is required for Template type."))
|
||||
elif link_type == Link.LinkType.CUSTOM and not text:
|
||||
raise forms.ValidationError(_("Text is required for Custom type."))
|
||||
elif link_type == Link.LinkType.ACTION:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.12 on 2026-08-02 07:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('links', '0053_ask_as_action_link'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='link',
|
||||
name='link_type',
|
||||
field=models.CharField(choices=[('LINK', 'Link'), ('TEMPLATE', 'Template'), ('CUSTOM', 'Custom'), ('ACTION', 'Action')], default='LINK', max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
class Link(models.Model):
|
||||
class LinkType(models.TextChoices):
|
||||
LINK = 'LINK', _('Link')
|
||||
TEMPLATE = 'TEMPLATE', _('Template')
|
||||
CUSTOM = 'CUSTOM', _('Custom')
|
||||
ACTION = 'ACTION', _('Action')
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<a href="{% url 'redirect_to_original' link.alias %}" target="_blank" rel="noopener noreferrer" class="alias-link">{{ link.alias }}</a>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
{% if '{' in link.original_url and '}' in link.original_url %}
|
||||
{% if link.link_type == 'TEMPLATE' or '{' in link.original_url|default:'' and '}' in link.original_url|default:'' %}
|
||||
<span class="badge badge-template"><i class="fas fa-magic" aria-hidden="true"></i>{% trans "Template" %}</span>
|
||||
{% elif link.link_type == 'LINK' %}
|
||||
<span class="badge badge-link"><i class="fas fa-link" aria-hidden="true"></i>{% trans "Link" %}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="link-header-row">
|
||||
<div class="link-type-badge-row">
|
||||
<h1 class="link-detail-title">{% trans "Link Details" %}</h1>
|
||||
{% if '{' in link.original_url and '}' in link.original_url %}
|
||||
{% if link.link_type == 'TEMPLATE' or '{' in link.original_url|default:'' and '}' in link.original_url|default:'' %}
|
||||
<span class="badge badge-template"><i class="fas fa-magic" aria-hidden="true"></i>{% trans "Template" %}</span>
|
||||
{% elif link.link_type == 'LINK' %}
|
||||
<span class="badge badge-link"><i class="fas fa-link" aria-hidden="true"></i>{% trans "Link" %}</span>
|
||||
@@ -53,7 +53,7 @@
|
||||
<dt>{% trans "Updated At" %}</dt>
|
||||
<dd>{{ link.updated_at|date:"Y-m-d H:i" }}</dd>
|
||||
</div>
|
||||
{% if link.link_type == 'LINK' %}
|
||||
{% if link.link_type == 'LINK' or link.link_type == 'TEMPLATE' %}
|
||||
<div class="link-meta-item span-2">
|
||||
<dt>{% trans "Original URL" %}</dt>
|
||||
<dd>
|
||||
|
||||
@@ -154,10 +154,13 @@
|
||||
<!-- Link Type Tabs -->
|
||||
<div class="mb-4">
|
||||
<div class="flex border-b border-gray-200">
|
||||
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if not is_custom %}text-blue-600 border-b-2 border-blue-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-blue-700 hover:border-blue-700 focus:outline-none" id="link-tab">
|
||||
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if not is_custom and not is_template and not is_action %}text-blue-600 border-b-2 border-blue-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-blue-700 hover:border-blue-700 focus:outline-none" id="link-tab">
|
||||
{% trans "Link" %}
|
||||
</button>
|
||||
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_custom %}text-blue-600 border-b-2 border-blue-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-gray-700 hover:border-gray-300 focus:outline-none" id="custom-tab">
|
||||
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_template %}text-purple-600 border-b-2 border-purple-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-purple-700 hover:border-purple-300 focus:outline-none" id="template-tab">
|
||||
🔧 {% trans "Template" %}
|
||||
</button>
|
||||
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_custom %}text-green-600 border-b-2 border-green-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-green-700 hover:border-green-300 focus:outline-none" id="custom-tab">
|
||||
{% trans "Custom" %}
|
||||
</button>
|
||||
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_action %}text-purple-600 border-b-2 border-purple-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-purple-700 hover:border-purple-300 focus:outline-none" id="action-tab">
|
||||
@@ -167,24 +170,24 @@
|
||||
</div>
|
||||
|
||||
<!-- Link Type Description -->
|
||||
<div id="link-description" class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4 {% if is_custom %}hidden{% endif %}">
|
||||
<div id="link-description" class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4 {% if is_custom or is_template or is_action %}hidden{% endif %}">
|
||||
<p class="text-sm text-blue-700">
|
||||
{% trans "Link type is used for redirecting to an external URL. Use this when you want to create a short alias for a long URL." %}
|
||||
</p>
|
||||
</div>
|
||||
<div id="template-description" class="bg-purple-50 border-l-4 border-purple-400 p-4 mb-4 {% if not is_template %}hidden{% endif %}">
|
||||
<p class="text-sm text-purple-700">
|
||||
{% trans "Template type creates dynamic URLs with parameters. Click a parameter chip below to insert it, or type {name,default=value} in the URL." %}
|
||||
</p>
|
||||
<p class="text-sm text-purple-700 mt-2">
|
||||
{% trans "Example: https://google.com/search?q={q, default=hello} — opening go/alias?q=weather fills the parameter." %}
|
||||
</p>
|
||||
</div>
|
||||
<div id="custom-description" class="bg-green-50 border-l-4 border-green-400 p-4 mb-4 {% if not is_custom %}hidden{% endif %}">
|
||||
<p class="text-sm text-green-700">
|
||||
{% trans "Custom type allows you to create a link with custom HTML content. Use this when you want to display a message or custom content instead of redirecting." %}
|
||||
</p>
|
||||
</div>
|
||||
<div id="template-description" class="bg-purple-50 border-l-4 border-purple-400 p-4 mb-4 {% if is_custom or is_action %}hidden{% endif %}">
|
||||
<p class="text-sm text-purple-700">
|
||||
{% trans "Template type allows you to create dynamic URLs with parameters. Use {query, default=value} syntax in the URL." %}
|
||||
</p>
|
||||
<p class="text-sm text-purple-700 mt-2">
|
||||
{% trans "Example: https://google.com/search?q={query, default=hello}" %}
|
||||
</p>
|
||||
</div>
|
||||
<div id="action-description" class="bg-purple-50 border-l-4 border-purple-400 p-4 mb-4 {% if not is_action %}hidden{% endif %}">
|
||||
<p class="text-sm text-purple-700">
|
||||
⚡ 动作链接:打开链接时自动执行一个动作。链接后面跟的字会变成消息内容发给小黑。
|
||||
@@ -205,7 +208,7 @@
|
||||
{% for field in form %}
|
||||
{% if field.name != 'link_type' and field.name != 'action_config' %}
|
||||
<!-- Render fields based on link type -->
|
||||
<div class="space-y-1 {% if field.name == 'original_url' %}link-field{% if is_custom %}hidden{% endif %}{% elif field.name == 'text' %} custom-field {% if not is_custom %}hidden{% endif %}{% elif field.name == 'action_type' or field.name == 'action_message_template' %}action-field{% if not is_action %}hidden{% endif %}{% endif %}">
|
||||
<div class="space-y-1 {% if field.name == 'original_url' %}link-field{% if is_custom or is_action %}hidden{% endif %}{% elif field.name == 'text' %} custom-field {% if not is_custom %}hidden{% endif %}{% elif field.name == 'action_type' or field.name == 'action_message_template' %}action-field{% if not is_action %}hidden{% endif %}{% endif %}">
|
||||
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 {% if field.name == 'original_url' and is_custom or field.name == 'text' and not is_custom or field.name == 'action_type' and not is_action or field.name == 'action_message_template' and not is_action %}hidden{% endif %} {% if field.name != 'description' and field.name != 'action_type' and field.name != 'action_message_template' %}required-field{% endif %}">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
@@ -216,16 +219,16 @@
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% elif field.name == 'original_url' %}
|
||||
<!-- Render original_url field only for default links -->
|
||||
{% if not is_custom %}
|
||||
<!-- Render original_url field for Link & Template links -->
|
||||
{% if not is_custom and not is_action %}
|
||||
<input type="{{ field.field.widget.input_type }}"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
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 %}">
|
||||
<!-- Sketch-style template builder: Template type only -->
|
||||
<div id="template-builder" class="template-builder {% if not is_template %}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">
|
||||
@@ -358,6 +361,7 @@
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const linkTab = document.getElementById('link-tab');
|
||||
const templateTab = document.getElementById('template-tab');
|
||||
const customTab = document.getElementById('custom-tab');
|
||||
const actionTab = document.getElementById('action-tab');
|
||||
const linkFields = document.querySelectorAll('.link-field');
|
||||
@@ -369,102 +373,81 @@
|
||||
const actionDescription = document.getElementById('action-description');
|
||||
const linkTypeInput = document.getElementById('id_link_type');
|
||||
const textField = document.querySelector('.custom-field');
|
||||
const tplBuilder = document.getElementById('template-builder');
|
||||
let simplemde = null;
|
||||
|
||||
function setTabActive(tab, color) {
|
||||
[linkTab, templateTab, customTab, actionTab].forEach(function (t) {
|
||||
t.classList.remove('text-blue-600', 'border-blue-600',
|
||||
'text-purple-600', 'border-purple-600',
|
||||
'text-green-600', 'border-green-600');
|
||||
t.classList.add('text-gray-500', 'border-transparent');
|
||||
});
|
||||
tab.classList.remove('text-gray-500', 'border-transparent');
|
||||
tab.classList.add('text-' + color + '-600', 'border-' + color + '-600');
|
||||
}
|
||||
function showFieldSet(fields, visible) {
|
||||
fields.forEach(function (field) {
|
||||
field.classList.toggle('hidden', !visible);
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.toggle('hidden', !visible);
|
||||
});
|
||||
}
|
||||
|
||||
function showLinkFields() {
|
||||
linkTab.classList.add('text-blue-600', 'border-blue-600');
|
||||
linkTab.classList.remove('text-gray-500', 'border-transparent');
|
||||
customTab.classList.remove('text-blue-600', 'border-blue-600');
|
||||
customTab.classList.add('text-gray-500', 'border-transparent');
|
||||
actionTab.classList.remove('text-purple-600', 'border-purple-600');
|
||||
actionTab.classList.add('text-gray-500', 'border-transparent');
|
||||
linkFields.forEach(field => {
|
||||
field.classList.remove('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.remove('hidden');
|
||||
});
|
||||
customFields.forEach(field => {
|
||||
field.classList.add('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.add('hidden');
|
||||
});
|
||||
actionFields.forEach(field => {
|
||||
field.classList.add('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.add('hidden');
|
||||
});
|
||||
setTabActive(linkTab, 'blue');
|
||||
showFieldSet(linkFields, true);
|
||||
showFieldSet(customFields, false);
|
||||
showFieldSet(actionFields, false);
|
||||
linkDescription.classList.remove('hidden');
|
||||
templateDescription.classList.remove('hidden');
|
||||
templateDescription.classList.add('hidden');
|
||||
customDescription.classList.add('hidden');
|
||||
actionDescription.classList.add('hidden');
|
||||
linkTypeInput.value = 'LINK';
|
||||
textField.classList.add('hidden');
|
||||
const tplBuilder = document.getElementById('template-builder');
|
||||
if (tplBuilder) tplBuilder.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showTemplateFields() {
|
||||
setTabActive(templateTab, 'purple');
|
||||
showFieldSet(linkFields, true);
|
||||
showFieldSet(customFields, false);
|
||||
showFieldSet(actionFields, false);
|
||||
linkDescription.classList.add('hidden');
|
||||
templateDescription.classList.remove('hidden');
|
||||
customDescription.classList.add('hidden');
|
||||
actionDescription.classList.add('hidden');
|
||||
linkTypeInput.value = 'TEMPLATE';
|
||||
textField.classList.add('hidden');
|
||||
if (tplBuilder) tplBuilder.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showCustomFields() {
|
||||
customTab.classList.add('text-blue-600', 'border-blue-600');
|
||||
customTab.classList.remove('text-gray-500', 'border-transparent');
|
||||
linkTab.classList.remove('text-blue-600', 'border-blue-600');
|
||||
linkTab.classList.add('text-gray-500', 'border-transparent');
|
||||
actionTab.classList.remove('text-purple-600', 'border-purple-600');
|
||||
actionTab.classList.add('text-gray-500', 'border-transparent');
|
||||
customFields.forEach(field => {
|
||||
field.classList.remove('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.remove('hidden');
|
||||
});
|
||||
linkFields.forEach(field => {
|
||||
field.classList.add('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.add('hidden');
|
||||
});
|
||||
actionFields.forEach(field => {
|
||||
field.classList.add('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.add('hidden');
|
||||
});
|
||||
setTabActive(customTab, 'green');
|
||||
showFieldSet(linkFields, false);
|
||||
showFieldSet(customFields, true);
|
||||
showFieldSet(actionFields, false);
|
||||
customDescription.classList.remove('hidden');
|
||||
templateDescription.classList.add('hidden');
|
||||
linkDescription.classList.add('hidden');
|
||||
actionDescription.classList.add('hidden');
|
||||
linkTypeInput.value = 'CUSTOM';
|
||||
textField.classList.remove('hidden');
|
||||
const tplBuilder = document.getElementById('template-builder');
|
||||
if (tplBuilder) tplBuilder.classList.add('hidden');
|
||||
initializeSimpleMDE();
|
||||
}
|
||||
|
||||
function showActionFields() {
|
||||
actionTab.classList.add('text-purple-600', 'border-purple-600');
|
||||
actionTab.classList.remove('text-gray-500', 'border-transparent');
|
||||
linkTab.classList.remove('text-blue-600', 'border-blue-600');
|
||||
linkTab.classList.add('text-gray-500', 'border-transparent');
|
||||
customTab.classList.remove('text-blue-600', 'border-blue-600');
|
||||
customTab.classList.add('text-gray-500', 'border-transparent');
|
||||
actionFields.forEach(field => {
|
||||
field.classList.remove('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.remove('hidden');
|
||||
});
|
||||
linkFields.forEach(field => {
|
||||
field.classList.add('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.add('hidden');
|
||||
});
|
||||
customFields.forEach(field => {
|
||||
field.classList.add('hidden');
|
||||
const label = field.querySelector('label');
|
||||
if (label) label.classList.add('hidden');
|
||||
});
|
||||
setTabActive(actionTab, 'purple');
|
||||
showFieldSet(linkFields, false);
|
||||
showFieldSet(customFields, false);
|
||||
showFieldSet(actionFields, true);
|
||||
actionDescription.classList.remove('hidden');
|
||||
templateDescription.classList.add('hidden');
|
||||
linkDescription.classList.add('hidden');
|
||||
customDescription.classList.add('hidden');
|
||||
linkTypeInput.value = 'ACTION';
|
||||
textField.classList.add('hidden');
|
||||
const tplBuilder = document.getElementById('template-builder');
|
||||
if (tplBuilder) tplBuilder.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -525,6 +508,7 @@
|
||||
}
|
||||
|
||||
linkTab.addEventListener('click', showLinkFields);
|
||||
templateTab.addEventListener('click', showTemplateFields);
|
||||
customTab.addEventListener('click', showCustomFields);
|
||||
actionTab.addEventListener('click', showActionFields);
|
||||
|
||||
@@ -762,8 +746,14 @@
|
||||
// Initialize form state
|
||||
if (linkTypeInput.value === 'CUSTOM') {
|
||||
showCustomFields();
|
||||
} else if (linkTypeInput.value === 'TEMPLATE') {
|
||||
showTemplateFields();
|
||||
} else if (linkTypeInput.value === 'ACTION') {
|
||||
showActionFields();
|
||||
} else if (urlInput && urlInput.value.includes('{') && urlInput.value.includes('}')) {
|
||||
// Legacy template links (LINK type + {param} in URL): auto-switch
|
||||
// to the Template tab so the visual builder is available.
|
||||
showTemplateFields();
|
||||
} else {
|
||||
showLinkFields();
|
||||
}
|
||||
|
||||
@@ -1276,6 +1276,7 @@
|
||||
}
|
||||
|
||||
function badgeClassFor(item) {
|
||||
if (item.link_type === 'TEMPLATE') return 'badge-template';
|
||||
if (item.original_url && item.original_url.includes('{') && item.original_url.includes('}')) return 'badge-template';
|
||||
if (item.link_type === 'ACTION') return 'badge-action';
|
||||
if (item.link_type === 'LINK') return 'badge-link';
|
||||
|
||||
@@ -170,6 +170,7 @@ class LinkCreateView(CreateView):
|
||||
# 新建表单:根据当前表单值判断(提交失败重渲染时保持选中状态)
|
||||
form = context.get('form')
|
||||
context['is_custom'] = bool(form and form['link_type'].value() == Link.LinkType.CUSTOM)
|
||||
context['is_template'] = bool(form and form['link_type'].value() == Link.LinkType.TEMPLATE)
|
||||
context['is_action'] = bool(form and form['link_type'].value() == Link.LinkType.ACTION)
|
||||
return context
|
||||
|
||||
@@ -234,6 +235,7 @@ class LinkUpdateView(UpdateView):
|
||||
context = super().get_context_data(**kwargs)
|
||||
link = self.get_object()
|
||||
context['is_custom'] = link.link_type == Link.LinkType.CUSTOM
|
||||
context['is_template'] = link.link_type == Link.LinkType.TEMPLATE
|
||||
context['is_action'] = link.link_type == Link.LinkType.ACTION
|
||||
return context
|
||||
|
||||
|
||||
Reference in New Issue
Block a user