diff --git a/innovedus_cms/home/context_processors.py b/innovedus_cms/home/context_processors.py new file mode 100644 index 0000000..d9561f2 --- /dev/null +++ b/innovedus_cms/home/context_processors.py @@ -0,0 +1,11 @@ +from home.models import LatestPage, TrendingPage + + +def navigation_pages(request): + """ + Provide Latest/Trending page references for site-wide navigation. + """ + return { + "nav_latest_page": LatestPage.objects.live().first(), + "nav_trending_page": TrendingPage.objects.live().first(), + } diff --git a/innovedus_cms/home/migrations/0009_articlepagetag_articlepage_tags.py b/innovedus_cms/home/migrations/0009_articlepagetag_articlepage_tags.py new file mode 100644 index 0000000..fa7e80b --- /dev/null +++ b/innovedus_cms/home/migrations/0009_articlepagetag_articlepage_tags.py @@ -0,0 +1,59 @@ +import django.db.models.deletion +import modelcluster.contrib.taggit +import modelcluster.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0008_alter_articlepage_banner_image_and_more"), + ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"), + ] + + operations = [ + migrations.CreateModel( + name="ArticlePageTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "content_object", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tagged_items", + to="home.articlepage", + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="home_articlepagetag_items", + to="taggit.tag", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="articlepage", + name="tags", + field=modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="home.ArticlePageTag", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ] diff --git a/innovedus_cms/home/migrations/0010_alter_articlepage_recommended.py b/innovedus_cms/home/migrations/0010_alter_articlepage_recommended.py new file mode 100644 index 0000000..9a6c2a6 --- /dev/null +++ b/innovedus_cms/home/migrations/0010_alter_articlepage_recommended.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0009_articlepagetag_articlepage_tags"), + ] + + operations = [ + migrations.AlterField( + model_name="articlepage", + name="recommended", + field=models.BooleanField( + default=False, + help_text="在熱門區塊顯示", + verbose_name="Trending", + ), + ), + ] diff --git a/innovedus_cms/home/migrations/0011_rename_recommendedpage_trendingpage.py b/innovedus_cms/home/migrations/0011_rename_recommendedpage_trendingpage.py new file mode 100644 index 0000000..87b487f --- /dev/null +++ b/innovedus_cms/home/migrations/0011_rename_recommendedpage_trendingpage.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0010_alter_articlepage_recommended"), + ] + + operations = [ + migrations.RenameModel( + old_name="RecommendedPage", + new_name="TrendingPage", + ), + ] diff --git a/innovedus_cms/home/migrations/0012_rename_recommended_articlepage_trending_and_more.py b/innovedus_cms/home/migrations/0012_rename_recommended_articlepage_trending_and_more.py new file mode 100644 index 0000000..2a4b86e --- /dev/null +++ b/innovedus_cms/home/migrations/0012_rename_recommended_articlepage_trending_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.7 on 2025-11-06 04:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0011_rename_recommendedpage_trendingpage'), + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ] + + operations = [ + migrations.RenameField( + model_name='articlepage', + old_name='recommended', + new_name='trending', + ), + migrations.AlterField( + model_name='articlepagetag', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='articlepagetag', + name='tag', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag'), + ), + ] diff --git a/innovedus_cms/home/models.py b/innovedus_cms/home/models.py index 8ce9420..8d1024d 100644 --- a/innovedus_cms/home/models.py +++ b/innovedus_cms/home/models.py @@ -1,28 +1,47 @@ +import os + from django.db import models from wagtail.models import Page from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from modelcluster.contrib.taggit import ClusterTaggableManager +from modelcluster.fields import ParentalKey +from taggit.models import TaggedItemBase -BLOCK_SIZE = 5 -PAGE_SIZE = 10 +def _get_env_int(name, default): + value = os.environ.get(name) + if value is None: + return default + try: + return int(value) + except ValueError: + return default +BLOCK_SIZE = _get_env_int("HOMEPAGE_BLOCK_SIZE", 5) # Default to 5 articles in block layout +HORIZON_SIZE = _get_env_int("HOMEPAGE_HORIZON_SIZE", 8) # Default to 8 articles in horizon layout +PAGE_SIZE = _get_env_int("HOMEPAGE_PAGE_SIZE", 10) # Default to 10 articles per page for pagination +# Mixin for Category-related functionality class CategoryMixin: + # Build category blocks def build_category_blocks(self, request=None): blocks = [] subcategories = self.get_children().type(CategoryPage).live() if subcategories.exists(): + # If there are subcategories, create blocks for each for category in subcategories: blocks.append( { "title": category.title, "items": ArticlePage.objects.child_of(category) .live() - .order_by("-first_published_at")[:BLOCK_SIZE], + .order_by("-first_published_at")[:HORIZON_SIZE], "url": category.url, + "layout": "horizon", } ) else: + # If no subcategories, paginate articles under this category paginator = Paginator( ArticlePage.objects.child_of(self) .live() @@ -47,17 +66,31 @@ 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() if not request: + # No request means no pagination (e.g., homepage) return { - "title": latest_page.title if latest_page else "最新文章", + "title": latest_page.title, "items": ArticlePage.objects.live().order_by("-first_published_at")[ :BLOCK_SIZE ], - "url": latest_page.url if latest_page else "#", + "url": latest_page.url, } else: + # Paginated view paginator = Paginator( ArticlePage.objects.live().order_by("-first_published_at"), PAGE_SIZE ) @@ -75,16 +108,26 @@ class CategoryMixin: "url": self.url, } - def get_recommended_articles(self, request=None): - recommended_page = RecommendedPage.objects.first() + def get_trending_articles(self, request=None, exclude_ids=None): + trending_page = TrendingPage.objects.first() + articles_qs = ArticlePage.objects.filter(trending=True).live().order_by( + "-first_published_at" + ) + + # Exclude specified article IDs + if exclude_ids: + articles_qs = articles_qs.exclude(id__in=exclude_ids) + if not request: + # No request means no pagination (e.g., homepage) return { - "title": recommended_page.title if recommended_page else "推薦文章", - "items": ArticlePage.objects.filter(recommended=True).live()[:BLOCK_SIZE], - "url": recommended_page.url if recommended_page else "#", + "title": trending_page.title, + "items": articles_qs[:HORIZON_SIZE], + "url": trending_page.url, } else: - paginator = Paginator(ArticlePage.objects.filter(recommended=True).live(), PAGE_SIZE) + # Paginated view + paginator = Paginator(articles_qs, PAGE_SIZE) page_number = request.GET.get("page") try: @@ -104,27 +147,43 @@ class HomePage(Page, CategoryMixin): def get_context(self, request): context = super().get_context(request) - category_blocks = [self.get_latest_articles(), self.get_recommended_articles()] + sections = { + "top_section": [], + "category_sections": [], + } - # 找出第一層 CategoryPage(HomePage 直屬子頁) + latest_section = self.get_latest_articles().copy() + latest_section["layout"] = "block" + sections["top_section"].append(latest_section) + + # Exclude latest articles from trending section + latest_items = latest_section.get("items", []) + if hasattr(latest_items, "values_list"): + latest_ids = list(latest_items.values_list("id", flat=True)) + else: + latest_ids = [item.id for item in latest_items] + + trending_section = self.get_trending_articles( + exclude_ids=latest_ids + ).copy() + trending_section["layout"] = "horizon" + sections["top_section"].append(trending_section) + + # Build category sections categories = CategoryPage.objects.child_of(self).live().in_menu() - - # 若第一層沒有,抓 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( + sections["category_sections"].append( { "title": category.title, - "type": "category", - "items": ArticlePage.objects.child_of(category).live()[:BLOCK_SIZE], "url": category.url, + "items": ArticlePage.objects.descendant_of(category) + .live() + .order_by("-first_published_at")[:HORIZON_SIZE], + "layout": "horizon", } ) - context["category_blocks"] = category_blocks + context["sections"] = sections return context @@ -133,16 +192,22 @@ class LatestPage(Page, CategoryMixin): def get_context(self, request): context = super().get_context(request) - context["category_blocks"] = [self.get_latest_articles(request)] + context["category_sections"] = [self.get_latest_articles(request)] + breadcrumbs, site_root = self.build_breadcrumbs() + context["breadcrumbs"] = breadcrumbs + context["breadcrumb_root"] = site_root return context -class RecommendedPage(Page, CategoryMixin): +class TrendingPage(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_sections"] = [self.get_trending_articles(request)] + breadcrumbs, site_root = self.build_breadcrumbs() + context["breadcrumbs"] = breadcrumbs + context["breadcrumb_root"] = site_root return context @@ -153,7 +218,10 @@ class CategoryPage(Page, CategoryMixin): def get_context(self, request): context = super().get_context(request) - context["category_blocks"] = self.build_category_blocks(request) + context["category_sections"] = self.build_category_blocks(request) + breadcrumbs, site_root = self.build_breadcrumbs() + context["breadcrumbs"] = breadcrumbs + context["breadcrumb_root"] = site_root return context @@ -163,6 +231,13 @@ from wagtail.images.blocks import ImageChooserBlock from wagtail.fields import StreamField from .blocks import ValidatingEmbedBlock, H2HeadingBlock, HorizontalRuleBlock +# HashTag for Article +class ArticlePageTag(TaggedItemBase): + content_object = ParentalKey( + "home.ArticlePage", + related_name="tagged_items", + on_delete=models.CASCADE, + ) class ArticlePage(Page): cover_image = models.ForeignKey( @@ -194,14 +269,33 @@ class ArticlePage(Page): ], use_json_field=True, ) - recommended = models.BooleanField(default=False, help_text="在推薦區塊顯示") + trending = models.BooleanField("Trending", default=False, help_text="在熱門區塊顯示") + tags = ClusterTaggableManager(through="home.ArticlePageTag", blank=True) content_panels = Page.content_panels + [ - FieldPanel("recommended"), + FieldPanel("trending"), FieldPanel("cover_image"), FieldPanel("banner_image"), FieldPanel("date"), FieldPanel("intro"), FieldPanel("body"), + FieldPanel("tags"), ] + def get_context(self, request): + context = super().get_context(request) + + tag_ids = list(self.tags.values_list("id", flat=True)) + if tag_ids: + related_articles = ( + ArticlePage.objects.live() + .exclude(id=self.id) + .filter(tags__id__in=tag_ids) + .distinct() + .order_by("-first_published_at")[:4] + ) + else: + related_articles = ArticlePage.objects.none() + + context["related_articles"] = related_articles + return context diff --git a/innovedus_cms/home/templates/home/article_page.html b/innovedus_cms/home/templates/home/article_page.html index 3a1307b..8f4b3ad 100644 --- a/innovedus_cms/home/templates/home/article_page.html +++ b/innovedus_cms/home/templates/home/article_page.html @@ -13,5 +13,23 @@
{{ page.body }}
+ {% with tags=page.tags.all %} + {% if tags %} +
+ Hashtags: + +
+ {% endif %} + {% endwith %} + {% if related_articles %} + + {% endif %} {% endblock %} diff --git a/innovedus_cms/home/templates/home/category_page.html b/innovedus_cms/home/templates/home/category_page.html index 5ae1796..0dba871 100644 --- a/innovedus_cms/home/templates/home/category_page.html +++ b/innovedus_cms/home/templates/home/category_page.html @@ -1,9 +1,31 @@ {% extends "base.html" %} {% load wagtailcore_tags %} {% block content %} + {% if breadcrumbs %} + + {% endif %} {% if page.has_subcategories %} - {% include "home/includes/category_block_list.html" %} + {% for section in category_sections %} + {% include "home/includes/category_session.html" with section=section %} + {% endfor %} {% else %} - {% include "home/includes/category_full_list.html" %} + {% include "home/includes/page-article-list.html" with category=category_sections.0 %} {% endif %} {% endblock %} diff --git a/innovedus_cms/home/templates/home/home_page.html b/innovedus_cms/home/templates/home/home_page.html index a7d00e4..718cf8d 100644 --- a/innovedus_cms/home/templates/home/home_page.html +++ b/innovedus_cms/home/templates/home/home_page.html @@ -1,14 +1,20 @@ {% extends "base.html" %} -{% load static %} - {% block body_class %}template-homepage{% endblock %} - -{% block extra_css %} - -{% endblock extra_css %} - {% block content %} +{% with top_section=sections.top_section %} +

