Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de1c033c5 | ||
|
|
fe70e1847e | ||
|
|
8c4ce7b92e | ||
|
|
b16ee811e3 | ||
|
|
ed9020839c | ||
|
|
95601170f6 | ||
|
|
4303c1f5db | ||
|
|
c8bcdb0ee6 | ||
|
|
3e2a290e44 | ||
|
|
019faf4459 | ||
|
|
ec5d55560a | ||
|
|
5a4aea9a39 | ||
|
|
36f4d8bb15 | ||
|
|
a3f7043aea | ||
|
|
73f8442796 | ||
|
|
f55c766881 | ||
|
|
485818c22a | ||
|
|
2719d84c5b | ||
|
|
eb8307cb3b | ||
|
|
9ffbcb0a65 | ||
|
|
40dee52d16 |
6
.vscode/launch.json
vendored
6
.vscode/launch.json
vendored
@ -6,10 +6,16 @@
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/innovedus_cms/manage.py",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"args": ["runserver", "0.0.0.0:8000"],
|
||||
"django": true,
|
||||
"justMyCode": true,
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"MEDIA_URL": "/media/",
|
||||
"AWS_S3_CUSTOM_DOMAIN": "localhost:8000/media",
|
||||
"AWS_S3_URL_PROTOCOL": "http:"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
|
||||
@ -421,3 +421,163 @@ Send Engine 最終態(terminal):
|
||||
建議:
|
||||
- 做「最終寄出 HTML 預覽」功能,預覽管線與正式發送使用同一套 renderer。
|
||||
- 後續可加多裝置寬度與常見 email client 快速檢視模式(若使用者回饋有需要)。
|
||||
|
||||
## 10. 電子報「類型化編輯」設計分析與實作計劃(2026-05-13)
|
||||
|
||||
需求摘要(已確認):
|
||||
- 建立電子報時,先選「電子報類型」再進入編輯。
|
||||
- 每種電子報類型有預設 template,但允許使用者改模板。
|
||||
- 既有類型:`通用電子報`(自由編輯內容)。
|
||||
- 新增類型:`每週新聞電子報`(選文章組版)。
|
||||
- `每週新聞`除模板與文章選擇外,還要有「單則新聞 HTML 樣式」:
|
||||
- 系統提供預設值。
|
||||
- 每封電子報可覆寫(通常不改,必要時可改)。
|
||||
- 預覽功能必須保留,且要反映「套模板後最終結果」。
|
||||
- 後續新增定型電子報,接受「需要改程式」。
|
||||
|
||||
### 10.1 可行性結論
|
||||
|
||||
可行,且建議採「類型策略(type strategy)」實作:
|
||||
- 資料層只新增最少欄位承載「類型 + 類型專屬設定」。
|
||||
- 組版/預覽/發送統一走同一組 renderer 介面,依類型分派。
|
||||
- 後續加新類型時,不改既有類型流程,僅新增一個 renderer + 後台欄位配置。
|
||||
|
||||
### 10.2 目標操作流程(UX)
|
||||
|
||||
1. 使用者在 Snippet 列表按「新增電子報」。
|
||||
2. 第一步顯示「選擇電子報類型」頁:
|
||||
- `general` 通用電子報
|
||||
- `weekly_news` 每週新聞電子報
|
||||
3. 選類型後進入對應編輯畫面(或同畫面動態顯示對應欄位)。
|
||||
4. 系統帶入該類型預設 template(可改)。
|
||||
5. `weekly_news` 類型需額外完成:
|
||||
- 選文章(可多選、可排序)
|
||||
- 單則新聞樣式(預設值 + 可覆寫)
|
||||
6. 預覽按鈕顯示最終結果(文章內容 + 單則樣式 + 外層模板)。
|
||||
7. 發送時使用相同 renderer,避免預覽與實際寄送不一致。
|
||||
|
||||
### 10.3 資料模型規劃(最小變更)
|
||||
|
||||
建議在 `NewsletterCampaign` 新增:
|
||||
- `campaign_type`(CharField)
|
||||
- choices: `general`, `weekly_news`
|
||||
- default: `general`
|
||||
- `item_template_html`(TextField, blank=True)
|
||||
- 用於 `weekly_news` 單則新聞樣式覆寫
|
||||
- `content_config`(JSONField, default=dict, blank=True)
|
||||
- 先用於存 `weekly_news` 文章清單與排序資訊
|
||||
- 範例:
|
||||
- `{"article_page_ids":[123,456,789]}`
|
||||
|
||||
建議在 `NewsletterTemplate` 新增(可選,但推薦):
|
||||
- `template_type`(CharField)
|
||||
- 用於限制模板類型對應(避免 weekly 套到不相容模板)
|
||||
- 若先求快可不加,先用通用模板 + placeholder 規範。
|
||||
|
||||
建議新增「類型預設設定」來源(二選一):
|
||||
1. 快速版:在程式常數定義每種類型預設值(包括預設 item template)。
|
||||
2. 長期版:加 `NewsletterTypeSettings`(Wagtail setting/snippet)供後台改預設值。
|
||||
|
||||
### 10.4 渲染與發送策略(核心)
|
||||
|
||||
新增 renderer 介面(概念):
|
||||
- `build_campaign_body_html(campaign) -> str`
|
||||
- `build_campaign_body_text(campaign) -> str`
|
||||
- `build_preview_html(campaign_or_payload) -> str`
|
||||
|
||||
類型分派:
|
||||
- `general`:
|
||||
- 直接沿用既有 `html_template` / `text_template` 流程。
|
||||
- `weekly_news`:
|
||||
- 讀 `content_config.article_page_ids`
|
||||
- 載入文章(標題、連結、摘要、封面等)
|
||||
- 以 `item_template_html` 逐筆渲染並串接成 `email_body`
|
||||
- 再套外層 `newsletter_template.template_html`
|
||||
|
||||
注意:
|
||||
- 預覽 API 與發送 job 必須共用同一 renderer。
|
||||
- 不把套完外層模板的結果存回 DB(維持既有原則:預覽/發送當下組裝)。
|
||||
|
||||
### 10.5 後台表單與互動規劃
|
||||
|
||||
#### A. 建立流程(先選類型)
|
||||
- 做法 1(推薦):客製 `CreateView`,先顯示 `campaign_type`,提交後導到 edit。
|
||||
- 做法 2:進 create 頁先顯示全欄位,但用 JS 依 `campaign_type` 切換可見區塊。
|
||||
|
||||
#### B. `general` 欄位
|
||||
- 顯示:`subject_template`, `html_template`, `text_template`, `newsletter_template`
|
||||
- 隱藏:`weekly_news` 文章選擇區與 item template(或只讀)
|
||||
|
||||
#### C. `weekly_news` 欄位
|
||||
- 顯示:
|
||||
- `newsletter_template`
|
||||
- 文章選擇器(多選 + 排序)
|
||||
- `item_template_html`(有預設值,可覆寫)
|
||||
- (可選)`subject_template`(若空可由 template 預設 subject 帶入)
|
||||
- 隱藏:
|
||||
- 自由編輯 `html_template`(或改成唯讀顯示最終組版來源)
|
||||
|
||||
### 10.6 預覽功能規劃
|
||||
|
||||
新增/調整 preview endpoint:
|
||||
- 接收 `campaign_type` 與對應 payload。
|
||||
- `weekly_news` 預覽時直接用目前畫面文章清單 + item template 組版,不必先存檔。
|
||||
- 回傳最終 HTML(已套外層模板、已替換示例變數)。
|
||||
|
||||
### 10.7 每週新聞(Weekly)預設單則樣式建議
|
||||
|
||||
預設 `item_template_html` 建議包含:
|
||||
- `{{article_title}}`
|
||||
- `{{article_url}}`
|
||||
- `{{article_cover_url}}`
|
||||
- `{{article_intro}}`
|
||||
- `{{article_date}}`
|
||||
|
||||
建議 fallback 規則:
|
||||
- 無封面圖:隱藏 `<img>` 區塊或改用預設圖。
|
||||
- 無摘要:顯示標題 + 連結即可。
|
||||
|
||||
### 10.8 實作步驟(建議)
|
||||
|
||||
1. Model + migration
|
||||
- `NewsletterCampaign` 新增 `campaign_type`, `item_template_html`, `content_config`。
|
||||
|
||||
2. Admin form / panels
|
||||
- 先選類型流程(create view)+ 欄位顯示切換。
|
||||
- `weekly_news` 文章選擇與排序 UI(第一版可先用簡單多選)。
|
||||
|
||||
3. Renderer
|
||||
- 抽出 `general` / `weekly_news` 共用入口。
|
||||
- 串入 preview API 與 send scheduler。
|
||||
|
||||
4. Preview
|
||||
- 擴充現有 preview compose 邏輯,使其依 `campaign_type` 分派。
|
||||
|
||||
5. Send
|
||||
- `_build_send_job_payload` 改為依 renderer 組 body,再送 Send Engine。
|
||||
|
||||
6. 測試
|
||||
- `general` 回歸測試(不可壞)。
|
||||
- `weekly_news` 組版、預覽、發送一致性測試。
|
||||
|
||||
### 10.9 風險與控管
|
||||
|
||||
風險:
|
||||
- 類型欄位加入後,既有 campaign 資料相容性。
|
||||
- preview 與 send 使用不同邏輯導致內容不一致。
|
||||
- 文章多選排序 UI 複雜度偏高。
|
||||
|
||||
控管:
|
||||
- 既有資料 migration 一律補 `campaign_type='general'`。
|
||||
- renderer 單一路徑,preview/send 共用。
|
||||
- `weekly_news` 第一版先做可用,再迭代進階排序體驗。
|
||||
|
||||
### 10.10 後續新增定型電子報的擴充規範
|
||||
|
||||
每新增一種類型至少變更:
|
||||
1. `campaign_type` choices
|
||||
2. 該類型後台欄位配置
|
||||
3. 該類型 renderer
|
||||
4. 該類型 preview payload 驗證
|
||||
|
||||
此規範符合「新增定型電子報需要改程式」的產品決策,且可保持改動局部化。
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
from django import forms
|
||||
from .models import ContactFormSubmission
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
from wagtail.admin.rich_text import DraftailRichTextArea
|
||||
|
||||
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
|
||||
|
||||
|
||||
class NewsletterSubscribeForm(forms.Form):
|
||||
@ -27,3 +31,147 @@ class ContactForm(forms.ModelForm):
|
||||
widgets = {
|
||||
"source_page": forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class GrapesJSEditorWidget(forms.Textarea):
|
||||
class Media:
|
||||
js = (
|
||||
"js/newsletter_template_editor.js",
|
||||
)
|
||||
css = {
|
||||
"all": (
|
||||
"vendor/grapesjs/grapes.min.css",
|
||||
"css/newsletter_template_editor.css",
|
||||
)
|
||||
}
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
attrs = attrs or {}
|
||||
attrs["data-newsletter-html-input"] = "1"
|
||||
attrs["data-newsletter-media-url"] = (settings.MEDIA_URL or "").strip()
|
||||
attrs["hidden"] = "hidden"
|
||||
textarea = super().render(name, value, attrs, renderer)
|
||||
editor_shell = """
|
||||
<div class="newsletter-editor-shell">
|
||||
<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>
|
||||
<div class="newsletter-editor-toolbar">
|
||||
<button type="button" class="button button-small button-secondary" data-newsletter-preview>
|
||||
Preview with sample content
|
||||
</button>
|
||||
</div>
|
||||
<div class="newsletter-preview-shell" data-newsletter-preview-shell hidden>
|
||||
<div class="newsletter-preview-shell__head">
|
||||
<strong>Preview</strong>
|
||||
<button type="button" class="button button-small button-secondary" data-newsletter-preview-close>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<iframe class="newsletter-preview-frame" data-newsletter-preview-frame title="Newsletter preview"></iframe>
|
||||
</div>
|
||||
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor"></div>
|
||||
</div>
|
||||
"""
|
||||
return mark_safe(f"{editor_shell}{textarea}")
|
||||
|
||||
|
||||
class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
||||
class Media:
|
||||
js = ("js/newsletter_campaign_editor.js",)
|
||||
css = {"all": ("css/newsletter_campaign_editor.css",)}
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
return super().render(name, value, attrs, renderer)
|
||||
|
||||
|
||||
class NewsletterTemplateAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = NewsletterTemplate
|
||||
fields = ["name", "subject", "template_json", "template_html", "template_text"]
|
||||
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.",
|
||||
}
|
||||
),
|
||||
"template_text": forms.Textarea(
|
||||
attrs={
|
||||
"rows": 8,
|
||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class NewsletterCampaignAdminForm(forms.ModelForm):
|
||||
class Media:
|
||||
js = ("js/newsletter_campaign_editor.js",)
|
||||
css = {"all": ("css/newsletter_campaign_editor.css",)}
|
||||
|
||||
class Meta:
|
||||
model = NewsletterCampaign
|
||||
fields = [
|
||||
"title",
|
||||
"campaign_type",
|
||||
"newsletter_template",
|
||||
"list_id",
|
||||
"subject_template",
|
||||
"html_template",
|
||||
"text_template",
|
||||
"weekly_articles",
|
||||
"item_template_html",
|
||||
"scheduled_at",
|
||||
]
|
||||
widgets = {
|
||||
"html_template": NewsletterCampaignEditorWidget(
|
||||
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
||||
),
|
||||
"weekly_articles": forms.CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
from home.models import ArticlePage
|
||||
|
||||
self.fields["weekly_articles"].queryset = ArticlePage.objects.live().order_by("-date", "-id")
|
||||
self.fields["weekly_articles"].help_text = "每週新聞類型請勾選要帶入的文章(可多選)。"
|
||||
self.fields["campaign_type"].required = True
|
||||
# Required constraints are enforced by campaign type in clean()
|
||||
self.fields["html_template"].required = False
|
||||
self.fields["text_template"].required = False
|
||||
self.fields["weekly_articles"].required = False
|
||||
self.fields["item_template_html"].required = False
|
||||
if self.instance and self.instance.pk:
|
||||
self.fields["campaign_type"].disabled = True
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
campaign_type = cleaned.get("campaign_type")
|
||||
if self.instance and self.instance.pk:
|
||||
campaign_type = self.instance.campaign_type
|
||||
articles = cleaned.get("weekly_articles")
|
||||
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS and not articles:
|
||||
self.add_error("weekly_articles", "每週新聞電子報至少要選擇一篇文章。")
|
||||
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||
cleaned["html_template"] = "<p></p>"
|
||||
if campaign_type != NewsletterCampaign.TYPE_WEEKLY_NEWS and not (cleaned.get("html_template") or "").strip():
|
||||
self.add_error("html_template", "一般電子報需要填寫內容。")
|
||||
return cleaned
|
||||
|
||||
def save(self, commit=True):
|
||||
obj = super().save(commit=False)
|
||||
selected = self.cleaned_data.get("weekly_articles") or []
|
||||
if obj.campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||
obj.html_template = "<p></p>"
|
||||
obj.content_config = {
|
||||
**(obj.content_config or {}),
|
||||
"article_page_ids": [int(p.pk) for p in selected],
|
||||
}
|
||||
else:
|
||||
obj.content_config = {}
|
||||
obj.item_template_html = obj.item_template_html or ""
|
||||
if commit:
|
||||
obj.save()
|
||||
self.save_m2m()
|
||||
return obj
|
||||
|
||||
@ -0,0 +1,122 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
import wagtail.fields
|
||||
|
||||
|
||||
def copy_newsletter_template_settings(apps, schema_editor):
|
||||
SystemNotificationMailSettings = apps.get_model("base", "SystemNotificationMailSettings")
|
||||
NewsletterTemplateSettings = apps.get_model("base", "NewsletterTemplateSettings")
|
||||
|
||||
src = NewsletterTemplateSettings.objects.order_by("id").first()
|
||||
if not src:
|
||||
return
|
||||
for target in SystemNotificationMailSettings.objects.all():
|
||||
target.subscribe_subject_template = src.subscribe_subject_template
|
||||
target.subscribe_html_template = src.subscribe_html_template
|
||||
target.subscribe_text_template = src.subscribe_text_template
|
||||
target.confirm_success_template = src.confirm_success_template
|
||||
target.confirm_failure_template = src.confirm_failure_template
|
||||
target.unsubscribe_intro_template = src.unsubscribe_intro_template
|
||||
target.unsubscribe_success_template = src.unsubscribe_success_template
|
||||
target.unsubscribe_failure_template = src.unsubscribe_failure_template
|
||||
target.save(
|
||||
update_fields=[
|
||||
"subscribe_subject_template",
|
||||
"subscribe_html_template",
|
||||
"subscribe_text_template",
|
||||
"confirm_success_template",
|
||||
"confirm_failure_template",
|
||||
"unsubscribe_intro_template",
|
||||
"unsubscribe_success_template",
|
||||
"unsubscribe_failure_template",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("base", "0008_alter_contactformsubmission_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="newslettercampaign",
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"permissions": [("send_newslettercampaign", "Can send newsletter campaign")],
|
||||
"verbose_name": "Newsletter Campaign",
|
||||
"verbose_name_plural": "Newsletter Campaigns",
|
||||
},
|
||||
),
|
||||
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")),
|
||||
("template_text", models.TextField(blank=True, default="", verbose_name="Template Text")),
|
||||
("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"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="systemnotificationmailsettings",
|
||||
name="confirm_failure_template",
|
||||
field=wagtail.fields.RichTextField(blank=True, default="<p>訂閱確認失敗,請稍後再試。</p>", verbose_name="Confirm Failure Template"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="systemnotificationmailsettings",
|
||||
name="confirm_success_template",
|
||||
field=wagtail.fields.RichTextField(blank=True, default="<p>訂閱確認成功。</p>", verbose_name="Confirm Success Template"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="systemnotificationmailsettings",
|
||||
name="subscribe_html_template",
|
||||
field=models.TextField(default="<p>您好,請點擊以下連結完成訂閱:</p><p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>", verbose_name="Subscribe HTML Template"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="systemnotificationmailsettings",
|
||||
name="subscribe_subject_template",
|
||||
field=models.CharField(default="請確認您的電子報訂閱", max_length=255, verbose_name="Subscribe Subject Template"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="systemnotificationmailsettings",
|
||||
name="subscribe_text_template",
|
||||
field=models.TextField(default="您好,請點擊以下連結完成訂閱:{{confirm_url}}", verbose_name="Subscribe Text Template"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="systemnotificationmailsettings",
|
||||
name="unsubscribe_failure_template",
|
||||
field=wagtail.fields.RichTextField(blank=True, default="<p>退訂失敗,請稍後再試。</p>", verbose_name="Unsubscribe Failure Template"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="systemnotificationmailsettings",
|
||||
name="unsubscribe_intro_template",
|
||||
field=wagtail.fields.RichTextField(blank=True, default="<p>確認要退訂電子報嗎?</p>", verbose_name="Unsubscribe Intro Template"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="systemnotificationmailsettings",
|
||||
name="unsubscribe_success_template",
|
||||
field=wagtail.fields.RichTextField(blank=True, default="<p>已完成退訂。</p>", verbose_name="Unsubscribe Success Template"),
|
||||
),
|
||||
migrations.RunPython(copy_newsletter_template_settings, migrations.RunPython.noop),
|
||||
migrations.AddField(
|
||||
model_name="newslettercampaign",
|
||||
name="newsletter_template",
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="campaigns", to="base.newslettertemplate", verbose_name="Newsletter Template"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="newslettercampaign",
|
||||
name="html_template",
|
||||
field=wagtail.fields.RichTextField(verbose_name="HTML Template"),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("base", "0009_newsletter_templates_bundle"),
|
||||
("home", "0004_articlepage_not_news"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="newslettercampaign",
|
||||
name="campaign_type",
|
||||
field=models.CharField(
|
||||
choices=[("general", "General Newsletter"), ("weekly_news", "Weekly News Newsletter")],
|
||||
default="general",
|
||||
max_length=32,
|
||||
verbose_name="Campaign Type",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="newslettercampaign",
|
||||
name="content_config",
|
||||
field=models.JSONField(blank=True, default=dict, verbose_name="Content Config"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="newslettercampaign",
|
||||
name="item_template_html",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default='<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;"><tr><td style="padding:0;"><a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;"><img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;"></a><h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;"><a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a></h3><p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p><p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p></td></tr></table>',
|
||||
verbose_name="Weekly Item HTML Template",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="newslettercampaign",
|
||||
name="weekly_articles",
|
||||
field=models.ManyToManyField(blank=True, related_name="newsletter_campaigns", to="home.articlepage", verbose_name="Weekly Articles"),
|
||||
),
|
||||
]
|
||||
@ -376,6 +376,47 @@ class SystemNotificationMailSettings(BaseGenericSetting):
|
||||
),
|
||||
verbose_name=_("User Copy HTML Template"),
|
||||
)
|
||||
subscribe_subject_template = models.CharField(
|
||||
max_length=255,
|
||||
default="請確認您的電子報訂閱",
|
||||
verbose_name=_("Subscribe Subject Template"),
|
||||
)
|
||||
subscribe_html_template = models.TextField(
|
||||
default=(
|
||||
"<p>您好,請點擊以下連結完成訂閱:</p>"
|
||||
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
|
||||
),
|
||||
verbose_name=_("Subscribe HTML Template"),
|
||||
)
|
||||
subscribe_text_template = models.TextField(
|
||||
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
|
||||
verbose_name=_("Subscribe Text Template"),
|
||||
)
|
||||
confirm_success_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>訂閱確認成功。</p>",
|
||||
verbose_name=_("Confirm Success Template"),
|
||||
)
|
||||
confirm_failure_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>訂閱確認失敗,請稍後再試。</p>",
|
||||
verbose_name=_("Confirm Failure Template"),
|
||||
)
|
||||
unsubscribe_intro_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>確認要退訂電子報嗎?</p>",
|
||||
verbose_name=_("Unsubscribe Intro Template"),
|
||||
)
|
||||
unsubscribe_success_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>已完成退訂。</p>",
|
||||
verbose_name=_("Unsubscribe Success Template"),
|
||||
)
|
||||
unsubscribe_failure_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>退訂失敗,請稍後再試。</p>",
|
||||
verbose_name=_("Unsubscribe Failure Template"),
|
||||
)
|
||||
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
|
||||
|
||||
panels = [
|
||||
@ -393,6 +434,24 @@ class SystemNotificationMailSettings(BaseGenericSetting):
|
||||
],
|
||||
heading=_("Contact Us Notification Mail"),
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("subscribe_subject_template"),
|
||||
FieldPanel("subscribe_html_template"),
|
||||
FieldPanel("subscribe_text_template"),
|
||||
],
|
||||
heading=_("Subscribe Confirmation Email"),
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("confirm_success_template"),
|
||||
FieldPanel("confirm_failure_template"),
|
||||
FieldPanel("unsubscribe_intro_template"),
|
||||
FieldPanel("unsubscribe_success_template"),
|
||||
FieldPanel("unsubscribe_failure_template"),
|
||||
],
|
||||
heading=_("Page Templates"),
|
||||
),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@ -421,6 +480,13 @@ class OneClickUnsubscribeAudit(models.Model):
|
||||
|
||||
|
||||
class NewsletterCampaign(models.Model):
|
||||
TYPE_GENERAL = "general"
|
||||
TYPE_WEEKLY_NEWS = "weekly_news"
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_GENERAL, _("General Newsletter")),
|
||||
(TYPE_WEEKLY_NEWS, _("Weekly News Newsletter")),
|
||||
]
|
||||
|
||||
STATUS_DRAFT = "draft"
|
||||
STATUS_SCHEDULED = "scheduled"
|
||||
STATUS_SENDING = "sending"
|
||||
@ -435,10 +501,51 @@ class NewsletterCampaign(models.Model):
|
||||
]
|
||||
|
||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||
campaign_type = models.CharField(
|
||||
max_length=32,
|
||||
choices=TYPE_CHOICES,
|
||||
default=TYPE_GENERAL,
|
||||
verbose_name=_("Campaign Type"),
|
||||
)
|
||||
newsletter_template = models.ForeignKey(
|
||||
"base.NewsletterTemplate",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="campaigns",
|
||||
verbose_name=_("Newsletter Template"),
|
||||
)
|
||||
list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
|
||||
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
|
||||
html_template = models.TextField(verbose_name=_("HTML Template"))
|
||||
html_template = RichTextField(verbose_name=_("HTML Template"))
|
||||
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
|
||||
weekly_articles = models.ManyToManyField(
|
||||
"home.ArticlePage",
|
||||
blank=True,
|
||||
related_name="newsletter_campaigns",
|
||||
verbose_name=_("Weekly Articles"),
|
||||
)
|
||||
item_template_html = models.TextField(
|
||||
blank=True,
|
||||
default=(
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;">'
|
||||
"<tr>"
|
||||
'<td style="padding:0;">'
|
||||
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;">'
|
||||
'<img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;">'
|
||||
"</a>"
|
||||
'<h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;">'
|
||||
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a>'
|
||||
"</h3>"
|
||||
'<p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p>'
|
||||
'<p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p>'
|
||||
"</td>"
|
||||
"</tr>"
|
||||
"</table>"
|
||||
),
|
||||
verbose_name=_("Weekly Item HTML Template"),
|
||||
)
|
||||
content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config"))
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
|
||||
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
|
||||
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
|
||||
@ -448,10 +555,15 @@ class NewsletterCampaign(models.Model):
|
||||
|
||||
panels = [
|
||||
FieldPanel("title"),
|
||||
FieldPanel("campaign_type"),
|
||||
FieldPanel("newsletter_template"),
|
||||
FieldPanel("list_id"),
|
||||
FieldPanel("subject_template"),
|
||||
FieldPanel("html_template"),
|
||||
FieldPanel("text_template"),
|
||||
FieldPanel("weekly_articles"),
|
||||
FieldPanel("item_template_html"),
|
||||
FieldPanel("content_config"),
|
||||
FieldPanel("scheduled_at"),
|
||||
]
|
||||
|
||||
@ -459,6 +571,9 @@ class NewsletterCampaign(models.Model):
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = _("Newsletter Campaign")
|
||||
verbose_name_plural = _("Newsletter Campaigns")
|
||||
permissions = [
|
||||
("send_newslettercampaign", "Can send newsletter campaign"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@ -471,6 +586,32 @@ 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"))
|
||||
template_text = models.TextField(blank=True, default="", verbose_name=_("Template Text"))
|
||||
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"),
|
||||
FieldPanel("template_text"),
|
||||
]
|
||||
|
||||
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,
|
||||
@ -509,7 +650,6 @@ class NewsletterDispatchRecord(models.Model):
|
||||
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
|
||||
|
||||
|
||||
@register_setting
|
||||
class NewsletterTemplateSettings(BaseGenericSetting):
|
||||
subscribe_subject_template = models.CharField(
|
||||
max_length=255,
|
||||
|
||||
@ -13,6 +13,8 @@ from urllib.request import Request, urlopen
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.html import escape
|
||||
from wagtail.rich_text import expand_db_html
|
||||
|
||||
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
|
||||
@ -189,6 +191,25 @@ def render_placeholders(template: str, values: dict) -> str:
|
||||
return rendered
|
||||
|
||||
|
||||
def _convert_draftail_contentstate_to_html(value: str) -> str:
|
||||
raw = (value or "").strip()
|
||||
if not raw or not raw.startswith("{"):
|
||||
return value or ""
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return value or ""
|
||||
if not isinstance(payload, dict) or "blocks" not in payload:
|
||||
return value or ""
|
||||
try:
|
||||
from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
|
||||
|
||||
converter = ContentstateConverter(features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"])
|
||||
return converter.to_database_format(raw)
|
||||
except Exception:
|
||||
return value or ""
|
||||
|
||||
|
||||
def _absolutize_links(html: str, site_base_url: str) -> str:
|
||||
base = (site_base_url or "").strip().rstrip("/")
|
||||
if not base:
|
||||
@ -203,6 +224,17 @@ def _absolutize_links(html: str, site_base_url: str) -> str:
|
||||
return pattern.sub(_replace, html)
|
||||
|
||||
|
||||
def _strip_grapesjs_svg_placeholder_images(html: str) -> str:
|
||||
# GrapesJS uses inline SVG placeholder data URIs when image src is missing.
|
||||
# These should not be sent as real email images.
|
||||
return re.sub(
|
||||
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>",
|
||||
"",
|
||||
html or "",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
|
||||
rendered = render_placeholders(template, values)
|
||||
try:
|
||||
@ -213,14 +245,88 @@ def render_newsletter_html(template: str, values: dict, site_base_url: str = "")
|
||||
|
||||
|
||||
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
|
||||
rendered = template or ""
|
||||
rendered = _convert_draftail_contentstate_to_html(template or "")
|
||||
try:
|
||||
rendered = expand_db_html(rendered)
|
||||
except Exception:
|
||||
pass
|
||||
rendered = _strip_grapesjs_svg_placeholder_images(rendered)
|
||||
return _absolutize_links(rendered, site_base_url)
|
||||
|
||||
|
||||
def render_newsletter_text_for_send_job(template: str, site_base_url: str = "") -> str:
|
||||
html = render_newsletter_html_for_send_job(template or "", site_base_url=site_base_url)
|
||||
return strip_tags(html).strip()
|
||||
|
||||
|
||||
def compose_newsletter_template_html(*, layout_html: str, email_body_html: str) -> str:
|
||||
layout = (layout_html or "").strip()
|
||||
body = _convert_draftail_contentstate_to_html(email_body_html or "")
|
||||
if not layout:
|
||||
return body
|
||||
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
|
||||
return (
|
||||
layout.replace("{{email_body}}", body)
|
||||
.replace("{{html_body}}", body)
|
||||
.replace("{{text_body}}", body)
|
||||
)
|
||||
# If no placeholder is present, append body to avoid accidental body drop.
|
||||
return f"{layout}\n{body}" if body else layout
|
||||
|
||||
|
||||
def compose_newsletter_template_text(*, layout_text: str, email_body_text: str) -> str:
|
||||
layout = (layout_text or "").strip()
|
||||
body = email_body_text or ""
|
||||
if not layout:
|
||||
return body
|
||||
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
|
||||
return (
|
||||
layout.replace("{{email_body}}", body)
|
||||
.replace("{{html_body}}", body)
|
||||
.replace("{{text_body}}", body)
|
||||
)
|
||||
return f"{layout}\n{body}" if body else layout
|
||||
|
||||
|
||||
def build_weekly_news_body_html(*, article_page_ids: list[int], item_template_html: str, site_base_url: str = "") -> str:
|
||||
ids = [int(v) for v in (article_page_ids or []) if str(v).isdigit()]
|
||||
if not ids:
|
||||
return ""
|
||||
|
||||
from home.models import ArticlePage
|
||||
|
||||
pages_by_id = {p.id: p for p in ArticlePage.objects.live().filter(id__in=ids)}
|
||||
blocks = []
|
||||
template = (item_template_html or "").strip()
|
||||
for pid in ids:
|
||||
page = pages_by_id.get(pid)
|
||||
if not page:
|
||||
continue
|
||||
cover_url = ""
|
||||
if page.cover_image_id and page.cover_image:
|
||||
try:
|
||||
cover_url = page.cover_image.get_rendition("max-640x360").url
|
||||
except Exception:
|
||||
cover_url = page.cover_image.file.url
|
||||
if site_base_url and cover_url.startswith("/"):
|
||||
cover_url = urljoin(f"{site_base_url.rstrip('/')}/", cover_url.lstrip("/"))
|
||||
article_url = page.url or ""
|
||||
if site_base_url and article_url.startswith("/"):
|
||||
article_url = urljoin(f"{site_base_url.rstrip('/')}/", article_url.lstrip("/"))
|
||||
values = {
|
||||
"{{article_title}}": escape(page.title or ""),
|
||||
"{{article_url}}": escape(article_url),
|
||||
"{{article_cover_url}}": escape(cover_url),
|
||||
"{{article_intro}}": escape(page.intro or ""),
|
||||
"{{article_date}}": escape((page.date.strftime("%Y-%m-%d") if page.date else "")),
|
||||
}
|
||||
block = template
|
||||
for key, value in values.items():
|
||||
block = block.replace(key, value)
|
||||
blocks.append(block)
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
def extract_token(payload: dict) -> str:
|
||||
if not payload:
|
||||
return ""
|
||||
|
||||
@ -3,7 +3,14 @@ import time
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
||||
from .newsletter import SendEngineClient, render_newsletter_html_for_send_job
|
||||
from .newsletter import (
|
||||
SendEngineClient,
|
||||
build_weekly_news_body_html,
|
||||
compose_newsletter_template_html,
|
||||
compose_newsletter_template_text,
|
||||
render_newsletter_html_for_send_job,
|
||||
render_newsletter_text_for_send_job,
|
||||
)
|
||||
|
||||
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
|
||||
SUCCESS_SEND_JOB_STATUSES = {"completed"}
|
||||
@ -23,13 +30,42 @@ def _is_success_status(value: str) -> bool:
|
||||
|
||||
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
|
||||
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
|
||||
body_html = render_newsletter_html_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||
body_source_html = campaign.html_template
|
||||
if campaign.campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||
config = campaign.content_config or {}
|
||||
article_ids = config.get("article_page_ids") or []
|
||||
if not article_ids:
|
||||
article_ids = list(campaign.weekly_articles.values_list("id", flat=True))
|
||||
body_source_html = build_weekly_news_body_html(
|
||||
article_page_ids=article_ids,
|
||||
item_template_html=campaign.item_template_html,
|
||||
site_base_url=site_base_url,
|
||||
)
|
||||
if campaign.newsletter_template_id and campaign.newsletter_template:
|
||||
body_source_html = compose_newsletter_template_html(
|
||||
layout_html=campaign.newsletter_template.template_html,
|
||||
email_body_html=body_source_html,
|
||||
)
|
||||
body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url)
|
||||
body_text = (campaign.text_template or "").strip()
|
||||
if not body_text:
|
||||
body_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||
if campaign.newsletter_template_id and campaign.newsletter_template:
|
||||
source_text = (campaign.text_template or "").strip()
|
||||
if not source_text:
|
||||
source_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||
body_text = compose_newsletter_template_text(
|
||||
layout_text=campaign.newsletter_template.template_text,
|
||||
email_body_text=source_text,
|
||||
).strip()
|
||||
subject = (campaign.subject_template or "").strip()
|
||||
if not subject and campaign.newsletter_template_id and campaign.newsletter_template:
|
||||
subject = (campaign.newsletter_template.subject or "").strip()
|
||||
|
||||
payload = {
|
||||
"list_id": list_id,
|
||||
"name": campaign.title,
|
||||
"subject": campaign.subject_template,
|
||||
"subject": subject,
|
||||
}
|
||||
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
|
||||
if tenant_id:
|
||||
|
||||
74
innovedus_cms/base/static/css/newsletter_campaign_editor.css
Normal file
74
innovedus_cms/base/static/css/newsletter_campaign_editor.css
Normal file
@ -0,0 +1,74 @@
|
||||
.newsletter-campaign-toolbar {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.newsletter-campaign-preview-shell {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.newsletter-campaign-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.newsletter-campaign-preview-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.newsletter-article-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.newsletter-article-pager__label {
|
||||
margin: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
font-size: 15px;
|
||||
line-height: 36px;
|
||||
height: 36px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.newsletter-article-pager__size {
|
||||
width: 84px;
|
||||
min-width: 84px;
|
||||
max-width: 84px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
line-height: 36px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.newsletter-article-pager__btn {
|
||||
min-height: 36px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.newsletter-article-pager__info {
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.newsletter-article-row {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.6;
|
||||
}
|
||||
49
innovedus_cms/base/static/css/newsletter_template_editor.css
Normal file
49
innovedus_cms/base/static/css/newsletter_template_editor.css
Normal file
@ -0,0 +1,49 @@
|
||||
.newsletter-editor-shell {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.newsletter-editor-toolbar {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.newsletter-editor-status {
|
||||
margin: 8px 0 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.newsletter-grapesjs-editor {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.newsletter-preview-shell {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.newsletter-preview-shell__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.newsletter-preview-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
textarea[data-newsletter-html-input="1"] {
|
||||
display: none;
|
||||
}
|
||||
369
innovedus_cms/base/static/js/newsletter_campaign_editor.js
Normal file
369
innovedus_cms/base/static/js/newsletter_campaign_editor.js
Normal file
@ -0,0 +1,369 @@
|
||||
(function () {
|
||||
function resolveFieldContainer(input) {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
return input.closest('[data-field]') || input.closest('.w-field') || input.parentNode;
|
||||
}
|
||||
|
||||
function toggleFieldVisibility(campaignType) {
|
||||
var htmlInput = document.getElementById('id_html_template');
|
||||
var textInput = document.getElementById('id_text_template');
|
||||
var weeklyArticles = document.getElementById('id_weekly_articles');
|
||||
var weeklyArticleInput = document.querySelector('input[name="weekly_articles"]');
|
||||
var weeklyItemTemplate = document.getElementById('id_item_template_html');
|
||||
|
||||
var htmlField = resolveFieldContainer(htmlInput);
|
||||
var textField = resolveFieldContainer(textInput);
|
||||
var weeklyArticlesField = resolveFieldContainer(weeklyArticles || weeklyArticleInput);
|
||||
var weeklyItemTemplateField = resolveFieldContainer(weeklyItemTemplate);
|
||||
|
||||
var isWeekly = (campaignType || 'general') === 'weekly_news';
|
||||
var generalOnlyBlocks = document.querySelectorAll('.newsletter-general-only');
|
||||
var weeklyOnlyBlocks = document.querySelectorAll('.newsletter-weekly-only');
|
||||
for (var gi = 0; gi < generalOnlyBlocks.length; gi++) {
|
||||
generalOnlyBlocks[gi].style.display = isWeekly ? 'none' : '';
|
||||
}
|
||||
for (var wi = 0; wi < weeklyOnlyBlocks.length; wi++) {
|
||||
weeklyOnlyBlocks[wi].style.display = isWeekly ? '' : 'none';
|
||||
}
|
||||
|
||||
// Fallback for markup without classname bindings.
|
||||
if (htmlField) htmlField.style.display = isWeekly ? 'none' : '';
|
||||
if (textField) textField.style.display = isWeekly ? 'none' : '';
|
||||
if (weeklyArticlesField) weeklyArticlesField.style.display = isWeekly ? '' : 'none';
|
||||
if (weeklyItemTemplateField) weeklyItemTemplateField.style.display = isWeekly ? '' : 'none';
|
||||
}
|
||||
|
||||
function renderPreviewHtml(composedHtml) {
|
||||
return (composedHtml || '')
|
||||
.replace(/\{\{token\}\}/g, 'sample-token-123')
|
||||
.replace(/\{\{email\}\}/g, 'demo@example.com')
|
||||
.replace(/\{\{list_id\}\}/g, 'sample-list')
|
||||
.replace(/\{\{tenant_id\}\}/g, 'sample-tenant')
|
||||
.replace(/\{\{confirm_url\}\}/g, 'https://example.com/newsletter/confirm?token=sample-token-123')
|
||||
.replace(/\{\{unsubscribe_url\}\}/g, 'https://example.com/newsletter/unsubscribe?token=sample-token-123');
|
||||
}
|
||||
|
||||
function createPreviewShell() {
|
||||
var shell = document.createElement('div');
|
||||
shell.className = 'newsletter-campaign-preview-shell';
|
||||
shell.setAttribute('data-newsletter-preview-shell', '1');
|
||||
shell.hidden = true;
|
||||
shell.innerHTML =
|
||||
'<div class="newsletter-campaign-preview-head">' +
|
||||
'<strong>Preview</strong>' +
|
||||
'<button type="button" class="button button-small button-secondary" data-campaign-preview-close>Close</button>' +
|
||||
'</div>' +
|
||||
'<iframe class="newsletter-campaign-preview-frame" data-campaign-preview-frame title="Campaign preview"></iframe>';
|
||||
return shell;
|
||||
}
|
||||
|
||||
function buildToolbar() {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'newsletter-campaign-toolbar';
|
||||
row.setAttribute('data-newsletter-preview-toolbar', '1');
|
||||
row.innerHTML =
|
||||
'<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>' +
|
||||
'<span class="help" data-campaign-status hidden></span>';
|
||||
return row;
|
||||
}
|
||||
|
||||
function openPreviewInFrame(frame, shell, html) {
|
||||
if (!frame || !shell) {
|
||||
return false;
|
||||
}
|
||||
shell.hidden = false;
|
||||
var doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
doc.open();
|
||||
doc.write(
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>Campaign Preview</title></head><body>' +
|
||||
renderPreviewHtml(html) +
|
||||
'</body></html>'
|
||||
);
|
||||
doc.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
return input ? input.value : '';
|
||||
}
|
||||
|
||||
function getSelectedWeeklyArticleIds() {
|
||||
var form = document.querySelector('form[data-edit-form]') || document.getElementById('w-editor-form');
|
||||
if (!form) return '';
|
||||
var fd = new FormData(form);
|
||||
var ids = fd.getAll('weekly_articles') || [];
|
||||
var clean = [];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var v = String(ids[i] || '').trim();
|
||||
if (v) clean.push(v);
|
||||
}
|
||||
return clean.join(',');
|
||||
}
|
||||
|
||||
async function composePreviewHtml(templateId, emailBodyHtml, campaignType, weeklyArticleIds, weeklyItemTemplateHtml) {
|
||||
var body = new URLSearchParams();
|
||||
body.set('template_id', templateId || '');
|
||||
body.set('email_body_html', emailBodyHtml || '');
|
||||
body.set('campaign_type', campaignType || 'general');
|
||||
body.set('weekly_article_ids', weeklyArticleIds || '');
|
||||
body.set('weekly_item_template_html', weeklyItemTemplateHtml || '');
|
||||
|
||||
var resp = await fetch('/newsletter/campaigns/preview-compose/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to compose preview (HTTP ' + resp.status + ')');
|
||||
}
|
||||
var data = await resp.json();
|
||||
return data.html || '';
|
||||
}
|
||||
|
||||
function resolveHtmlFieldContainer(htmlInput) {
|
||||
function isVisible(node) {
|
||||
if (!node) return false;
|
||||
var style = window.getComputedStyle(node);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
var candidates = [
|
||||
document.querySelector('[data-field="newsletter_template"]'),
|
||||
document.querySelector('[data-field="subject_template"]'),
|
||||
document.querySelector('[data-field="list_id"]'),
|
||||
document.querySelector('.fields'),
|
||||
document.querySelector('[data-edit-form]'),
|
||||
document.querySelector('[data-field="html_template"]'),
|
||||
htmlInput ? htmlInput.closest('.w-field') : null,
|
||||
htmlInput ? htmlInput.parentNode : null,
|
||||
];
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
if (isVisible(candidates[i])) return candidates[i];
|
||||
}
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
function resolvePreviewAnchor(htmlInput) {
|
||||
var scheduledInput = document.getElementById('id_scheduled_at');
|
||||
var scheduledPanelSection =
|
||||
document.getElementById('panel-scheduled_at-section') ||
|
||||
(scheduledInput ? scheduledInput.closest('section.w-panel') : null);
|
||||
if (scheduledPanelSection && scheduledPanelSection.parentNode) {
|
||||
return { container: scheduledPanelSection.parentNode, beforeNode: scheduledPanelSection };
|
||||
}
|
||||
var scheduledField =
|
||||
document.querySelector('[data-field="scheduled_at"]') ||
|
||||
(scheduledInput &&
|
||||
(scheduledInput.closest('li[data-contentpath]') ||
|
||||
scheduledInput.closest('.w-field') ||
|
||||
scheduledInput.closest('li') ||
|
||||
scheduledInput.closest('section') ||
|
||||
resolveFieldContainer(scheduledInput)));
|
||||
if (scheduledField && scheduledField.parentNode) {
|
||||
return { container: scheduledField.parentNode, beforeNode: scheduledField };
|
||||
}
|
||||
var fallback = resolveHtmlFieldContainer(htmlInput);
|
||||
if (fallback && fallback.parentNode) {
|
||||
return { container: fallback.parentNode, beforeNode: fallback };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setupWeeklyArticlePager() {
|
||||
var weeklyField =
|
||||
document.querySelector('[data-field="weekly_articles"]') ||
|
||||
resolveFieldContainer(document.querySelector('input[name="weekly_articles"]'));
|
||||
if (!weeklyField) {
|
||||
return;
|
||||
}
|
||||
var inputs = weeklyField.querySelectorAll('input[name="weekly_articles"]');
|
||||
if (!inputs || !inputs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = [];
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
var row = inputs[i].closest('li') || inputs[i].closest('label') || inputs[i].parentNode;
|
||||
if (row) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
rows[r].classList.add('newsletter-article-row');
|
||||
}
|
||||
|
||||
var shell = document.createElement('div');
|
||||
shell.className = 'newsletter-article-pager';
|
||||
shell.innerHTML =
|
||||
'<label class="newsletter-article-pager__label">每頁</label>' +
|
||||
'<select class="newsletter-article-pager__size" data-article-page-size>' +
|
||||
'<option value="10">10</option>' +
|
||||
'<option value="30">30</option>' +
|
||||
'<option value="50">50</option>' +
|
||||
'</select>' +
|
||||
'<button type="button" class="button button-small button-secondary newsletter-article-pager__btn" data-article-prev>上一頁</button>' +
|
||||
'<span class="newsletter-article-pager__info" data-article-page-info></span>' +
|
||||
'<button type="button" class="button button-small button-secondary newsletter-article-pager__btn" data-article-next>下一頁</button>';
|
||||
weeklyField.insertBefore(shell, weeklyField.firstChild);
|
||||
|
||||
var pageSizeSelect = shell.querySelector('[data-article-page-size]');
|
||||
var prevBtn = shell.querySelector('[data-article-prev]');
|
||||
var nextBtn = shell.querySelector('[data-article-next]');
|
||||
var info = shell.querySelector('[data-article-page-info]');
|
||||
var page = 1;
|
||||
var pageSize = 10;
|
||||
|
||||
function renderPage() {
|
||||
var total = rows.length;
|
||||
var totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (page > totalPages) page = totalPages;
|
||||
var start = (page - 1) * pageSize;
|
||||
var end = start + pageSize;
|
||||
for (var idx = 0; idx < rows.length; idx++) {
|
||||
rows[idx].style.display = idx >= start && idx < end ? '' : 'none';
|
||||
}
|
||||
info.textContent = '第 ' + page + ' / ' + totalPages + ' 頁,共 ' + total + ' 則';
|
||||
prevBtn.disabled = page <= 1;
|
||||
nextBtn.disabled = page >= totalPages;
|
||||
}
|
||||
|
||||
pageSizeSelect.addEventListener('change', function () {
|
||||
pageSize = parseInt(pageSizeSelect.value, 10) || 10;
|
||||
page = 1;
|
||||
renderPage();
|
||||
});
|
||||
prevBtn.addEventListener('click', function () {
|
||||
if (page > 1) {
|
||||
page -= 1;
|
||||
renderPage();
|
||||
}
|
||||
});
|
||||
nextBtn.addEventListener('click', function () {
|
||||
page += 1;
|
||||
renderPage();
|
||||
});
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function boot() {
|
||||
var templateSelect = document.getElementById('id_newsletter_template');
|
||||
var htmlInput = document.getElementById('id_html_template');
|
||||
var campaignTypeInput = document.getElementById('id_campaign_type');
|
||||
var weeklyItemTemplateInput = document.getElementById('id_item_template_html');
|
||||
var campaignType =
|
||||
campaignTypeInput && (campaignTypeInput.value || '').trim()
|
||||
? (campaignTypeInput.value || '').trim()
|
||||
: document.querySelector('.newsletter-weekly-only')
|
||||
? 'weekly_news'
|
||||
: 'general';
|
||||
var isWeekly = campaignType === 'weekly_news';
|
||||
|
||||
if (!templateSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (campaignTypeInput) {
|
||||
toggleFieldVisibility(campaignType);
|
||||
campaignTypeInput.addEventListener('change', function () {
|
||||
toggleFieldVisibility(campaignTypeInput.value || 'general');
|
||||
});
|
||||
}
|
||||
setupWeeklyArticlePager();
|
||||
|
||||
if (!htmlInput && !isWeekly) {
|
||||
return;
|
||||
}
|
||||
|
||||
var previewAnchor = resolvePreviewAnchor(htmlInput);
|
||||
if (!previewAnchor) {
|
||||
return;
|
||||
}
|
||||
var htmlFieldContainer = previewAnchor.container;
|
||||
var insertBeforeNode = previewAnchor.beforeNode;
|
||||
var existingToolbars = document.querySelectorAll('[data-newsletter-preview-toolbar]');
|
||||
var toolbar = existingToolbars.length ? existingToolbars[0] : buildToolbar();
|
||||
for (var t = 1; t < existingToolbars.length; t++) {
|
||||
existingToolbars[t].remove();
|
||||
}
|
||||
var previewBtn = toolbar.querySelector('[data-preview-newsletter-campaign]');
|
||||
var statusNode = toolbar.querySelector('[data-campaign-status]');
|
||||
|
||||
var existingShells = document.querySelectorAll('[data-newsletter-preview-shell]');
|
||||
var previewShell = existingShells.length ? existingShells[0] : createPreviewShell();
|
||||
for (var s = 1; s < existingShells.length; s++) {
|
||||
existingShells[s].remove();
|
||||
}
|
||||
var previewFrame = previewShell.querySelector('[data-campaign-preview-frame]');
|
||||
var previewCloseBtn = previewShell.querySelector('[data-campaign-preview-close]');
|
||||
|
||||
if (toolbar.parentNode !== htmlFieldContainer || toolbar.nextSibling !== insertBeforeNode) {
|
||||
htmlFieldContainer.insertBefore(toolbar, insertBeforeNode);
|
||||
}
|
||||
if (previewShell.parentNode !== htmlFieldContainer) {
|
||||
htmlFieldContainer.insertBefore(previewShell, toolbar.nextSibling);
|
||||
}
|
||||
|
||||
if (previewCloseBtn && !previewCloseBtn.dataset.bound) {
|
||||
previewCloseBtn.dataset.bound = '1';
|
||||
previewCloseBtn.addEventListener('click', function () {
|
||||
previewShell.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (!previewBtn.dataset.bound) {
|
||||
previewBtn.dataset.bound = '1';
|
||||
previewBtn.addEventListener('click', async function () {
|
||||
var bodyHtml = htmlInput ? htmlInput.value || '' : '';
|
||||
var templateId = (templateSelect.value || '').trim();
|
||||
var campaignType =
|
||||
campaignTypeInput && (campaignTypeInput.value || '').trim()
|
||||
? (campaignTypeInput.value || '').trim()
|
||||
: document.querySelector('.newsletter-weekly-only')
|
||||
? 'weekly_news'
|
||||
: 'general';
|
||||
var weeklyArticleIds = getSelectedWeeklyArticleIds();
|
||||
var weeklyItemTemplateHtml = weeklyItemTemplateInput ? weeklyItemTemplateInput.value || '' : '';
|
||||
var html = '';
|
||||
try {
|
||||
html = (await composePreviewHtml(templateId, bodyHtml, campaignType, weeklyArticleIds, weeklyItemTemplateHtml)).trim();
|
||||
} catch (err) {
|
||||
statusNode.hidden = false;
|
||||
statusNode.textContent = err.message;
|
||||
return;
|
||||
}
|
||||
if (!html) {
|
||||
statusNode.hidden = false;
|
||||
statusNode.textContent = '目前沒有可預覽的內容';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openPreviewInFrame(previewFrame, previewShell, html)) {
|
||||
statusNode.hidden = false;
|
||||
statusNode.textContent = '無法顯示預覽';
|
||||
return;
|
||||
}
|
||||
statusNode.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
372
innovedus_cms/base/static/js/newsletter_template_editor.js
Normal file
372
innovedus_cms/base/static/js/newsletter_template_editor.js
Normal file
@ -0,0 +1,372 @@
|
||||
(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/index.js');
|
||||
} catch (e) {
|
||||
// Plugin is optional; core editor still works.
|
||||
}
|
||||
|
||||
return !!window.grapesjs;
|
||||
}
|
||||
|
||||
async function ensureWagtailImageChooser() {
|
||||
if (window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!window.ModalWorkflow) {
|
||||
await loadScript('/static/wagtailadmin/js/modal-workflow.js');
|
||||
}
|
||||
if (!window.CHOOSER_MODAL_ONLOAD_HANDLERS) {
|
||||
await loadScript('/static/wagtailadmin/js/chooser-modal.js');
|
||||
}
|
||||
await loadScript('/static/wagtailimages/js/image-chooser-modal.js');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return !!(window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS);
|
||||
}
|
||||
|
||||
function getImageDataSrc(imageData, mediaBaseUrl) {
|
||||
if (!imageData) {
|
||||
return '';
|
||||
}
|
||||
var sourceUrl =
|
||||
(imageData.url || '') ||
|
||||
(imageData.image && imageData.image.url) ||
|
||||
(imageData.preview && imageData.preview.url) ||
|
||||
'';
|
||||
if (!sourceUrl) {
|
||||
return '';
|
||||
}
|
||||
if (!mediaBaseUrl) {
|
||||
return sourceUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
var parsed = new URL(sourceUrl, window.location.origin);
|
||||
var path = parsed.pathname || '';
|
||||
var idx = path.indexOf('/images/');
|
||||
if (idx < 0) {
|
||||
return sourceUrl;
|
||||
}
|
||||
var key = path.slice(idx + 1);
|
||||
var base = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
|
||||
return new URL(key, base).toString();
|
||||
} catch (e) {
|
||||
if (sourceUrl.indexOf('/images/') >= 0) {
|
||||
var relative = sourceUrl.split('/images/')[1];
|
||||
if (relative) {
|
||||
var baseUrl = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
|
||||
return baseUrl + 'images/' + relative.split('?')[0];
|
||||
}
|
||||
}
|
||||
return sourceUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function openWagtailImageChooser(mediaBaseUrl) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
function onChoose(imageData) {
|
||||
var src = getImageDataSrc(imageData, mediaBaseUrl);
|
||||
if (!src) {
|
||||
reject(new Error('No image URL returned by chooser'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
src: src,
|
||||
title: imageData.title || imageData.name || 'Wagtail image',
|
||||
alt: imageData.alt || imageData.default_alt_text || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (window.ModalWorkflow) {
|
||||
new window.ModalWorkflow({
|
||||
url: '/admin/images/chooser/',
|
||||
onload: window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS || {},
|
||||
responses: {
|
||||
chosen: function (data) {
|
||||
// Wagtail image chooser commonly returns { step: "chosen", result: {...} }.
|
||||
onChoose(data && data.result ? data.result : data);
|
||||
},
|
||||
imageChosen: function (data) {
|
||||
onChoose(data && data.result ? data.result : data);
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('No compatible Wagtail chooser API found'));
|
||||
});
|
||||
}
|
||||
|
||||
function parseJSON(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getHtmlWithCss(editor) {
|
||||
var html = '';
|
||||
var css = '';
|
||||
|
||||
try {
|
||||
html = editor.getHtml() || '';
|
||||
css = editor.getCss() || '';
|
||||
} catch (e) {
|
||||
html = '';
|
||||
css = '';
|
||||
}
|
||||
|
||||
if (!html.trim()) {
|
||||
try {
|
||||
var wrapper = editor && editor.getWrapper ? editor.getWrapper() : null;
|
||||
if (wrapper && typeof wrapper.toHTML === 'function') {
|
||||
html = wrapper.toHTML() || '';
|
||||
}
|
||||
} catch (e) {
|
||||
html = html || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!css.trim()) {
|
||||
return html;
|
||||
}
|
||||
return '<style>' + css + '</style>\n' + html;
|
||||
}
|
||||
|
||||
function normalizeBodyPlaceholder(html) {
|
||||
var source = html || '';
|
||||
source = source.replace(/\{\{html_body\}\}/g, '{{email_body}}');
|
||||
if (source.indexOf('{{email_body}}') === -1) {
|
||||
return source;
|
||||
}
|
||||
// Wrap plain placeholder text so it's selectable/movable in GrapesJS.
|
||||
source = source.replace(
|
||||
/\{\{email_body\}\}/g,
|
||||
'<div data-newsletter-body-slot="1">{{email_body}}</div>'
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
function renderPreviewHtml(html) {
|
||||
var sampleBody =
|
||||
'<h2 style="margin:0 0 12px;">本期電子報標題</h2>' +
|
||||
'<p style="margin:0 0 10px;">這是預覽用的假內容,實際寄送時會替換成真實內容。</p>' +
|
||||
'<p style="margin:0 0 10px;"><a href="https://example.com" target="_blank">瞭解更多</a></p>';
|
||||
return (html || '')
|
||||
.replace(/\{\{email_body\}\}/g, sampleBody)
|
||||
.replace(/\{\{html_body\}\}/g, sampleBody)
|
||||
.replace(/\{\{token\}\}/g, 'sample-token-123')
|
||||
.replace(/\{\{email\}\}/g, 'demo@example.com')
|
||||
.replace(/\{\{list_id\}\}/g, 'sample-list')
|
||||
.replace(/\{\{tenant_id\}\}/g, 'sample-tenant')
|
||||
.replace(/\{\{confirm_url\}\}/g, 'https://example.com/newsletter/confirm?token=sample-token-123')
|
||||
.replace(/\{\{unsubscribe_url\}\}/g, 'https://example.com/newsletter/unsubscribe?token=sample-token-123');
|
||||
}
|
||||
|
||||
function openPreviewInFrame(html, frame, shell) {
|
||||
if (!frame || !shell) {
|
||||
return false;
|
||||
}
|
||||
shell.hidden = false;
|
||||
var doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
doc.open();
|
||||
doc.write(
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body>' +
|
||||
renderPreviewHtml(html) +
|
||||
'</body></html>'
|
||||
);
|
||||
doc.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPreviewSource(editor, htmlInput) {
|
||||
var source = '';
|
||||
var origin = '';
|
||||
|
||||
source = getHtmlWithCss(editor) || '';
|
||||
if (source.trim()) {
|
||||
origin = 'editor-export';
|
||||
return { source: source, origin: origin };
|
||||
}
|
||||
|
||||
source = htmlInput && htmlInput.value ? htmlInput.value : '';
|
||||
if (source.trim()) {
|
||||
origin = 'html-input';
|
||||
return { source: source, origin: origin };
|
||||
}
|
||||
|
||||
source =
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation">' +
|
||||
'<tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>';
|
||||
origin = 'default-template';
|
||||
return { source: source, origin: origin };
|
||||
}
|
||||
|
||||
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 statusNode = document.querySelector('[data-newsletter-editor-status]');
|
||||
var previewButton = document.querySelector('[data-newsletter-preview]');
|
||||
var previewShell = document.querySelector('[data-newsletter-preview-shell]');
|
||||
var previewFrame = document.querySelector('[data-newsletter-preview-frame]');
|
||||
var previewCloseButton = document.querySelector('[data-newsletter-preview-close]');
|
||||
var mediaBaseUrl = (htmlInput.dataset.newsletterMediaUrl || '').trim();
|
||||
|
||||
if (!htmlInput || !jsonInput || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async function () {
|
||||
var ok = await ensureGrapesJS();
|
||||
if (!ok) {
|
||||
if (statusNode) {
|
||||
statusNode.textContent = 'GrapesJS static files not found';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
assetManager: {
|
||||
custom: {
|
||||
open: function (props) {
|
||||
openWagtailImageChooser(mediaBaseUrl)
|
||||
.then(function (assetData) {
|
||||
var selected = editor.getSelected();
|
||||
if (selected && selected.is && selected.is('image')) {
|
||||
selected.addAttributes({
|
||||
src: assetData.src,
|
||||
alt: assetData.alt,
|
||||
});
|
||||
props.close();
|
||||
return;
|
||||
}
|
||||
var asset = editor.AssetManager.add({
|
||||
type: 'image',
|
||||
src: assetData.src,
|
||||
name: assetData.title,
|
||||
});
|
||||
if (props && typeof props.select === 'function') {
|
||||
props.select(asset, true);
|
||||
}
|
||||
if (props && typeof props.close === 'function') {
|
||||
props.close();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (statusNode) {
|
||||
statusNode.hidden = false;
|
||||
statusNode.textContent = 'Image chooser error: ' + err.message;
|
||||
}
|
||||
if (props && typeof props.close === 'function') {
|
||||
props.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
close: function () {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var chooserReady = await ensureWagtailImageChooser();
|
||||
if (!chooserReady && statusNode) {
|
||||
statusNode.hidden = false;
|
||||
statusNode.textContent = 'Wagtail image chooser not available';
|
||||
}
|
||||
|
||||
var savedProject = parseJSON(jsonInput.value || '{}', {});
|
||||
if (savedProject && Object.keys(savedProject).length > 0) {
|
||||
try {
|
||||
editor.loadProjectData(savedProject);
|
||||
} catch (e) {
|
||||
if ((htmlInput.value || '').trim()) {
|
||||
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
|
||||
}
|
||||
}
|
||||
} else if ((htmlInput.value || '').trim()) {
|
||||
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
|
||||
} else {
|
||||
editor.setComponents(
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></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);
|
||||
});
|
||||
}
|
||||
|
||||
if (previewButton) {
|
||||
previewButton.addEventListener('click', function () {
|
||||
var previewPayload = buildPreviewSource(editor, htmlInput);
|
||||
var html = previewPayload.source;
|
||||
if (!openPreviewInFrame(html, previewFrame, previewShell) && statusNode) {
|
||||
statusNode.hidden = false;
|
||||
statusNode.textContent = 'Unable to render in-page preview';
|
||||
}
|
||||
});
|
||||
}
|
||||
if (previewCloseButton && previewShell) {
|
||||
previewCloseButton.addEventListener('click', function () {
|
||||
previewShell.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (statusNode) {
|
||||
statusNode.hidden = true;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootEditor);
|
||||
} else {
|
||||
bootEditor();
|
||||
}
|
||||
})();
|
||||
3
innovedus_cms/base/static/vendor/grapesjs-preset-newsletter/index.js
vendored
Normal file
3
innovedus_cms/base/static/vendor/grapesjs-preset-newsletter/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
innovedus_cms/base/static/vendor/grapesjs/grapes.min.css
vendored
Normal file
1
innovedus_cms/base/static/vendor/grapesjs/grapes.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
innovedus_cms/base/static/vendor/grapesjs/grapes.min.js
vendored
Normal file
3
innovedus_cms/base/static/vendor/grapesjs/grapes.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1,37 @@
|
||||
{% extends "wagtailadmin/base.html" %}
|
||||
{% load i18n wagtailadmin_tags %}
|
||||
|
||||
{% block titletag %}{% trans "Choose Newsletter Type" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "wagtailadmin/shared/header.html" with title=_("Create a newsletter") icon="mail" %}
|
||||
|
||||
<div class="nice-padding">
|
||||
<p>{% trans "Please choose a newsletter type before editing." %}</p>
|
||||
|
||||
<ul class="listing">
|
||||
<li>
|
||||
<div class="row row-flush">
|
||||
<div class="col8">
|
||||
<a href="{% url 'newsletter_campaign_add_by_type' type_general %}">
|
||||
{% icon name="plus-inverse" classname="default w-mr-1 w-align-middle" %}
|
||||
{% trans "General Newsletter" %}
|
||||
<div><span class="w-sr-only">.</span><small>{% trans "Free-form campaign content editor." %}</small></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="row row-flush">
|
||||
<div class="col8">
|
||||
<a href="{% url 'newsletter_campaign_add_by_type' type_weekly_news %}">
|
||||
{% icon name="plus-inverse" classname="default w-mr-1 w-align-middle" %}
|
||||
{% trans "Weekly News Newsletter" %}
|
||||
<div><span class="w-sr-only">.</span><small>{% trans "Build from selected articles with item template." %}</small></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -5,7 +5,9 @@ import json
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@ -18,15 +20,19 @@ from .models import (
|
||||
MailSmtpSettings,
|
||||
NewsletterCampaign,
|
||||
NewsletterSystemSettings,
|
||||
NewsletterTemplate,
|
||||
NewsletterTemplateSettings,
|
||||
OneClickUnsubscribeAudit,
|
||||
SystemNotificationMailSettings,
|
||||
)
|
||||
from .newsletter import (
|
||||
MemberCenterClient,
|
||||
build_weekly_news_body_html,
|
||||
build_from_email,
|
||||
compose_newsletter_template_html,
|
||||
extract_token,
|
||||
render_placeholders,
|
||||
render_newsletter_html_for_send_job,
|
||||
send_contact_notification_email,
|
||||
send_contact_user_email,
|
||||
send_subscribe_email,
|
||||
@ -35,12 +41,34 @@ from .newsletter_scheduler import dispatch_campaign
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
||||
|
||||
|
||||
@require_GET
|
||||
def health_check(request):
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
@require_GET
|
||||
def media_proxy(request, key: str):
|
||||
normalized_key = (key or "").lstrip("/")
|
||||
if not normalized_key:
|
||||
return JsonResponse({"error": "missing media key"}, status=404)
|
||||
# Avoid redirect loops when custom_domain points back to this Django route
|
||||
# (e.g. localhost:8000/media in local dev).
|
||||
bucket = getattr(default_storage, "bucket", None)
|
||||
connection = getattr(default_storage, "connection", None)
|
||||
querystring_expire = int(getattr(default_storage, "querystring_expire", 3600) or 3600)
|
||||
if bucket is not None and connection is not None:
|
||||
signed = connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": bucket.name, "Key": normalized_key},
|
||||
ExpiresIn=querystring_expire,
|
||||
)
|
||||
return redirect(signed)
|
||||
return redirect(default_storage.url(normalized_key))
|
||||
|
||||
|
||||
def _load_settings(request_or_site=None):
|
||||
return (
|
||||
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
||||
@ -464,9 +492,12 @@ def one_click_unsubscribe(request):
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@login_required
|
||||
@require_GET
|
||||
def newsletter_campaign_send_now(request, campaign_id: int):
|
||||
if not request.user.has_perm(SEND_NEWSLETTER_PERMISSION):
|
||||
raise PermissionDenied
|
||||
|
||||
campaign = get_object_or_404(NewsletterCampaign, pk=campaign_id)
|
||||
if campaign.status == NewsletterCampaign.STATUS_SENDING:
|
||||
messages.error(request, "Campaign is currently sending.")
|
||||
@ -486,6 +517,58 @@ def newsletter_campaign_send_now(request, campaign_id: int):
|
||||
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_GET
|
||||
def newsletter_template_payload(request, template_id: int):
|
||||
template = get_object_or_404(NewsletterTemplate, pk=template_id)
|
||||
return JsonResponse(
|
||||
{
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"subject": template.subject or "",
|
||||
"template_html": template.template_html or "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_POST
|
||||
def newsletter_campaign_preview_compose(request):
|
||||
template_id_raw = (request.POST.get("template_id") or "").strip()
|
||||
email_body_html = request.POST.get("email_body_html") or ""
|
||||
campaign_type = (request.POST.get("campaign_type") or NewsletterCampaign.TYPE_GENERAL).strip()
|
||||
weekly_item_template_html = request.POST.get("weekly_item_template_html") or ""
|
||||
weekly_article_ids_raw = request.POST.get("weekly_article_ids") or ""
|
||||
|
||||
template_html = ""
|
||||
if template_id_raw:
|
||||
try:
|
||||
template_id = int(template_id_raw)
|
||||
except ValueError:
|
||||
return JsonResponse({"error": "template_id is invalid"}, status=400)
|
||||
template_obj = get_object_or_404(NewsletterTemplate, pk=template_id)
|
||||
template_html = template_obj.template_html or ""
|
||||
|
||||
body_html = email_body_html
|
||||
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||
article_ids = []
|
||||
for token in weekly_article_ids_raw.split(","):
|
||||
token = token.strip()
|
||||
if token.isdigit():
|
||||
article_ids.append(int(token))
|
||||
body_html = build_weekly_news_body_html(
|
||||
article_page_ids=article_ids,
|
||||
item_template_html=weekly_item_template_html,
|
||||
)
|
||||
|
||||
composed = compose_newsletter_template_html(
|
||||
layout_html=template_html,
|
||||
email_body_html=body_html,
|
||||
)
|
||||
rendered = render_newsletter_html_for_send_job(composed)
|
||||
return JsonResponse({"html": rendered})
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_POST
|
||||
def newsletter_smtp_test(request):
|
||||
|
||||
@ -1,24 +1,136 @@
|
||||
from django import forms
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.urls import path
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import render
|
||||
from wagtail import hooks
|
||||
from wagtail.admin.panels import FieldPanel
|
||||
from wagtail.admin.rich_text import DraftailRichTextArea
|
||||
from wagtail.admin.widgets import Button
|
||||
from wagtail.permission_policies import ModelPermissionPolicy
|
||||
from wagtail.snippets.models import register_snippet
|
||||
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet
|
||||
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
|
||||
|
||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
||||
from .forms import (
|
||||
GrapesJSEditorWidget,
|
||||
NewsletterCampaignAdminForm,
|
||||
NewsletterCampaignEditorWidget,
|
||||
NewsletterTemplateAdminForm,
|
||||
)
|
||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
||||
|
||||
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
||||
|
||||
|
||||
def _newsletter_campaign_add_url() -> str:
|
||||
candidates = [
|
||||
"wagtailsnippets_base_newslettercampaign:add",
|
||||
"base_newslettercampaign:add",
|
||||
]
|
||||
for name in candidates:
|
||||
try:
|
||||
return reverse(name)
|
||||
except Exception:
|
||||
continue
|
||||
return "/admin/snippets/base/newslettercampaign/add/"
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def newsletter_campaign_type_select_view(request):
|
||||
return render(
|
||||
request,
|
||||
"base/wagtail/newsletter_campaign_type_select.html",
|
||||
{
|
||||
"title": _("Choose Newsletter Type"),
|
||||
"add_url": _newsletter_campaign_add_url(),
|
||||
"type_general": NewsletterCampaign.TYPE_GENERAL,
|
||||
"type_weekly_news": NewsletterCampaign.TYPE_WEEKLY_NEWS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def newsletter_campaign_add_by_type_view(request, campaign_type: str):
|
||||
campaign_type = (campaign_type or "").strip()
|
||||
allowed = {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}
|
||||
if campaign_type not in allowed:
|
||||
return redirect("newsletter_campaign_type_select")
|
||||
return redirect(f"{_newsletter_campaign_add_url()}?campaign_type={campaign_type}")
|
||||
|
||||
|
||||
@hooks.register("register_admin_urls")
|
||||
def register_newsletter_admin_urls():
|
||||
return [
|
||||
path(
|
||||
"newsletter/campaigns/type-select/",
|
||||
newsletter_campaign_type_select_view,
|
||||
name="newsletter_campaign_type_select",
|
||||
),
|
||||
path(
|
||||
"newsletter/campaigns/add/<slug:campaign_type>/",
|
||||
newsletter_campaign_add_by_type_view,
|
||||
name="newsletter_campaign_add_by_type",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class NewsletterCampaignCreateView(CreateView):
|
||||
def _resolve_campaign_type(self):
|
||||
candidate = (
|
||||
self.request.POST.get("campaign_type")
|
||||
or self.request.GET.get("campaign_type")
|
||||
or ""
|
||||
).strip()
|
||||
if candidate in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
campaign_type = self._resolve_campaign_type()
|
||||
if campaign_type in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
|
||||
return super().get(request, *args, **kwargs)
|
||||
return redirect("newsletter_campaign_type_select")
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
campaign_type = self._resolve_campaign_type()
|
||||
if campaign_type not in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
|
||||
# Do not redirect on POST; inject a safe default so form validation can run.
|
||||
post_data = request.POST.copy()
|
||||
is_weekly_hint = bool(post_data.getlist("weekly_articles")) or bool(
|
||||
(post_data.get("item_template_html") or "").strip()
|
||||
)
|
||||
post_data["campaign_type"] = (
|
||||
NewsletterCampaign.TYPE_WEEKLY_NEWS if is_weekly_hint else NewsletterCampaign.TYPE_GENERAL
|
||||
)
|
||||
request.POST = post_data
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
campaign_type = self._resolve_campaign_type()
|
||||
if campaign_type in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
|
||||
initial["campaign_type"] = campaign_type
|
||||
if (initial.get("list_id") or "").strip():
|
||||
return initial
|
||||
return self._with_default_template(initial)
|
||||
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
|
||||
if default_list_id:
|
||||
initial["list_id"] = default_list_id
|
||||
return self._with_default_template(initial)
|
||||
|
||||
def _with_default_template(self, initial: dict):
|
||||
if initial.get("newsletter_template"):
|
||||
return initial
|
||||
campaign_type = initial.get("campaign_type") or NewsletterCampaign.TYPE_GENERAL
|
||||
qs = NewsletterTemplate.objects.all()
|
||||
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||
candidate = qs.filter(name__icontains="weekly").order_by("-updated_at", "-created_at").first()
|
||||
if not candidate:
|
||||
candidate = qs.order_by("-updated_at", "-created_at").first()
|
||||
else:
|
||||
candidate = qs.order_by("-updated_at", "-created_at").first()
|
||||
if candidate:
|
||||
initial["newsletter_template"] = candidate.pk
|
||||
return initial
|
||||
|
||||
|
||||
@ -27,13 +139,24 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
||||
icon = "mail"
|
||||
menu_label = _("Newsletter campaigns")
|
||||
menu_order = 250
|
||||
add_to_admin_menu = True
|
||||
add_to_admin_menu = False
|
||||
add_view_class = NewsletterCampaignCreateView
|
||||
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
||||
form_class = NewsletterCampaignAdminForm
|
||||
base_form_class = NewsletterCampaignAdminForm
|
||||
list_display = ["title", "campaign_type", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
||||
list_filter = ["status"]
|
||||
search_fields = ["title", "list_id", "subject_template"]
|
||||
|
||||
def get_form_class(self, for_update=False):
|
||||
return NewsletterCampaignAdminForm
|
||||
|
||||
panels = [
|
||||
FieldPanel("title"),
|
||||
FieldPanel("campaign_type"),
|
||||
FieldPanel(
|
||||
"newsletter_template",
|
||||
help_text=_("Choose a template; default is picked from campaign type."),
|
||||
),
|
||||
FieldPanel(
|
||||
"list_id",
|
||||
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
|
||||
@ -41,12 +164,19 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
||||
FieldPanel("subject_template"),
|
||||
FieldPanel(
|
||||
"html_template",
|
||||
widget=DraftailRichTextArea(
|
||||
widget=NewsletterCampaignEditorWidget(
|
||||
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
||||
),
|
||||
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
|
||||
classname="newsletter-general-only",
|
||||
),
|
||||
FieldPanel("text_template"),
|
||||
FieldPanel("text_template", classname="newsletter-general-only"),
|
||||
FieldPanel(
|
||||
"weekly_articles",
|
||||
widget=forms.CheckboxSelectMultiple(),
|
||||
classname="newsletter-weekly-only",
|
||||
),
|
||||
FieldPanel("item_template_html", classname="newsletter-weekly-only"),
|
||||
FieldPanel("scheduled_at"),
|
||||
]
|
||||
|
||||
@ -63,7 +193,7 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
||||
icon = "tasks"
|
||||
menu_label = _("Newsletter dispatch records")
|
||||
menu_order = 251
|
||||
add_to_admin_menu = True
|
||||
add_to_admin_menu = False
|
||||
inspect_view_enabled = True
|
||||
copy_view_enabled = False
|
||||
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
|
||||
@ -81,15 +211,59 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
||||
search_fields = ["email", "subscriber_id", "campaign__title"]
|
||||
|
||||
|
||||
register_snippet(NewsletterCampaignViewSet)
|
||||
register_snippet(NewsletterDispatchRecordViewSet)
|
||||
class NewsletterTemplateViewSet(SnippetViewSet):
|
||||
model = NewsletterTemplate
|
||||
icon = "doc-full-inverse"
|
||||
menu_label = _("Newsletter templates")
|
||||
menu_order = 252
|
||||
add_to_admin_menu = False
|
||||
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",
|
||||
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
||||
),
|
||||
FieldPanel(
|
||||
"template_html",
|
||||
widget=GrapesJSEditorWidget(
|
||||
attrs={
|
||||
"rows": 18,
|
||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||
}
|
||||
),
|
||||
),
|
||||
FieldPanel(
|
||||
"template_text",
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 8,
|
||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||
}
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class NewsletterAdminGroup(SnippetViewSetGroup):
|
||||
menu_label = _("Newsletter campaigns")
|
||||
menu_icon = "mail"
|
||||
menu_order = 250
|
||||
items = (NewsletterCampaignViewSet, NewsletterDispatchRecordViewSet, NewsletterTemplateViewSet)
|
||||
|
||||
|
||||
register_snippet(NewsletterAdminGroup)
|
||||
|
||||
|
||||
@hooks.register("register_snippet_listing_buttons")
|
||||
def newsletter_campaign_listing_buttons(snippet, user, next_url=None):
|
||||
if not isinstance(snippet, NewsletterCampaign):
|
||||
return
|
||||
if not user.is_staff:
|
||||
if not user.has_perm(SEND_NEWSLETTER_PERMISSION):
|
||||
return
|
||||
if snippet.status == NewsletterCampaign.STATUS_SENDING:
|
||||
return
|
||||
|
||||
30
innovedus_cms/home/feeds.py
Normal file
30
innovedus_cms/home/feeds.py
Normal file
@ -0,0 +1,30 @@
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.utils.feedgenerator import Rss201rev2Feed
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
from .models import ArticlePage
|
||||
|
||||
|
||||
class LatestArticlesFeed(Feed):
|
||||
feed_type = Rss201rev2Feed
|
||||
title = "DeBuT AI 最新文章"
|
||||
link = "/"
|
||||
description = "DeBuT AI 最新文章 RSS feed"
|
||||
|
||||
def items(self):
|
||||
return ArticlePage.objects.live().order_by("-date", "-id")[:20]
|
||||
|
||||
def item_title(self, item):
|
||||
return item.title
|
||||
|
||||
def item_description(self, item):
|
||||
return strip_tags(item.intro or item.search_description or "")
|
||||
|
||||
def item_link(self, item):
|
||||
return item.url
|
||||
|
||||
def item_pubdate(self, item):
|
||||
return item.date
|
||||
|
||||
def item_categories(self, item):
|
||||
return list(item.tags.values_list("name", flat=True))
|
||||
@ -8,6 +8,7 @@ from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||
from modelcluster.fields import ParentalKey
|
||||
from taggit.models import TaggedItemBase
|
||||
from wagtail.search import index
|
||||
from .pagination import build_pagination_context, get_page_size
|
||||
|
||||
def _get_env_int(name, default):
|
||||
value = os.environ.get(name)
|
||||
@ -62,7 +63,7 @@ class CategoryMixin:
|
||||
ArticlePage.objects.child_of(self)
|
||||
.live()
|
||||
.order_by("-date", "-id"),
|
||||
PAGE_SIZE,
|
||||
get_page_size(request, PAGE_SIZE),
|
||||
)
|
||||
page_number = request.GET.get("page") if request else None
|
||||
|
||||
@ -78,7 +79,7 @@ class CategoryMixin:
|
||||
"title": self.title,
|
||||
"items": page_obj,
|
||||
"url": self.url,
|
||||
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||
"pagination": build_pagination_context(request, page_obj, paginator),
|
||||
}
|
||||
)
|
||||
return blocks
|
||||
@ -98,7 +99,8 @@ class CategoryMixin:
|
||||
else:
|
||||
# Paginated view
|
||||
paginator = Paginator(
|
||||
ArticlePage.objects.live().order_by("-date", "-id"), PAGE_SIZE
|
||||
ArticlePage.objects.live().order_by("-date", "-id"),
|
||||
get_page_size(request, PAGE_SIZE),
|
||||
)
|
||||
page_number = request.GET.get("page")
|
||||
|
||||
@ -112,7 +114,7 @@ class CategoryMixin:
|
||||
"title": self.title,
|
||||
"items": page_obj,
|
||||
"url": self.url,
|
||||
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||
"pagination": build_pagination_context(request, page_obj, paginator),
|
||||
}
|
||||
|
||||
def get_trending_articles(self, request=None, exclude_ids=None):
|
||||
@ -134,7 +136,7 @@ class CategoryMixin:
|
||||
}
|
||||
else:
|
||||
# Paginated view
|
||||
paginator = Paginator(articles_qs, PAGE_SIZE)
|
||||
paginator = Paginator(articles_qs, get_page_size(request, PAGE_SIZE))
|
||||
page_number = request.GET.get("page")
|
||||
|
||||
try:
|
||||
@ -147,7 +149,7 @@ class CategoryMixin:
|
||||
"title": self.title,
|
||||
"items": page_obj,
|
||||
"url": self.url,
|
||||
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||
"pagination": build_pagination_context(request, page_obj, paginator),
|
||||
}
|
||||
|
||||
|
||||
@ -310,6 +312,15 @@ class ArticlePage(Page, BreadcrumbMixin):
|
||||
def get_context(self, request):
|
||||
context = super().get_context(request)
|
||||
|
||||
if self.cover_image:
|
||||
cover = self.cover_image.get_rendition("original")
|
||||
context["og_image"] = {
|
||||
"url": request.build_absolute_uri(cover.url),
|
||||
"width": cover.width,
|
||||
"height": cover.height,
|
||||
"alt": self.title,
|
||||
}
|
||||
|
||||
breadcrumbs, site_root = self.build_breadcrumbs()
|
||||
# context["breadcrumbs"] = breadcrumbs
|
||||
# context["breadcrumb_root"] = site_root
|
||||
|
||||
56
innovedus_cms/home/pagination.py
Normal file
56
innovedus_cms/home/pagination.py
Normal file
@ -0,0 +1,56 @@
|
||||
PAGE_SIZE_OPTIONS = (10, 20, 30)
|
||||
|
||||
|
||||
def get_page_size(request, default):
|
||||
try:
|
||||
page_size = int(request.GET.get("page_size", default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return page_size if page_size in PAGE_SIZE_OPTIONS else default
|
||||
|
||||
|
||||
def build_query_string(request, **updates):
|
||||
query = request.GET.copy()
|
||||
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
query.pop(key, None)
|
||||
else:
|
||||
query[key] = value
|
||||
|
||||
return query.urlencode()
|
||||
|
||||
|
||||
def build_pagination_context(request, page_obj, paginator):
|
||||
page_range = paginator.get_elided_page_range(page_obj.number)
|
||||
page_size = paginator.per_page
|
||||
|
||||
return {
|
||||
"page_size": page_size,
|
||||
"page_size_options": [
|
||||
{
|
||||
"value": option,
|
||||
"url": f"?{build_query_string(request, page_size=option, page=None)}",
|
||||
"is_current": option == page_size,
|
||||
}
|
||||
for option in PAGE_SIZE_OPTIONS
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"number": page_num,
|
||||
"url": f"?{build_query_string(request, page=page_num)}"
|
||||
if page_num != "…"
|
||||
else "",
|
||||
"is_current": page_num == page_obj.number,
|
||||
"is_ellipsis": page_num == "…",
|
||||
}
|
||||
for page_num in page_range
|
||||
],
|
||||
"previous_url": f"?{build_query_string(request, page=page_obj.previous_page_number())}"
|
||||
if page_obj.has_previous()
|
||||
else "",
|
||||
"next_url": f"?{build_query_string(request, page=page_obj.next_page_number())}"
|
||||
if page_obj.has_next()
|
||||
else "",
|
||||
}
|
||||
@ -136,6 +136,35 @@
|
||||
color: #0e1b4266;
|
||||
}
|
||||
|
||||
.page-size-selector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 40px 0 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-size-label {
|
||||
color: #0e1b4266;
|
||||
}
|
||||
|
||||
.page-size-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
height: 32px;
|
||||
border: 1px solid #0e1b42;
|
||||
border-radius: 4px;
|
||||
color: #0e1b42;
|
||||
}
|
||||
|
||||
.page-size-option.is-current {
|
||||
background: #0e1b42;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@ -3,6 +3,14 @@
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.template-darkbackground .category-title {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.template-darkbackground .category-title span {
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
.subcategory-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -63,6 +63,13 @@
|
||||
<path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://social-plugins.line.me/lineit/share?url={{ share_url }}" target="_blank" rel="noopener noreferrer" aria-label="分享到 LINE">
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path class="icon-cutout" d="M17.69 6.71L16.06 6.67L15.12 6.74L14.01 6.9L13.17 7.1L12.17 7.4L11.03 7.87L10.19 8.31L9.45 8.78L8.41 9.59L7.78 10.21L7.23 10.85L6.63 11.73L6.22 12.49L5.86 13.43L5.62 14.44L5.56 15.02L5.54 15.99L5.59 16.56L5.86 17.82L6.38 19.08L6.72 19.68L7.31 20.51L7.74 21.01L8.67 21.89L9.69 22.63L10.61 23.17L11.49 23.57L12.38 23.91L13.41 24.2L15.02 24.5L15.55 24.74L15.76 25L15.84 25.39L15.83 25.89L15.6 27.38L15.67 27.56L15.86 27.69L16.31 27.64L17.09 27.28L19.21 26L20.95 24.82L22.15 23.93L23.3 23.01L23.44 22.83L23.67 22.68L24.25 22.15L25.7 20.61L26.21 19.91L26.72 19.1L26.97 18.58L27.28 17.77L27.48 17.04L27.59 16.14L27.61 15.29L27.49 14.29L27.28 13.45L26.99 12.65L26.62 11.91L26.1 11.1L25.47 10.32L24.82 9.67L23.91 8.93L23.15 8.42L21.53 7.61L20.85 7.36L19.7 7.03L18.55 6.8Z" fill="var(--cutout, #fff)" />
|
||||
<path d="M9.28 13.25L9.17 13.37L9.19 18.37L9.3 18.45L12.54 18.44L12.64 18.34L12.64 17.42L12.56 17.32L10.37 17.3L10.32 17.25L10.3 13.35L10.21 13.25Z M13.46 13.25L13.37 13.38L13.37 18.34L13.5 18.45L14.37 18.45L14.47 18.4L14.52 18.31L14.52 13.4L14.39 13.25Z M15.41 13.27L15.33 13.37L15.33 18.32L15.46 18.45L16.33 18.45L16.48 18.32L16.48 15.44L16.53 15.41L18.76 18.4L18.84 18.45L19.78 18.42L19.85 18.34L19.83 13.33L19.73 13.25L18.83 13.25L18.71 13.35L18.71 16.22L18.66 16.3L16.38 13.27Z M20.72 13.28L20.67 13.35L20.69 18.37L20.8 18.45L24.06 18.42L24.14 18.32L24.14 17.43L24.09 17.34L23.93 17.29L21.87 17.3L21.81 17.22L21.81 16.48L21.86 16.43L24.01 16.43L24.14 16.28L24.12 15.36L24.03 15.28L21.86 15.28L21.81 15.2L21.81 14.45L21.86 14.4L24.01 14.4L24.14 14.26L24.11 13.32L24.03 13.25Z" fill="var(--fill-0, #0E1B42)" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% with tags=page.tags.all %}
|
||||
|
||||
@ -6,10 +6,23 @@
|
||||
<div class="page-article-list">
|
||||
{% include "home/includes/article_list.html" with items=category.items show_hero=show_hero empty_message=empty_message %}
|
||||
|
||||
{% if category.pagination.page_size_options %}
|
||||
<div class="page-size-selector" aria-label="每頁文章數">
|
||||
<span class="page-size-label">每頁</span>
|
||||
{% for option in category.pagination.page_size_options %}
|
||||
{% if option.is_current %}
|
||||
<span class="page-size-option is-current">{{ option.value }}</span>
|
||||
{% else %}
|
||||
<a class="page-size-option" href="{{ option.url }}">{{ option.value }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if category.items.paginator.num_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if category.items.has_previous %}
|
||||
<a class="prev-page" href="?page={{ category.items.previous_page_number }}">
|
||||
<a class="prev-page" href="{{ category.pagination.previous_url }}">
|
||||
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
|
||||
<svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
||||
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
|
||||
@ -28,18 +41,18 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="pagination-pages">
|
||||
{% for page_num in category.page_range %}
|
||||
{% if page_num == category.items.number %}
|
||||
<span class="pagination-current">{{ page_num }}</span>
|
||||
{% elif page_num == "…" %}
|
||||
<span class="pagination-ellipsis">{{ page_num }}</span>
|
||||
{% for page_item in category.pagination.pages %}
|
||||
{% if page_item.is_current %}
|
||||
<span class="pagination-current">{{ page_item.number }}</span>
|
||||
{% elif page_item.is_ellipsis %}
|
||||
<span class="pagination-ellipsis">{{ page_item.number }}</span>
|
||||
{% else %}
|
||||
<a href="?page={{ page_num }}">{{ page_num }}</a>
|
||||
<a href="{{ page_item.url }}">{{ page_item.number }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if category.items.has_next %}
|
||||
<a class="next-page" href="?page={{ category.items.next_page_number }}">
|
||||
<a class="next-page" href="{{ category.pagination.next_url }}">
|
||||
<span>NEXT</span>
|
||||
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
|
||||
<svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
||||
|
||||
@ -5,6 +5,7 @@ from taggit.models import Tag
|
||||
from wagtail.models import Site
|
||||
|
||||
from .models import ArticlePage, CATEGORY_HOT_SIZE, PAGE_SIZE
|
||||
from .pagination import build_pagination_context, get_page_size
|
||||
|
||||
|
||||
def hashtag_search(request, slug):
|
||||
@ -15,7 +16,7 @@ def hashtag_search(request, slug):
|
||||
.order_by("-date", "-id")
|
||||
)
|
||||
|
||||
paginator = Paginator(articles, PAGE_SIZE)
|
||||
paginator = Paginator(articles, get_page_size(request, PAGE_SIZE))
|
||||
page_number = request.GET.get("page")
|
||||
|
||||
try:
|
||||
@ -35,7 +36,7 @@ def hashtag_search(request, slug):
|
||||
"title": f"#{tag.name}",
|
||||
"items": page_obj,
|
||||
"url": request.path,
|
||||
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||
"pagination": build_pagination_context(request, page_obj, paginator),
|
||||
}
|
||||
],
|
||||
"category_trending": (
|
||||
|
||||
Binary file not shown.
@ -287,6 +287,12 @@ msgstr "電子報發送紀錄"
|
||||
msgid "Newsletter Dispatch Records"
|
||||
msgstr "電子報發送紀錄"
|
||||
|
||||
msgid "Newsletter Template"
|
||||
msgstr "電子報模板"
|
||||
|
||||
msgid "Newsletter Templates"
|
||||
msgstr "電子報模板"
|
||||
|
||||
msgid "Collaboration"
|
||||
msgstr "合作邀約"
|
||||
|
||||
@ -338,6 +344,20 @@ msgstr "電子報"
|
||||
msgid "Newsletter dispatch records"
|
||||
msgstr "電子報發送紀錄"
|
||||
|
||||
msgid "Newsletter templates"
|
||||
msgstr "電子報模板"
|
||||
|
||||
msgid "Add %(model_name)s"
|
||||
msgstr "新增 %(model_name)s"
|
||||
|
||||
msgid ""
|
||||
"There are no %(model_name)s to display. Why not <a href=\"%(add_url)s\">add "
|
||||
"one</a>?"
|
||||
msgstr "目前沒有可顯示的%(model_name)s。<a href=\"%(add_url)s\">立即新增</a>?"
|
||||
|
||||
msgid "There are no %(model_name)s to display."
|
||||
msgstr "目前沒有可顯示的%(model_name)s。"
|
||||
|
||||
msgid "Social Media Settings"
|
||||
msgstr "社群媒體設定"
|
||||
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = os.environ.get("ENV_FILE", "../.env")
|
||||
default_env = Path(__file__).resolve().parent.parent / ".env"
|
||||
env_file = os.environ.get("ENV_FILE", str(default_env))
|
||||
load_dotenv(env_file)
|
||||
|
||||
def main():
|
||||
|
||||
@ -55,6 +55,29 @@ def env_optional(name, default=None):
|
||||
return normalized
|
||||
|
||||
|
||||
def build_media_storage_options():
|
||||
options = {
|
||||
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
|
||||
"secret_key": os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
||||
"bucket_name": os.environ.get("AWS_STORAGE_BUCKET_NAME"),
|
||||
"region_name": os.environ.get("AWS_S3_REGION_NAME", default="us-east-1"),
|
||||
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
|
||||
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
|
||||
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"),
|
||||
"url_protocol": env_optional("AWS_S3_URL_PROTOCOL", default="https:"),
|
||||
}
|
||||
|
||||
endpoint_url = env_optional("AWS_S3_ENDPOINT_URL")
|
||||
if endpoint_url:
|
||||
options["endpoint_url"] = endpoint_url
|
||||
|
||||
addressing_style = env_optional("AWS_S3_ADDRESSING_STYLE")
|
||||
if addressing_style:
|
||||
options["addressing_style"] = addressing_style
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def detect_private_ip():
|
||||
"""
|
||||
Return the primary private IPv4 address for this container when available.
|
||||
@ -100,6 +123,7 @@ INSTALLED_APPS = [
|
||||
"search",
|
||||
"wagtail.contrib.forms",
|
||||
"wagtail.contrib.redirects",
|
||||
"wagtail.contrib.sitemaps",
|
||||
"wagtail.contrib.settings",
|
||||
"wagtail.embeds",
|
||||
"wagtail.sites",
|
||||
@ -119,6 +143,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sitemaps",
|
||||
"base",
|
||||
]
|
||||
|
||||
@ -258,17 +283,7 @@ MEDIA_URL = (
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
|
||||
"OPTIONS": {
|
||||
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
|
||||
"secret_key": os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
||||
"bucket_name": os.environ.get("AWS_STORAGE_BUCKET_NAME"),
|
||||
"region_name": os.environ.get("AWS_S3_REGION_NAME", default="us-east-1"),
|
||||
"endpoint_url": env_optional("AWS_S3_ENDPOINT_URL"),
|
||||
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
|
||||
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
|
||||
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"),
|
||||
"url_protocol": os.environ.get("AWS_S3_URL_PROTOCOL", "https:"),
|
||||
},
|
||||
"OPTIONS": build_media_storage_options(),
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||
|
||||
@ -63,7 +63,7 @@
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
column-count: 2;
|
||||
column-gap: 40px;
|
||||
column-gap: 28px;
|
||||
}
|
||||
|
||||
.footer-menu-list li {
|
||||
|
||||
@ -75,10 +75,15 @@ a {
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
flex: 0 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menu-item:hover,
|
||||
.menu-item:focus-within {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.menu-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -172,10 +177,13 @@ a {
|
||||
.submenu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
left: 0;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 32px);
|
||||
transform: none;
|
||||
margin-top: -2px;
|
||||
margin-left: 20px;
|
||||
margin-left: 10px;
|
||||
list-style: none;
|
||||
padding-inline-start: 0;
|
||||
border-bottom: #0e1b42;
|
||||
@ -184,6 +192,7 @@ a {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
@ -198,11 +207,11 @@ a {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
min-width: 94px;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
border: #0e1b42;
|
||||
border-style: solid;
|
||||
@ -221,7 +230,8 @@ a {
|
||||
display: block;
|
||||
font-variation-settings: normal;
|
||||
font-family: "Inter:Regular", "Noto Sans JP:Regular", sans-serif;
|
||||
word-break: break-word;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-size: 14px;
|
||||
@ -276,7 +286,7 @@ a {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
width: 153px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.template-darkbackground .header-search input[type="search"] {
|
||||
@ -311,6 +321,14 @@ a {
|
||||
color: #0e1b42;
|
||||
}
|
||||
|
||||
.template-darkbackground .site-hero-band .news-title {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.template-darkbackground .site-hero-band .news-title span {
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.site-container {
|
||||
max-width: 640px;
|
||||
@ -321,7 +339,19 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.main-nav {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main-menu {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.main-menu-link {
|
||||
padding: 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 575px) and (max-width: 767px) {
|
||||
@ -424,6 +454,8 @@ a {
|
||||
|
||||
.submenu {
|
||||
position: static;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
margin: 8px 0 0;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
@ -455,6 +487,8 @@ a {
|
||||
.submenu-item a {
|
||||
padding: 6px 0 6px 18px;
|
||||
font-size: 14px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
padding: 0 10px 0 0;
|
||||
border-radius: 36px;
|
||||
background: #ffffff1a;
|
||||
border: 1px solid #ffffff80;
|
||||
border: 1px solid #0e1b0e;
|
||||
transform: translateX(calc(100% - var(--fab-toggle-width)));
|
||||
transition: transform 0.25s ease, background-color 0.2s ease;
|
||||
backdrop-filter: blur(12px);
|
||||
|
||||
@ -16,7 +16,14 @@
|
||||
{% if page.search_description %}
|
||||
<meta name="description" content="{{ page.search_description }}" />
|
||||
{% endif %}
|
||||
{% if og_image %}
|
||||
<meta property="og:image" content="{{ og_image.url }}" />
|
||||
<meta property="og:image:width" content="{{ og_image.width }}" />
|
||||
<meta property="og:image:height" content="{{ og_image.height }}" />
|
||||
<meta property="og:image:alt" content="{{ og_image.alt }}" />
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="alternate" type="application/rss+xml" title="DeBuT AI 最新文章 RSS" href="{% url 'article_feed' %}">
|
||||
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
|
||||
{% if ga4_measurement_id %}
|
||||
|
||||
@ -4,9 +4,11 @@ from django.contrib import admin
|
||||
|
||||
from wagtail.admin import urls as wagtailadmin_urls
|
||||
from wagtail import urls as wagtail_urls
|
||||
from wagtail.contrib.sitemaps.views import sitemap
|
||||
from wagtail.documents import urls as wagtaildocs_urls
|
||||
|
||||
from search import views as search_views
|
||||
from home.feeds import LatestArticlesFeed
|
||||
from home import views as home_views
|
||||
from base import views as base_views
|
||||
from mysite import views as mysite_views
|
||||
@ -15,6 +17,8 @@ urlpatterns = [
|
||||
path("django-admin/", admin.site.urls),
|
||||
path("admin/", include(wagtailadmin_urls)),
|
||||
path("documents/", include(wagtaildocs_urls)),
|
||||
path("feed.xml", LatestArticlesFeed(), name="article_feed"),
|
||||
path("sitemap.xml", sitemap, name="sitemap"),
|
||||
path("media/<path:path>", mysite_views.media_proxy, name="media_proxy"),
|
||||
path("health", base_views.health_check, name="health_check"),
|
||||
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
|
||||
@ -25,10 +29,21 @@ urlpatterns = [
|
||||
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
||||
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
||||
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
||||
path("newsletter/templates/<int:template_id>/payload/", base_views.newsletter_template_payload, name="newsletter_template_payload"),
|
||||
path("newsletter/campaigns/preview-compose/", base_views.newsletter_campaign_preview_compose, name="newsletter_campaign_preview_compose"),
|
||||
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
|
||||
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
|
||||
]
|
||||
|
||||
_media_prefix = (settings.MEDIA_URL or "").strip()
|
||||
if _media_prefix.startswith("/") and _media_prefix.endswith("/"):
|
||||
_media_prefix = _media_prefix.strip("/")
|
||||
if _media_prefix:
|
||||
urlpatterns.insert(
|
||||
3,
|
||||
path(f"{_media_prefix}/<path:key>", base_views.media_proxy, name="media_proxy"),
|
||||
)
|
||||
|
||||
|
||||
if settings.DEBUG:
|
||||
from django.conf.urls.static import static
|
||||
|
||||
@ -8,9 +8,15 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
default_env = Path(__file__).resolve().parent.parent.parent / ".env"
|
||||
load_dotenv(os.environ.get("ENV_FILE", str(default_env)))
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev")
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
||||
@ -7,6 +7,7 @@ from django.db.models import Q
|
||||
from wagtail.models import Site
|
||||
|
||||
from home.models import ArticlePage, PAGE_SIZE
|
||||
from home.pagination import build_pagination_context, get_page_size
|
||||
|
||||
|
||||
def search(request):
|
||||
@ -26,7 +27,7 @@ def search(request):
|
||||
results_count = primary_qs.count()
|
||||
|
||||
if results_count:
|
||||
paginator = Paginator(primary_qs, PAGE_SIZE)
|
||||
paginator = Paginator(primary_qs, get_page_size(request, PAGE_SIZE))
|
||||
results_page = paginator.get_page(page_number)
|
||||
query_string = urlencode({"query": search_query})
|
||||
category_sections = [
|
||||
@ -34,6 +35,7 @@ def search(request):
|
||||
"title": f"搜尋:{search_query}",
|
||||
"items": results_page,
|
||||
"url": f"{request.path}?{query_string}",
|
||||
"pagination": build_pagination_context(request, results_page, paginator),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user