Add newsletter template editor

This commit is contained in:
warrenchen 2026-05-02 20:14:02 +09:00
parent 4303c1f5db
commit 95601170f6
6 changed files with 279 additions and 2 deletions

View File

@ -1,5 +1,7 @@
from django import forms from django import forms
from .models import ContactFormSubmission from django.utils.safestring import mark_safe
from .models import ContactFormSubmission, NewsletterTemplate
class NewsletterSubscribeForm(forms.Form): class NewsletterSubscribeForm(forms.Form):
@ -27,3 +29,52 @@ class ContactForm(forms.ModelForm):
widgets = { widgets = {
"source_page": forms.HiddenInput(), "source_page": forms.HiddenInput(),
} }
class GrapesJSEditorWidget(forms.Textarea):
class Media:
js = (
"js/newsletter_template_editor.js",
)
css = {
"all": (
"css/newsletter_template_editor.css",
)
}
def render(self, name, value, attrs=None, renderer=None):
attrs = attrs or {}
attrs["data-newsletter-html-input"] = "1"
textarea = super().render(name, value, attrs, renderer)
editor_shell = """
<div class="newsletter-editor-shell">
<div class="help warning">
GrapesJS editor will load from local static assets:
<code>/static/vendor/grapesjs/grapes.min.js</code> and
<code>/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js</code>.
If missing, fallback textarea remains available.
</div>
<div class="newsletter-editor-toolbar">
<button type="button" class="button button-small button-secondary" data-newsletter-load-editor>
Load visual editor
</button>
</div>
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor" hidden></div>
</div>
"""
return mark_safe(f"{editor_shell}{textarea}")
class NewsletterTemplateAdminForm(forms.ModelForm):
class Meta:
model = NewsletterTemplate
fields = ["name", "subject", "template_json", "template_html"]
widgets = {
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
"template_html": GrapesJSEditorWidget(
attrs={
"rows": 18,
"placeholder": "Use {{email_body}} as the content placeholder.",
}
),
}

View File

@ -0,0 +1,28 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("base", "0009_alter_newslettercampaign_options"),
]
operations = [
migrations.CreateModel(
name="NewsletterTemplate",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=255, verbose_name="Name")),
("subject", models.CharField(blank=True, default="", max_length=255, verbose_name="Subject")),
("template_json", models.JSONField(blank=True, default=dict, verbose_name="Template JSON")),
("template_html", models.TextField(blank=True, default="", verbose_name="Template HTML")),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")),
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")),
],
options={
"verbose_name": "Newsletter Template",
"verbose_name_plural": "Newsletter Templates",
"ordering": ["-updated_at", "-created_at"],
},
),
]

View File