+ 最新文章 +

+ {% for section in top_section %} + {% if section.layout == "block" %} + {% include "home/includes/block_list.html" with items=section.items %} + {% elif section.layout == "horizon" %} + {% include "home/includes/horizontal_list.html" with items=section.items %} + {% endif %} + {% endfor %} +{% endwith %} -{% include "home/includes/category_block_list.html" with category_blocks=category_blocks %} - -{% endblock content %} + {% for section in sections.category_sections %} + {% include "home/includes/category_session.html" with section=section %} + {% endfor %} +{% endblock content %} \ No newline at end of file diff --git a/innovedus_cms/home/templates/home/includes/article_list.html b/innovedus_cms/home/templates/home/includes/article_list.html new file mode 100644 index 0000000..05b5675 --- /dev/null +++ b/innovedus_cms/home/templates/home/includes/article_list.html @@ -0,0 +1,21 @@ +{% load wagtailimages_tags static %} + + diff --git a/innovedus_cms/home/templates/home/includes/block_list.html b/innovedus_cms/home/templates/home/includes/block_list.html new file mode 100644 index 0000000..1bd06ed --- /dev/null +++ b/innovedus_cms/home/templates/home/includes/block_list.html @@ -0,0 +1,19 @@ +{% load wagtailimages_tags static %} + + diff --git a/innovedus_cms/home/templates/home/includes/category_block_list.html b/innovedus_cms/home/templates/home/includes/category_block_list.html deleted file mode 100644 index c5edd83..0000000 --- a/innovedus_cms/home/templates/home/includes/category_block_list.html +++ /dev/null @@ -1,25 +0,0 @@ -{% load wagtailimages_tags static %} - - -
- {% for category in category_blocks %} -
-

