Add banner image support to ArticlePage and enhance embed validation

- Introduced `banner_image` field to ArticlePage model.
- Updated article templates to display banner images.
- Added ValidatingEmbedBlock for improved URL validation.
- Refactored category block templates to use static template loading.
- Enhanced header navigation to include submenu support.
- Updated .gitignore to exclude media files.
This commit is contained in:
Warren Chen 2025-10-29 15:59:20 +09:00
parent 3232de90d4
commit ee6eb0db17
12 changed files with 212 additions and 71 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.env
__pycache__
*.pyc
media/

View File

@ -0,0 +1,37 @@
from django.core.exceptions import ValidationError
from wagtail.embeds.blocks import EmbedBlock
from wagtail.embeds import embeds as wagtail_embeds
from wagtail import blocks
class ValidatingEmbedBlock(EmbedBlock):
"""
Embed block that validates the URL at clean-time by resolving
via Wagtail's embed system. Raises ValidationError if not embeddable.
"""
def clean(self, value):
value = super().clean(value)
if value:
try:
# Attempt to resolve and cache embed; will raise on failure
wagtail_embeds.get_embed(value)
except Exception:
raise ValidationError(
"嵌入連結無法驗證,請確認為公開且可嵌入的 URL。"
)
return value
class H2HeadingBlock(blocks.CharBlock):
class Meta:
template = "home/blocks/h2_heading.html"
icon = "title"
label = "Heading"
class HorizontalRuleBlock(blocks.StaticBlock):
class Meta:
template = "home/blocks/horizontal_rule.html"
icon = "horizontalrule"
label = "Separator"

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2025-10-29 06:25
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0006_articlepage_cover_image_articlepage_recommended'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.AddField(
model_name='articlepage',
name='banner_image',
field=models.ForeignKey(blank=True, help_text='文章頁頂部橫幅圖片', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image'),
),
migrations.AlterField(
model_name='articlepage',
name='body',
field=wagtail.fields.StreamField([('heading', 0), ('paragraph', 1), ('image', 2), ('embed', 3), ('hr', 4), ('html', 5)], block_lookup={0: ('home.blocks.H2HeadingBlock', (), {'form_classname': 'full title'}), 1: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link', 'ol', 'ul']}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 3: ('home.blocks.ValidatingEmbedBlock', (), {}), 4: ('home.blocks.HorizontalRuleBlock', (), {}), 5: ('wagtail.blocks.RawHTMLBlock', (), {'help_text': '僅限信任來源的 blockquote/iframe 原始碼'})}),
),
]

View File

@ -6,19 +6,29 @@ from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
BLOCK_SIZE = 5
PAGE_SIZE = 10
class CategoryMixin:
def build_category_blocks(self, request=None):
blocks = []
subcategories = self.get_children().type(CategoryPage).live()
subcategories = self.get_children().type(CategoryPage).live()
if subcategories.exists():
for category in subcategories :
blocks.append({
"title": category.title,
"items": ArticlePage.objects.child_of(category).live().order_by("-first_published_at")[:BLOCK_SIZE],
"url": category.url,
})
for category in subcategories:
blocks.append(
{
"title": category.title,
"items": ArticlePage.objects.child_of(category)
.live()
.order_by("-first_published_at")[:BLOCK_SIZE],
"url": category.url,
}
)
else:
paginator = Paginator(ArticlePage.objects.child_of(self).live().order_by("-first_published_at"), PAGE_SIZE)
paginator = Paginator(
ArticlePage.objects.child_of(self)
.live()
.order_by("-first_published_at"),
PAGE_SIZE,
)
page_number = request.GET.get("page") if request else None
try:
@ -28,23 +38,29 @@ class CategoryMixin:
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
blocks.append({
"title": self.title,
"items": page_obj,
"url": self.url,
})
blocks.append(
{
"title": self.title,
"items": page_obj,
"url": self.url,
}
)
return blocks
def get_latest_articles(self, request=None):
latestPage = LatestPage.objects.first()
latest_page = LatestPage.objects.first()
if not request:
return {
"title": latestPage.title,
"items": ArticlePage.objects.live().order_by("-first_published_at")[:BLOCK_SIZE],
"url": latestPage.url,
"title": latest_page.title if latest_page else "最新文章",
"items": ArticlePage.objects.live().order_by("-first_published_at")[
:BLOCK_SIZE
],
"url": latest_page.url if latest_page else "#",
}
else:
paginator = Paginator(ArticlePage.objects.live().order_by("-first_published_at"), PAGE_SIZE)
paginator = Paginator(
ArticlePage.objects.live().order_by("-first_published_at"), PAGE_SIZE
)
page_number = request.GET.get("page")
try:
@ -60,12 +76,12 @@ class CategoryMixin:
}
def get_recommended_articles(self, request=None):
recommendedPage = RecommendedPage.objects.first()
recommended_page = RecommendedPage.objects.first()
if not request:
return {
"title": recommendedPage.title,
"title": recommended_page.title if recommended_page else "推薦文章",
"items": ArticlePage.objects.filter(recommended=True).live()[:BLOCK_SIZE],
"url": recommendedPage.url,
"url": recommended_page.url if recommended_page else "#",
}
else:
paginator = Paginator(ArticlePage.objects.filter(recommended=True).live(), PAGE_SIZE)
@ -82,54 +98,54 @@ class CategoryMixin:
"items": page_obj,
"url": self.url,
}
return blocks
class HomePage(Page, CategoryMixin):
def get_context(self, request):
context = super().get_context(request)
category_blocks = [
self.get_latest_articles(),
self.get_recommended_articles(),
]
category_blocks = [self.get_latest_articles(), self.get_recommended_articles()]
# 找出第一層 CategoryPageHomePage 直屬子
# 找出第一層 CategoryPageHomePage 直屬子頁)
categories = CategoryPage.objects.child_of(self).live().in_menu()
# 若第一層沒有分類就嘗試所有 descendant CategoryPage
# 若第一層沒有,抓 descendant CategoryPage
if not categories.exists():
categories = CategoryPage.objects.descendant_of(self).live().in_menu()
for category in categories:
subcategories = category.get_children().type(CategoryPage).live()
category_blocks.append({
"title": category.title,
"type": "category",
"items": subcategories or ArticlePage.objects.child_of(category).live()[:BLOCK_SIZE],
"url": category.url,
})
category_blocks.append(
{
"title": category.title,
"type": "category",
"items": ArticlePage.objects.child_of(category).live()[:BLOCK_SIZE],
"url": category.url,
}
)
context["category_blocks"] = category_blocks
return context
class LatestPage(Page, CategoryMixin):
template = "home/category_page.html"
def get_context(self, request):
context = super().get_context(request)
context["category_blocks"] = [
self.get_latest_articles(request)
]
context["category_blocks"] = [self.get_latest_articles(request)]
return context
class RecommendedPage(Page, CategoryMixin):
template = "home/category_page.html"
def get_context(self, request):
context = super().get_context(request)
context["category_blocks"] = [
self.get_recommended_articles(request)
]
context["category_blocks"] = [self.get_recommended_articles(request)]
return context
class CategoryPage(Page, CategoryMixin):
@property
def has_subcategories(self):
@ -140,35 +156,52 @@ class CategoryPage(Page, CategoryMixin):
context["category_blocks"] = self.build_category_blocks(request)
return context
# from wagtail.fields import RichTextField
from wagtail.admin.panels import FieldPanel
from wagtail import blocks
from wagtail.embeds.blocks import EmbedBlock
from wagtail.images.blocks import ImageChooserBlock
from wagtail.fields import StreamField
from .blocks import ValidatingEmbedBlock, H2HeadingBlock, HorizontalRuleBlock
class ArticlePage(Page):
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True, blank=True,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="文章列表與分享用的首圖"
help_text="列表封面圖",
)
banner_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="文章內文橫幅圖片",
)
date = models.DateField("Published date")
intro = models.CharField(max_length=250, blank=True)
body = StreamField([
("heading", blocks.CharBlock(form_classname="full title")),
("paragraph", blocks.RichTextBlock(features=["bold", "italic", "link"])),
("image", ImageChooserBlock()),
("embed", EmbedBlock()),
], use_json_field=True)
recommended = models.BooleanField(default=False, help_text="在推薦清單顯示")
body = StreamField(
[
("heading", H2HeadingBlock(form_classname="full title")),
("paragraph", blocks.RichTextBlock(features=["bold", "italic", "link"])),
("image", ImageChooserBlock()),
("embed", ValidatingEmbedBlock()),
("hr", HorizontalRuleBlock()),
("html", blocks.RawHTMLBlock(help_text="僅限信任來源的 blockquote/iframe 原始碼")),
],
use_json_field=True,
)
recommended = models.BooleanField(default=False, help_text="在推薦區塊顯示")
content_panels = Page.content_panels + [
FieldPanel("recommended"),
FieldPanel("cover_image"),
FieldPanel("banner_image"),
FieldPanel("date"),
FieldPanel("intro"),
FieldPanel("body"),
]

