Compare commits

...

21 Commits

Author SHA1 Message Date
warrenchen
3de1c033c5 Merge branch 'main' into develop 2026-06-18 15:09:55 +09:00
warrenchen
fe70e1847e Wrap header menu and tighten footer spacing 2026-06-18 14:48:59 +09:00
Warren Chen
8c4ce7b92e Add sitemap, RSS feed, OG image, and pagination size controls 2026-06-04 15:29:06 +09:00
Warren Chen
b16ee811e3 feat: Add support for weekly newsletters with article selection and templates
- Introduced `campaign_type` field in `NewsletterCampaign` model to differentiate between general and weekly newsletters.
- Added `weekly_articles` ManyToMany field to associate articles with weekly newsletters.
- Implemented `item_template_html` for customizing the appearance of articles in weekly newsletters.
- Updated forms and views to handle new fields and validation logic for weekly newsletters.
- Created a new view for selecting newsletter types before creating a campaign.
- Enhanced the newsletter editor UI to show/hide fields based on the selected campaign type.
- Added JavaScript functionality for paginating article selections in the editor.
- Updated CSS for new UI components related to newsletter campaigns.
- Created a migration to add new fields to the `NewsletterCampaign` model.
- Added a template for the newsletter type selection page.
2026-05-14 18:17:35 +09:00
Warren Chen
ed9020839c Add newsletter templates edit and migrate into sending flow. 2026-05-03 00:58:20 +09:00
warrenchen
95601170f6 Add newsletter template editor 2026-05-02 20:14:02 +09:00
warrenchen
4303c1f5db feat(newsletter): Add permission to send newsletter campaigns and update model options 2026-04-28 02:26:29 +09:00
warrenchen
c8bcdb0ee6 Merge branch 'develop' of https://gitea.innovedus.com/warrenchen/innovedus_cms into develop 2026-04-02 03:02:30 +09:00
warrenchen
3e2a290e44 Add health check and harden S3 and static file settings 2026-04-02 02:51:39 +09:00
Warren Chen
019faf4459 feat(settings): Add SECRET_KEY environment variable to base settings 2026-04-02 02:51:39 +09:00
Warren Chen
ec5d55560a feat(migrations): Update newsletter and contact form templates for improved user communication 2026-04-02 02:51:39 +09:00
Warren Chen
5a4aea9a39 feat(pagination): Enhance disabled state styling for pagination links 2026-04-02 02:51:39 +09:00
Warren Chen
36f4d8bb15 feat: Refactor article and news templates for improved layout and styling 2026-04-02 02:51:39 +09:00
Warren Chen
a3f7043aea Add internationalization support and update translations
- Updated Wagtail hooks to use gettext for translatable strings in the NewsletterCampaignViewSet and related help texts.
- Added LOCALE_PATHS to settings for loading translation files.
- Updated base.html to include favicon links.
- Translated various strings in the wagtailsettings edit template to support internationalization.
- Created and populated zh_Hant translation files for Django messages.
- Added a favicon.ico file to the static directory.
2026-04-02 02:51:39 +09:00
Warren Chen
73f8442796 feat: Implement contact form submission feature with SMTP settings
- Added ContactFormSubmission model to store contact form submissions.
- Created ContactForm for handling form submissions.
- Implemented admin interface for managing contact form submissions.
- Developed views and JavaScript for handling contact form submission via AJAX.
- Added SMTP settings model for email configuration.
- Created notification email templates for contact form submissions.
- Updated frontend to include contact form modal and associated styles.
- Added tests for contact form submission and validation.
2026-04-02 02:51:39 +09:00
Warren Chen
f55c766881 Add subscription floating action button with toggle functionality
- Implemented a floating action button (FAB) for newsletter subscription in the template.
- Added JavaScript to handle the toggle state of the FAB and close it on outside clicks or Escape key press.
- Created CSS styles for the FAB, including animations and responsive design.
- Added a Django template tag to return a random default cover image for the FAB.
- Integrated a form for email input and submission within the FAB.
2026-04-02 02:51:39 +09:00
Warren Chen
485818c22a feat(footer): Add footer component with responsive design and social media links 2026-04-02 02:51:39 +09:00
Warren Chen
2719d84c5b feat(newsletter): Implement one-click unsubscribe functionality and update related settings 2026-04-02 02:51:39 +09:00
Warren Chen
eb8307cb3b feat: Implement one-click unsubscribe feature and newsletter campaign management
- Added one-click unsubscribe functionality with token generation and verification.
- Introduced a new model for tracking one-click unsubscribe audits.
- Enhanced newsletter campaign management with the ability to send campaigns immediately.
- Implemented a scheduler for dispatching due newsletter campaigns.
- Updated views and templates to support one-click unsubscribe and campaign previews.
- Added management commands for running the newsletter scheduler.
- Removed obsolete SSL certificate file.
- Updated entrypoint script to handle different application roles.
2026-04-02 02:51:39 +09:00
Warren Chen
9ffbcb0a65 fix(docker): Update Dockerfile to install ca-certificates and create unprivileged user
fix(settings): Ensure SSL_CERT_FILE is set using certifi if not already defined

chore(requirements): Add certifi to requirements for SSL certificate handling
2026-04-02 02:51:39 +09:00
Warren Chen
40dee52d16 feat(newsletter): Implement newsletter subscription and unsubscription features
- Added models for NewsletterSystemSettings and NewsletterTemplateSettings to manage configuration.
- Created forms for subscribing and unsubscribing from the newsletter.
- Developed views for handling subscription, confirmation, and unsubscription processes.
- Integrated Member Center API for managing newsletter subscriptions.
- Implemented email sending functionality with SMTP settings.
- Added templates for displaying subscription status and unsubscription confirmation.
- Enhanced CSS for newsletter forms and status messages.
- Included tests for newsletter functionality and security measures for sensitive data.
2026-04-02 02:51:39 +09:00
37 changed files with 2244 additions and 61 deletions

6
.vscode/launch.json vendored
View File

@ -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"
}
]

View File

@ -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 驗證
此規範符合「新增定型電子報需要改程式」的產品決策,且可保持改動局部化。

View File

@ -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

View File

@ -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"),
),
]

View File

@ -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"),
),
]

View File

@ -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,

View File

@ -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 ""

View File

@ -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:

View 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;
}

View 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;
}

View 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();
}
})();

View 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();
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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 %}

View File

@ -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):

View File

@ -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

View 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))

View File

@ -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

View 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 "",
}

View File

@ -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;

View File

@ -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;

View File

@ -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 %}

View File

@ -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">

View File

@ -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": (

View File

@ -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 "社群媒體設定"

View File

@ -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():

View File

@ -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",

View File

@ -63,7 +63,7 @@
padding: 0;
list-style: none;
column-count: 2;
column-gap: 40px;
column-gap: 28px;
}
.footer-menu-list li {

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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 %}

View File

@ -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

View File

@ -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()

View File

@ -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),
}
]