{{ category.title }}

- -
- {% endfor %} -
diff --git a/innovedus_cms/home/templates/home/includes/category_full_list.html b/innovedus_cms/home/templates/home/includes/category_full_list.html deleted file mode 100644 index 6538144..0000000 --- a/innovedus_cms/home/templates/home/includes/category_full_list.html +++ /dev/null @@ -1,40 +0,0 @@ -{% load wagtailimages_tags static %} - -
- {% with category=category_blocks.0 %} -

{{ category.title }}

- - - - {% if category.items.paginator.num_pages > 1 %} - - {% endif %} - - {% endwith %} -
- diff --git a/innovedus_cms/home/templates/home/includes/category_session.html b/innovedus_cms/home/templates/home/includes/category_session.html new file mode 100644 index 0000000..e12ec5d --- /dev/null +++ b/innovedus_cms/home/templates/home/includes/category_session.html @@ -0,0 +1,17 @@ +{% load wagtailimages_tags static %} + +
+

+ {% if section.url %} + {{ section.title }} + {% else %} + {{ section.title }} + {% endif %} +

+ {% if section.layout == "block" %} + {% include "home/includes/block_list.html" with items=section.items %} + {% elif section.layout == "horizon" %} + {% include "home/includes/horizontal_list.html" with items=section.items %} + {% endif %} +
+ \ No newline at end of file diff --git a/innovedus_cms/home/templates/home/includes/horizontal_list.html b/innovedus_cms/home/templates/home/includes/horizontal_list.html new file mode 100644 index 0000000..0775831 --- /dev/null +++ b/innovedus_cms/home/templates/home/includes/horizontal_list.html @@ -0,0 +1,19 @@ +{% load wagtailimages_tags static %} + + diff --git a/innovedus_cms/home/templates/home/includes/page-article-list.html b/innovedus_cms/home/templates/home/includes/page-article-list.html new file mode 100644 index 0000000..702f8b5 --- /dev/null +++ b/innovedus_cms/home/templates/home/includes/page-article-list.html @@ -0,0 +1,22 @@ +{% load wagtailimages_tags static %} + +
+ {% with category=category_sections.0 %} +

