From 95601170f6238284c6156d37912e8e51b9ca841a Mon Sep 17 00:00:00 2001 From: warrenchen Date: Sat, 2 May 2026 20:14:02 +0900 Subject: [PATCH] Add newsletter template editor --- innovedus_cms/base/forms.py | 53 ++++++- .../migrations/0010_newslettertemplate.py | 28 ++++ innovedus_cms/base/models.py | 24 ++++ .../static/css/newsletter_template_editor.css | 18 +++ .../static/js/newsletter_template_editor.js | 130 ++++++++++++++++++ innovedus_cms/base/wagtail_hooks.py | 28 +++- 6 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 innovedus_cms/base/migrations/0010_newslettertemplate.py create mode 100644 innovedus_cms/base/static/css/newsletter_template_editor.css create mode 100644 innovedus_cms/base/static/js/newsletter_template_editor.js diff --git a/innovedus_cms/base/forms.py b/innovedus_cms/base/forms.py index 60fecbf..defdf70 100644 --- a/innovedus_cms/base/forms.py +++ b/innovedus_cms/base/forms.py @@ -1,5 +1,7 @@ from django import forms -from .models import ContactFormSubmission +from django.utils.safestring import mark_safe + +from .models import ContactFormSubmission, NewsletterTemplate class NewsletterSubscribeForm(forms.Form): @@ -27,3 +29,52 @@ class ContactForm(forms.ModelForm): widgets = { "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 = """ +
+
+ GrapesJS editor will load from local static assets: + /static/vendor/grapesjs/grapes.min.js and + /static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js. + If missing, fallback textarea remains available. +
+ + +
+ """ + 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.", + } + ), + } diff --git a/innovedus_cms/base/migrations/0010_newslettertemplate.py b/innovedus_cms/base/migrations/0010_newslettertemplate.py new file mode 100644 index 0000000..b0668d2 --- /dev/null +++ b/innovedus_cms/base/migrations/0010_newslettertemplate.py @@ -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"], + }, + ), + ] diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py index 9ef9c87..bbe004b 100644 --- a/innovedus_cms/base/models.py +++ b/innovedus_cms/base/models.py @@ -474,6 +474,30 @@ class NewsletterCampaign(models.Model): 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): campaign = models.ForeignKey( NewsletterCampaign, diff --git a/innovedus_cms/base/static/css/newsletter_template_editor.css b/innovedus_cms/base/static/css/newsletter_template_editor.css new file mode 100644 index 0000000..f862e4b --- /dev/null +++ b/innovedus_cms/base/static/css/newsletter_template_editor.css @@ -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; +} diff --git a/innovedus_cms/base/static/js/newsletter_template_editor.js b/innovedus_cms/base/static/js/newsletter_template_editor.js new file mode 100644 index 0000000..c99bcf0 --- /dev/null +++ b/innovedus_cms/base/static/js/newsletter_template_editor.js @@ -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 '\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('
{{email_body}}
'); + } + + 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(); + } +})(); diff --git a/innovedus_cms/base/wagtail_hooks.py b/innovedus_cms/base/wagtail_hooks.py index 86df348..7603a8f 100644 --- a/innovedus_cms/base/wagtail_hooks.py +++ b/innovedus_cms/base/wagtail_hooks.py @@ -8,7 +8,8 @@ from wagtail.permission_policies import ModelPermissionPolicy from wagtail.snippets.models import register_snippet 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" @@ -83,8 +84,33 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet): 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(NewsletterDispatchRecordViewSet) +register_snippet(NewsletterTemplateViewSet) @hooks.register("register_snippet_listing_buttons")