Implement article page layout with responsive design

This commit is contained in:
Warren Chen 2026-02-12 15:59:58 +09:00
parent c5b2f18177
commit 299624832d
7 changed files with 621 additions and 81 deletions

View File

@ -23,6 +23,19 @@ BLOCK_SIZE = _get_env_int("HOMEPAGE_BLOCK_SIZE", 7) # Default to 7 articles in b
HORIZON_SIZE = _get_env_int("HOMEPAGE_HORIZON_SIZE", 4) # Default to 4 articles in horizon layout
PAGE_SIZE = _get_env_int("HOMEPAGE_PAGE_SIZE", 10) # Default to 10 articles per page for pagination
CATEGORY_HOT_SIZE = _get_env_int("CATEGORY_HOT_SIZE", 6) # Default to 4 articles in category hot layout
RELATED_ARTICLES_SIZE = _get_env_int("RELATED_ARTICLES_SIZE", 4) # Default to 4 related articles
class BreadcrumbMixin:
def build_breadcrumbs(self):
site = self.get_site()
site_root = site.root_page if site else None
if site_root:
ancestors = self.get_ancestors().specific().filter(depth__gt=site_root.depth)
else:
ancestors = self.get_ancestors().specific()
breadcrumbs = list(ancestors) + [self]
return breadcrumbs, site_root
# Mixin for Category-related functionality
class CategoryMixin:
@ -70,17 +83,6 @@ class CategoryMixin:
)
return blocks
# Build breadcrumbs
def build_breadcrumbs(self):
site = self.get_site()
site_root = site.root_page if site else None
if site_root:
ancestors = self.get_ancestors().specific().filter(depth__gt=site_root.depth)
else:
ancestors = self.get_ancestors().specific()
breadcrumbs = list(ancestors) + [self]
return breadcrumbs, site_root
# Get latest articles
def get_latest_articles(self, request=None):
latest_page = LatestPage.objects.first()
@ -196,7 +198,7 @@ class HomePage(Page, CategoryMixin):
return context
class LatestPage(Page, CategoryMixin):
class LatestPage(Page, CategoryMixin, BreadcrumbMixin):
template = "home/category_page.html"
def get_context(self, request):
@ -208,7 +210,7 @@ class LatestPage(Page, CategoryMixin):
return context
class TrendingPage(Page, CategoryMixin):
class TrendingPage(Page, CategoryMixin, BreadcrumbMixin):
template = "home/category_page.html"
def get_context(self, request):
@ -220,7 +222,7 @@ class TrendingPage(Page, CategoryMixin):
return context
class CategoryPage(Page, CategoryMixin):
class CategoryPage(Page, CategoryMixin, BreadcrumbMixin):
@property
def has_subcategories(self):
return self.get_children().type(CategoryPage).live().exists()
@ -254,7 +256,7 @@ class ArticlePageTag(TaggedItemBase):
on_delete=models.CASCADE,
)
class ArticlePage(Page):
class ArticlePage(Page, BreadcrumbMixin):
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
@ -308,6 +310,16 @@ class ArticlePage(Page):
def get_context(self, request):
context = super().get_context(request)
breadcrumbs, site_root = self.build_breadcrumbs()
context["breadcrumbs"] = breadcrumbs
context["breadcrumb_root"] = site_root
category_crumbs = [
crumb
for crumb in breadcrumbs
if crumb.id != self.id and (not site_root or crumb.id != site_root.id)
]
context["article_category"] = category_crumbs[-1] if category_crumbs else None
tag_ids = list(self.tags.values_list("id", flat=True))
if tag_ids:
related_articles = (
@ -315,7 +327,7 @@ class ArticlePage(Page):
.exclude(id=self.id)
.filter(tags__id__in=tag_ids)
.distinct()
.order_by("-date", "-id")[:4]
.order_by("-date", "-id")[:RELATED_ARTICLES_SIZE]
)
else:
related_articles = ArticlePage.objects.none()

View File