View File

@ -3,9 +3,11 @@
{% block content %}
<article>
{% image page.cover_image original as cover %}
<img src="{{ cover.url }}" alt="{{ page.title }}">
<h1>{{ page.title }}</h1>
{% if page.banner_image %}
{% image page.banner_image original as banner %}
<img src="{{ banner.url }}" alt="{{ page.title }}">
{% endif %}
<p class="date">{{ page.date }}</p>
<div class="intro">{{ page.intro }}</div>
<div class="body">

View File

@ -0,0 +1 @@
<h2 class="article-heading">{{ value }}</h2>

View File

@ -0,0 +1 @@
<hr class="article-hr">

View File

@ -1,4 +1,4 @@
{% load wagtailimages_tags %}
{% load wagtailimages_tags static %}
<div class="category-block-list">
@ -11,9 +11,9 @@
<a href="{{ article.url }}">
{% if article.cover_image %}
{% image article.cover_image max-200x200 as cover %}
<img src="{{ cover.url }}" alt="{{ article.title }}" height="200" width="200" style="width:200px;height:200px;object-fit:contain;display:block;"/>
<img src="{{ cover.url }}" alt="{{ article.title }}" height="200" width="200" style="width:200px;height:200px;object-fit:contain;display:block;"/>
{% else %}
<img src="/static/img/default_cover.jpg" alt="{{ article.title }}" height="200" width="200" style="width:200px;height:200px;object-fit:contain;display:block;"/>
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}" height="200" width="200" style="width:200px;height:200px;object-fit:contain;display:block;"/>
{% endif %}
{{ article.title }}
</a>