{{ category.title }}

+ + {% include "home/includes/article_list.html" with items=category.items %} + + {% if category.items.paginator.num_pages > 1 %} + + {% endif %} + + {% endwith %} +
diff --git a/innovedus_cms/home/templates/home/welcome_page.html b/innovedus_cms/home/templates/home/welcome_page.html deleted file mode 100644 index dcacaf3..0000000 --- a/innovedus_cms/home/templates/home/welcome_page.html +++ /dev/null @@ -1,52 +0,0 @@ -{% load i18n wagtailcore_tags %} - -
- - -
-
-
- -
-
-

{% trans "Welcome to your new Wagtail site!" %}

-

{% trans 'Please feel free to join our community on Slack, or get started with one of the links below.' %}

-
-
- diff --git a/innovedus_cms/home/templatetags/__init__.py b/innovedus_cms/home/templatetags/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/innovedus_cms/home/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/innovedus_cms/mysite/settings/base.py b/innovedus_cms/mysite/settings/base.py index 7e0622d..36e09c4 100644 --- a/innovedus_cms/mysite/settings/base.py +++ b/innovedus_cms/mysite/settings/base.py @@ -77,6 +77,7 @@ TEMPLATES = [ "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "wagtail.contrib.settings.context_processors.settings", + "home.context_processors.navigation_pages", ], }, }, diff --git a/innovedus_cms/mysite/templates/includes/header.html b/innovedus_cms/mysite/templates/includes/header.html index 51498b8..c07be12 100644 --- a/innovedus_cms/mysite/templates/includes/header.html +++ b/innovedus_cms/mysite/templates/includes/header.html @@ -16,6 +16,21 @@