@ -474,6 +474,30 @@ class NewsletterCampaign(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class NewsletterTemplate(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject"))
template_json = models.JSONField(default=dict, blank=True, verbose_name=_("Template JSON"))
template_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
panels = [
FieldPanel("name"),
FieldPanel("subject"),
FieldPanel("template_json"),
FieldPanel("template_html"),
]
class Meta:
ordering = ["-updated_at", "-created_at"]
verbose_name = _("Newsletter Template")
verbose_name_plural = _("Newsletter Templates")
def __str__(self):
return self.name
class NewsletterDispatchRecord(models.Model): class NewsletterDispatchRecord(models.Model):
campaign = models.ForeignKey( campaign = models.ForeignKey(
NewsletterCampaign, NewsletterCampaign,

View File

@ -0,0 +1,18 @@
.newsletter-editor-shell {
margin-bottom: 12px;
}
.newsletter-editor-toolbar {
margin: 10px 0;
}
.newsletter-grapesjs-editor {
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
background: #fff;
}
textarea[data-newsletter-html-input="1"] {
margin-top: 10px;
}

View File

@ -0,0 +1,130 @@
(function () {
function loadScript(src) {
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
async function ensureGrapesJS() {
if (window.grapesjs) {
return true;
}
try {
await loadScript('/static/vendor/grapesjs/grapes.min.js');
} catch (e) {
return false;
}
try {
await loadScript('/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js');
} catch (e) {
// Plugin is optional; core editor still works.
}
return !!window.grapesjs;
}
function parseJSON(value, fallback) {
try {
return JSON.parse(value);
} catch (e) {
return fallback;
}
}
function getHtmlWithCss(editor) {
var html = editor.getHtml() || '';
var css = editor.getCss() || '';
if (!css.trim()) {
return html;
}
return '<style>' + css + '</style>\n' + html;
}
function bootEditor() {
var htmlInput = document.querySelector('[data-newsletter-html-input="1"]');
var jsonInput = document.querySelector('[data-newsletter-json-input="1"]');
var container = document.getElementById('newsletter-grapesjs-editor');
var loadButton = document.querySelector('[data-newsletter-load-editor]');
if (!htmlInput || !jsonInput || !container || !loadButton) {
return;
}
var initialized = false;
loadButton.addEventListener('click', async function () {
if (initialized) {
return;
}
loadButton.disabled = true;
loadButton.textContent = 'Loading...';
var ok = await ensureGrapesJS();
if (!ok) {
loadButton.textContent = 'GrapesJS static files not found';
return;
}
container.hidden = false;
container.innerHTML = '';
var plugins = [];
if (window['grapesjs-preset-newsletter']) {
plugins.push('grapesjs-preset-newsletter');
}
var editor = window.grapesjs.init({
container: '#newsletter-grapesjs-editor',
height: '620px',
storageManager: false,
fromElement: false,
plugins: plugins,
});
var savedProject = parseJSON(jsonInput.value || '{}', {});
if (savedProject && Object.keys(savedProject).length > 0) {
try {
editor.loadProjectData(savedProject);
} catch (e) {
if ((htmlInput.value || '').trim()) {
editor.setComponents(htmlInput.value);
}
}
} else if ((htmlInput.value || '').trim()) {
editor.setComponents(htmlInput.value);
} else {
editor.setComponents('<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td>{{email_body}}</td></tr></table>');
}
var form = htmlInput.closest('form');
if (form) {
form.addEventListener('submit', function () {
try {
var projectData = editor.getProjectData();
jsonInput.value = JSON.stringify(projectData);
} catch (e) {
jsonInput.value = '{}';
}
htmlInput.value = getHtmlWithCss(editor);
});
}
initialized = true;
loadButton.textContent = 'Visual editor loaded';
loadButton.disabled = true;
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootEditor);
} else {
bootEditor();
}
})();

View File

@ -8,7 +8,8 @@ from wagtail.permission_policies import ModelPermissionPolicy
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet from wagtail.snippets.views.snippets import CreateView, SnippetViewSet
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings from .forms import NewsletterTemplateAdminForm
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign" SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
@ -83,8 +84,33 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
search_fields = ["email", "subscriber_id", "campaign__title"] search_fields = ["email", "subscriber_id", "campaign__title"]
class NewsletterTemplateViewSet(SnippetViewSet):
model = NewsletterTemplate
icon = "doc-full-inverse"
menu_label = _("Newsletter templates")
menu_order = 252
add_to_admin_menu = True
form_class = NewsletterTemplateAdminForm
base_form_class = NewsletterTemplateAdminForm
list_display = ["name", "subject", "updated_at", "created_at"]
search_fields = ["name", "subject"]
panels = [
FieldPanel("name"),
FieldPanel("subject"),
FieldPanel(
"template_json",
help_text=_("Stored as editor state (hidden in form)."),
),
FieldPanel(
"template_html",
help_text=_("Use {{email_body}} as content placeholder."),
),
]
register_snippet(NewsletterCampaignViewSet) register_snippet(NewsletterCampaignViewSet)
register_snippet(NewsletterDispatchRecordViewSet) register_snippet(NewsletterDispatchRecordViewSet)
register_snippet(NewsletterTemplateViewSet)
@hooks.register("register_snippet_listing_buttons") @hooks.register("register_snippet_listing_buttons")