View File

@ -1,4 +1,4 @@
{% load wagtailimages_tags %}
{% load wagtailimages_tags static %}
<div class="category-full-list">
{% with category=category_blocks.0 %}
@ -13,7 +13,7 @@
{% image article.cover_image max-200x200 as cover %}
<img src="{{ cover.url }}" alt="{{ article.title }}" height="200" width="200" style="width:200px;height:200px;object-fit:contain;display:block;"/>
{% else %}
<img src="/static/img/default_cover.jpg" alt="{{ article.title }}" height="200" width="200" style="width:200px;height:200px;object-fit:contain;display:block;"/>
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}" height="200" width="200" style="width:200px;height:200px;object-fit:contain;display:block;"/>
{% endif %}
{{ article.title }}
</a>
@ -26,14 +26,15 @@
{% if category.items.paginator.num_pages > 1 %}
<div class="pagination">
{% if category.items.has_previous %}
<a href="?page={{ category.items.previous_page_number }}">上一頁</a>
<a href="?page={{ category.items.previous_page_number }}">上一頁</a>
{% endif %}
<span>第 {{ category.items.number }} / {{ category.items.paginator.num_pages }} 頁</span>
{% if category.items.has_next %}
<a href="?page={{ category.items.next_page_number }}">下一頁</a>
<a href="?page={{ category.items.next_page_number }}">下一頁</a>
{% endif %}
</div>
{% endif %}
{% endwith %}
</div>

View File

@ -124,6 +124,28 @@ USE_I18N = True
USE_TZ = True
# --- Wagtail embeds (Instagram/FB via Graph oEmbed) ---
# Reads a token from env and adds an extra oEmbed finder for IG/FB.
# Keeps the default oEmbed finder first for YouTube/Vimeo/etc.
WAGTAIL_EMBED_FINDERS = [
{"class": "wagtail.embeds.finders.oembed"},
{
"class": "wagtail.embeds.finders.oembed",
"options": {
"providers": [
{"endpoint": "https://graph.facebook.com/v11.0/instagram_oembed", "urls": ["https://www.instagram.com/*"]},
{"endpoint": "https://graph.facebook.com/v11.0/oembed_post", "urls": ["https://www.facebook.com/*"]},
{"endpoint": "https://graph.facebook.com/v11.0/oembed_page", "urls": ["https://www.facebook.com/*"]},
{"endpoint": "https://graph.facebook.com/v11.0/oembed_video", "urls": ["https://www.facebook.com/*"]},
],
"params": {
"access_token": os.environ.get("IG_OEMBED_ACCESS_TOKEN", ""),
"omitscript": True,
},
},
},
]
from django.utils.translation import gettext_lazy as _
LANGUAGES = [

View File

@ -42,6 +42,8 @@
{# Global javascript #}
<script type="text/javascript" src="{% static 'js/mysite.js' %}"></script>
{# Instagram embed script to render IG oEmbeds #}
<script async src="https://www.instagram.com/embed.js"></script>
{% block extra_js %}
{# Override this in templates to add extra javascript #}

View File

@ -14,11 +14,26 @@
<nav class="main-nav">
<ul>
{%with page.get_site.root_page as root %}
{% for page in root.get_descendants.live.in_menu %}
<li><a href="{{ page.url }}">{{ page.title }}</a></li>
{% endfor %}
{% with site_root=page.get_site.root_page %}
{# Top-level menu: direct children of site root #}
{% for menu_page in site_root.get_children.live.in_menu %}
<li>
<a href="{{ menu_page.url }}">{{ menu_page.title }}</a>
{# Second-level: direct children of each top-level page #}
{% with submenu=menu_page.get_children.live.in_menu %}
{% if submenu %}
<ul class="submenu">
{% for subpage in submenu %}
<li><a href="{{ subpage.url }}">{{ subpage.title }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
</li>
{% endfor %}
{% endwith %}
{# Optional extra links from settings #}
{% if settings.base.HeaderSettings.main_links %}
{% for item in settings.base.HeaderSettings.main_links %}
<li><a href="{{ item.value.url }}">{{ item.value.label }}</a></li>