@ -0,0 +1,396 @@
.article-page {
width: 100%;
max-width: 718px;
margin: 0 auto;
}
.article-breadcrumbs {
margin: 12px 0 16px;
}
.article-breadcrumbs ol {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.article-breadcrumbs li {
font-size: 12px;
font-weight: 700;
line-height: 1.5;
color: #0e1b4266;
}
.article-breadcrumbs li + li::before {
content: "/";
margin: 0 8px;
}
.article-breadcrumbs a:hover {
text-decoration: underline;
}
.block-title {
background-color: #0e1b42;
color: #ffffff;
}
.article-content img {
max-width: 718px;
height: auto;
display: block;
margin: 16px 0;
}
.article-content h1 {
font-size: 36px;
margin: 24px 0;
}
.article-content h2 {
font-size: 32px;
margin: 24px 0;
}
.article-content p {
font-size: 14px;
}
.article-content .intro {
font-size: 24px;
}
.article-content .body iframe {
display: block;
width: 100% !important;
max-width: 100% !important;
border: 0;
}
.article-content .body blockquote.instagram-media,
.article-content .body blockquote.twitter-tweet,
.article-content .body .fb-post,
.article-content .body .fb-video {
max-width: 100% !important;
min-width: 0 !important;
margin-left: auto !important;
margin-right: auto !important;
}
.article-content .date {
display: flex;
align-items: center;
font-size: 24px;
color: #0e1b4266;
margin: 24px 0;
}
.article-content .date::after {
content: "";
flex: 1;
height: 1px;
background: #0e1b4266;
margin-left: 16px;
}
.article-tags {
display: flex;
justify-content: flex-end;
margin: 24px 0;
}
.article-tags .tag-chips {
flex-wrap: nowrap;
white-space: nowrap;
overflow-x: auto;
}
.tag-chips li {
flex: 0 0 auto;
}
.tag-chips a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 96px;
height: 25px;
color: #ffffff;
font-size: 10px;
font-weight: 800;
line-height: 1;
overflow: hidden;
}
.tag-chips li:nth-child(3n + 1) a {
background: #00abf5;
}
.tag-chips li:nth-child(3n + 2) a {
background: #f4a41c;
}
.tag-chips li:nth-child(3n) a {
background: #ff461c;
}
.article-share {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin: 64px 0;
}
.article-share .icon {
width: 32px;
height: 32px;
--fill-0: #0e1b4266;
}
.article-share .icon .icon-cutout {
fill: #ffffff;
}
.related-articles {
margin-top: 32px;
margin-bottom: 32px;
}
.related-articles h2 {
margin: 0 0 24px;
text-align: center;
font-size: 24px;
font-weight: 600;
color: #0e1b4266;
}
.related-articles-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.related-articles-list > hr {
width: 100%;
margin: 12px 0;
margin-inline: 0;
border: 0;
border-top: 1px solid #0e1b4266;
}
.related-article-item {
display: grid;
grid-template-columns: 194px 1fr;
gap: 24px;
align-items: start;
}
.related-article-image {
display: block;
width: 194px;
}
.related-article-image img {
width: 194px;
max-width: 100%;
height: 133px;
display: block;
object-fit: cover;
border-radius: 5px;
}
.related-article-date {
margin: 10px 0 0;
text-align: left;
font-size: 16px;
color: #0e1b4266;
}
.related-article-title {
margin: 0 0 12px;
font-size: 28px;
line-height: 1.3;
}
.tag-chips {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.related-article-tags {
flex-wrap: wrap;
gap: 8px 12px;
}
.back-to-top {
position: fixed;
right: var(--back-to-top-right, 24px);
bottom: 24px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
color: #0e1b42;
cursor: pointer;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.back-to-top.is-visible {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.back-to-top__icon {
position: relative;
display: block;
width: 48px;
height: 48px;
background: #ffffff;
border: 1px solid #d9deea;
box-shadow: 0 3px 8px rgba(14, 27, 66, 0.18);
}
.back-to-top__icon::before,
.back-to-top__icon::after {
content: "";
position: absolute;
top: 24px;
width: 14px;
height: 2px;
background: #0e1b42;
}
.back-to-top__icon::before {
left: 12px;
transform: rotate(-45deg);
}
.back-to-top__icon::after {
right: 12px;
transform: rotate(45deg);
}
.back-to-top__label {
font-size: 24px;
line-height: 1;
}
@media (min-width: 768px) and (max-width: 1023px) {
.article-page {
max-width: 560px;
}
.article-content img {
max-width: 560px;
}
.article-content h2 {
font-size: 24px;
}
}
@media (min-width: 575px) and (max-width: 767px) {
.article-page {
max-width: 426px;
}
.article-content img {
max-width: 426px;
}
.related-article-title {
font-size: 20px;
}
.back-to-top {
bottom: 20px;
}
}
@media (max-width: 574px) {
.article-page {
max-width: 300px;
}
.article-content img {
max-width: 300px;
}
.article-content h1 {
font-size: 24px;
}
.article-content .intro {
font-size: 18px;
}
.article-content .date {
font-size: 16px;
}
.article-tags {
justify-content: center;
}
.related-article-item {
grid-template-columns: 139px 1fr;
gap: 12px;
}
.related-article-image {
width: 139px;
}
.related-article-image img {
width: 139px;
height: 110px;
}
.related-article-date {
font-size: 12px;
}
.related-article-title {
font-size: 16px;
}
.tag-chips a {
width: 70px;
}
.related-article-tags {
flex-wrap: wrap;
gap: 6px 8px;
}
.back-to-top {
bottom: 16px;
}
.back-to-top__label {
font-size: 18px;
}
}
@media (max-width: 767px) {
.article-breadcrumbs {
margin: 8px 0 12px;
}
}

View File

@ -1,17 +1,3 @@
.block-title {
display: inline-block;
width: 197px;
height: 87px;
vertical-align: middle;
font-size: 20px;
font-weight: 700;
}
.block-title span {
padding-left: 21px;
line-height: 87px;
}
.category-title {
background-color: #00abf5;
color: #ffffff;
@ -50,17 +36,6 @@
}
@media (max-width: 574px) {
.block-title {
width: 139px;
height: 55px;
font-size: 16px;
}
.block-title span {
padding-left: 14px;
line-height: 55px;
}
.subcategory-title span {
width: 139px;
height: 55px;

View File

@ -13,20 +13,6 @@
margin: 10px 0;
}
.block-title {
display: inline-block;
width: 197px;
height: 87px;
vertical-align: middle;
font-size: 20px;
font-weight: 700;
}
.block-title span {
padding-left: 21px;
line-height: 87px;
}
.block-title-divider {
display: inline-flex;
width: 28px;
@ -78,17 +64,6 @@
}
@media (max-width: 574px) {
.block-title {
width: 139px;
height: 55px;
font-size: 16px;
}
.block-title span {
padding-left: 14px;
line-height: 55px;
}
.block-title-divider {
width: 20px;
height: 1px;

View File

@ -0,0 +1,67 @@
(function () {
const button = document.querySelector(".back-to-top");
const articlePage = document.querySelector(".article-page");
const articleBody = document.querySelector(".article-content .body");
if (!button) return;
const toggleVisibility = () => {
button.classList.toggle("is-visible", window.scrollY > 200);
};
const syncHorizontalPosition = () => {
if (!articlePage) return;
const rightGap = Math.max(0, window.innerWidth - articlePage.getBoundingClientRect().right);
button.style.setProperty("--back-to-top-right", `${rightGap}px`);
};
const makeEmbedsResponsive = () => {
if (!articleBody) return;
articleBody.querySelectorAll("iframe").forEach((iframe) => {
const src = (iframe.getAttribute("src") || "").toLowerCase();
const isFixedRatioPlayer =
src.includes("youtube.com") ||
src.includes("youtube-nocookie.com") ||
src.includes("m.youtube.com") ||
src.includes("youtu.be") ||
src.includes("vimeo.com") ||
src.includes("player.vimeo.com") ||
src.includes("dailymotion.com");
const width = Number(iframe.getAttribute("width"));
const height = Number(iframe.getAttribute("height"));
iframe.style.width = "100%";
iframe.style.maxWidth = "100%";
if (isFixedRatioPlayer) {
if (width > 0 && height > 0) {
iframe.style.aspectRatio = `${width} / ${height}`;
} else {
iframe.style.aspectRatio = "16 / 9";
}
iframe.style.height = "auto";
} else {
iframe.style.removeProperty("aspect-ratio");
iframe.style.removeProperty("height");
}
});
};
window.addEventListener("scroll", toggleVisibility, { passive: true });
window.addEventListener("resize", syncHorizontalPosition);
toggleVisibility();
syncHorizontalPosition();
makeEmbedsResponsive();
if (articleBody && "MutationObserver" in window) {
const observer = new MutationObserver(() => {
makeEmbedsResponsive();
});
observer.observe(articleBody, { childList: true, subtree: true });
}
button.addEventListener("click", function () {
window.scrollTo({ top: 0, behavior: "smooth" });
});
})();

View File

@ -1,35 +1,125 @@
{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags %}
{% load wagtailcore_tags wagtailimages_tags static %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/article_page.css' %}">
{% endblock %}
{% block content %}
<article>
<h1>{{ page.title }}</h1>
{% if page.banner_image %}
{% image page.banner_image original as banner %}
<img src="{{ banner.url }}" alt="{{ page.title }}">
<article class="article-page">
{% if breadcrumbs %}
<nav class="article-breadcrumbs" aria-label="breadcrumb">
<ol>
<li>
{% if breadcrumb_root %}
<a href="{{ breadcrumb_root.url }}">首頁</a>
{% else %}
<a href="/">首頁</a>
{% endif %}
</li>
{% for crumb in breadcrumbs %}
{% if not breadcrumb_root or crumb.id != breadcrumb_root.id %}
{% if crumb.id != page.id %}
<li>
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
</li>
{% endif %}
{% endif %}
{% endfor %}
</ol>
</nav>
{% endif %}
<p class="date">{{ page.date }}</p>
<div class="intro">{{ page.intro }}</div>
<div class="body">
{{ page.body }}
<div class="article-category">
<div class="block-title category-title"><span>{% if article_category %}{{ article_category.title }}{% endif %}</span></div>
</div>
<div class="article-content">
{% if page.cover_image %}
{% image page.cover_image original as cover %}
<img src="{{ cover.url }}" alt="{{ page.title }}">
{% endif %}
<h1>{{ page.title }}</h1>
<p class="date">{{ page.date|date:"Y/m/d" }}</p>
<div class="intro">{{ page.intro }}</div>
<div class="body">
{{ page.body }}
</div>
</div>
{% with share_url=request.build_absolute_uri|urlencode share_text=page.title|urlencode %}
<div class="article-share" aria-label="分享文章">
<a href="https://www.facebook.com/sharer/sharer.php?u={{ share_url }}" target="_blank" rel="noopener noreferrer" aria-label="分享到 Facebook">
<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 0ZM23.12 8.37H21.18C19.27 8.37 18.67 9.56 18.67 10.78V13.67H22.94L22.26 18.12H18.67V28.89H13.85V18.12H9.94V13.67H13.85V10.28C13.85 6.42 16.15 4.29 19.67 4.29C21.36 4.29 23.12 4.59 23.12 4.59V8.38V8.37Z" fill="var(--fill-0, #0E1B42)"/>
</svg>
</a>
<a href="https://x.com/intent/tweet?url={{ share_url }}&text={{ share_text }}" target="_blank" rel="noopener noreferrer" aria-label="分享到 X">
<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="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" transform="translate(7.59 7.59) scale(1.125)"/>
</svg>
</a>
<a href="https://www.threads.net/intent/post?text={{ share_text }}%20{{ share_url }}" target="_blank" rel="noopener noreferrer" aria-label="分享到 Threads">
<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="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>
</div>
{% endwith %}
{% with tags=page.tags.all %}
{% if tags %}
<div class="tags">
<span>Hashtags:</span>
<ul>
<div class="article-tags">
<ul class="tag-chips">
{% for tag in tags %}
<li><a href="{% url 'hashtag_search' tag.slug %}">#{{ tag }}</a></li>
<li><a href="{% url 'hashtag_search' tag.slug %}">{{ tag }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
{% if related_articles %}
<hr/>
<section class="related-articles">
<h2>相關文章</h2>
{% include "home/includes/article_list.html" with items=related_articles %}
<div class="related-articles-list">
{% for related in related_articles %}
<article class="related-article-item">
<div class="related-article-left">
<a class="related-article-image" href="{{ related.url }}">
{% if related.cover_image %}
{% image related.cover_image max-194x133 as related_cover %}
<img src="{{ related_cover.url }}" alt="{{ related.title }}">
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ related.title }}">
{% endif %}
</a>
<p class="related-article-date">{{ related.date|date:"Y/m/d" }}</p>
</div>
<div class="related-article-right">
<h3 class="related-article-title"><a href="{{ related.url }}">{{ related.title }}</a></h3>
{% with related_tags=related.tags.all %}
{% if related_tags %}
<ul class="related-article-tags tag-chips">
{% for tag in related_tags %}
<li><a href="{% url 'hashtag_search' tag.slug %}">{{ tag }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
</div>
</article>
<hr/>
{% endfor %}
</div>
</section>
{% endif %}
</article>
<button class="back-to-top" type="button" aria-label="回到頁首">
<span class="back-to-top__icon" aria-hidden="true"></span>
<span class="back-to-top__label">TOP</span>
</button>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<script type="text/javascript" src="{% static 'js/article_page.js' %}"></script>
{% endblock %}

View File

@ -369,6 +369,20 @@ footer .footer-sections {
flex-wrap: wrap;
}
.block-title {
display: inline-block;
width: 197px;
height: 87px;
vertical-align: middle;
font-size: 20px;
font-weight: 700;
}
.block-title span {
padding-left: 21px;
line-height: 87px;
}
.template-darkbackground .site-hero-band {
background-color: #0e1b42;
color: #ffffff;
@ -578,6 +592,17 @@ footer .footer-sections {
.main-nav {
right: 16px;
}
.block-title {
width: 139px;
height: 55px;
font-size: 16px;
}
.block-title span {
padding-left: 14px;
line-height: 55px;
}
}
@layer figreset {