Compare commits

...

12 Commits

Author SHA1 Message Date
Warren Chen
21475448d2 Ready to deploy via docker 2025-11-28 17:36:54 +09:00
Warren Chen
2c21cca5a7 Clean up and re-generate migrations 2025-11-26 17:29:52 +09:00
Warren Chen
97ddd2dfd1 Refactor search view to improve query handling and pagination logic 2025-11-11 13:49:10 +09:00
Warren Chen
653847df6a Add search functionality to ArticlePage and enhance search templates
- Implement search fields in ArticlePage model for indexing.
- Update hashtag search view to include site root in context.
- Enhance header with a search form for articles.
- Modify search results template to improve user experience and display.
2025-11-10 16:42:15 +09:00
Warren Chen
a98d36da14 Add hashtag search functionality and create hashtag page template 2025-11-10 15:39:43 +09:00
Warren Chen
d75ea17b32 Normalize article dates and update ArticlePage model to use DateTimeField; configure S3 storage settings and update requirements for django-storages 2025-11-10 14:58:22 +09:00
Warren Chen
b04ad110a6 Add tagging and trending functionality to ArticlePage
- Created ArticlePageTag model for tagging articles.
- Added ClusterTaggableManager to ArticlePage for tag management.
- Renamed 'recommended' field to 'trending' in ArticlePage.
- Updated migrations to reflect changes in models.
- Implemented methods to retrieve trending articles and related articles based on tags.
- Modified templates to display tags and related articles on article pages.
- Refactored category and homepage templates to accommodate new sections and layouts.
- Removed deprecated templates and added new includes for better modularity.
- Updated header to include links for latest and trending articles.
- Added environment variable support for configurable block and page sizes.
- Updated requirements to include python-dotenv for environment variable management.
2025-11-06 16:49:31 +09:00
Warren Chen
7c9fe7f6f9 Enhance ValidatingEmbedBlock with strict host validation and update ArticlePage fields in migration 2025-10-29 18:41:16 +09:00
Warren Chen
ee6eb0db17 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.
2025-10-29 15:59:20 +09:00
Warren Chen
3232de90d4 Add article and category page models, templates, and default cover image 2025-10-17 17:47:21 +09:00
Warren Chen
0874255859 Add header 2025-10-16 15:29:55 +09:00
Warren Chen
b97fed5340 Implement footer navigation and social media 2025-10-16 12:02:57 +09:00
44 changed files with 1270 additions and 139 deletions

1
.gitignore vendored
View File

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

View File

@ -1,3 +1,8 @@
fly.toml fly.toml
.git/ .git/
.venv
__pycache__/
*.pyc
*.sqlite3 *.sqlite3
media/
*.log

View File

@ -2,20 +2,29 @@ ARG PYTHON_VERSION=3.13-slim
FROM python:${PYTHON_VERSION} FROM python:${PYTHON_VERSION}
ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONDONTWRITEBYTECODE=1 \
ENV PYTHONUNBUFFERED 1 PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=on \
RUN mkdir -p /code DJANGO_SETTINGS_MODULE=mysite.settings.production
WORKDIR /code WORKDIR /code
# Create an unprivileged user to run the app
RUN adduser --disabled-password --gecos '' app
COPY requirements.txt /tmp/requirements.txt COPY requirements.txt /tmp/requirements.txt
RUN set -ex && \ RUN set -ex && \
pip install --upgrade pip && \ pip install --upgrade pip && \
pip install -r /tmp/requirements.txt && \ pip install -r /tmp/requirements.txt && \
rm -rf /root/.cache/ rm -rf /root/.cache/
COPY . /code COPY . /code
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && chown -R app:app /code
USER app
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn","--bind",":8000","--workers","2","mysite.wsgi"] CMD ["gunicorn","--bind",":8000","--workers","2","mysite.wsgi"]

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BaseConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'base'

View File

@ -0,0 +1,55 @@
# Generated by Django 5.2.7 on 2025-10-15 03:30
import django.db.models.deletion
import uuid
import wagtail.fields
import wagtail.models.preview
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0095_groupsitepermission'),
]
operations = [
migrations.CreateModel(
name='NavigationSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('linkedin_url', models.URLField(blank=True, verbose_name='LinkedIn URL')),
('github_url', models.URLField(blank=True, verbose_name='GitHub URL')),
('mastodon_url', models.URLField(blank=True, verbose_name='Mastodon URL')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='FooterText',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('translation_key', models.UUIDField(default=uuid.uuid4, editable=False)),
('live', models.BooleanField(default=True, editable=False, verbose_name='live')),
('has_unpublished_changes', models.BooleanField(default=False, editable=False, verbose_name='has unpublished changes')),
('first_published_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='first published at')),
('last_published_at', models.DateTimeField(editable=False, null=True, verbose_name='last published at')),
('go_live_at', models.DateTimeField(blank=True, null=True, verbose_name='go live date/time')),
('expire_at', models.DateTimeField(blank=True, null=True, verbose_name='expiry date/time')),
('expired', models.BooleanField(default=False, editable=False, verbose_name='expired')),
('body', wagtail.fields.RichTextField()),
('latest_revision', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.revision', verbose_name='latest revision')),
('live_revision', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.revision', verbose_name='live revision')),
('locale', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.locale', verbose_name='locale')),
],
options={
'verbose_name_plural': 'Footer Text',
'abstract': False,
'unique_together': {('translation_key', 'locale')},
},
bases=(wagtail.models.preview.PreviewableMixin, models.Model),
),
]

View File

@ -0,0 +1,59 @@
# Generated by Django 5.2.7 on 2025-11-26 08:11
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('base', '0001_initial'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='SocialMediaSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('links', wagtail.fields.StreamField([('link', 2)], block_lookup={0: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('facebook', 'Facebook'), ('twitter', 'Twitter'), ('instagram', 'Instagram'), ('thread', 'Thread'), ('linkedin', 'LinkedIn'), ('youtube', 'YouTube')]}), 1: ('wagtail.blocks.URLBlock', (), {}), 2: ('wagtail.blocks.StructBlock', [[('platform', 0), ('url', 1)]], {})})),
],
options={
'abstract': False,
},
),
migrations.AlterModelOptions(
name='navigationsettings',
options={'verbose_name': 'Footer Navigation'},
),
migrations.RemoveField(
model_name='navigationsettings',
name='github_url',
),
migrations.RemoveField(
model_name='navigationsettings',
name='linkedin_url',
),
migrations.RemoveField(
model_name='navigationsettings',
name='mastodon_url',
),
migrations.AddField(
model_name='navigationsettings',
name='footer_links',
field=wagtail.fields.StreamField([('section', 5)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'required': False}), 1: ('wagtail.blocks.CharBlock', (), {}), 2: ('wagtail.blocks.URLBlock', (), {}), 3: ('wagtail.blocks.StructBlock', [[('label', 1), ('url', 2)]], {}), 4: ('wagtail.blocks.ListBlock', (3,), {}), 5: ('wagtail.blocks.StructBlock', [[('title', 0), ('links', 4)]], {})}, null=True),
),
migrations.CreateModel(
name='HeaderSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('site_name', models.CharField(blank=True, max_length=255)),
('extra_links', wagtail.fields.StreamField([('link', 2)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {}), 1: ('wagtail.blocks.URLBlock', (), {}), 2: ('wagtail.blocks.StructBlock', [[('label', 0), ('url', 1)]], {})}, null=True)),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
],
options={
'verbose_name': 'Header Settings',
},
),
]

View File

@ -0,0 +1,131 @@
from django.db import models
from wagtail.admin.panels import (
FieldPanel,
MultiFieldPanel,
# import PublishingPanel:
PublishingPanel,
)
# import RichTextField:
from wagtail.fields import RichTextField
# import DraftStateMixin, PreviewableMixin, RevisionMixin, TranslatableMixin:
from wagtail.models import (
DraftStateMixin,
PreviewableMixin,
RevisionMixin,
TranslatableMixin,
)
from wagtail.contrib.settings.models import (
BaseGenericSetting,
register_setting,
)
from wagtail.snippets.models import register_snippet
from wagtail.fields import StreamField
from wagtail import blocks
@register_setting
class HeaderSettings(BaseGenericSetting):
logo = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
site_name = models.CharField(max_length=255, blank=True)
extra_links = StreamField([
("link", blocks.StructBlock([
("label", blocks.CharBlock()),
("url", blocks.URLBlock())
]))
], use_json_field=True, blank=True, null=True)
panels = [
MultiFieldPanel(
[
FieldPanel("logo"),
FieldPanel("site_name"),
FieldPanel("extra_links"),
],
heading="Header Settings",
),
]
class Meta:
verbose_name = "Header Settings"
@register_setting
class NavigationSettings(BaseGenericSetting):
footer_links = StreamField([
("section", blocks.StructBlock([
("title", blocks.CharBlock(required=False)),
("links", blocks.ListBlock(blocks.StructBlock([
("label", blocks.CharBlock()),
("url", blocks.URLBlock())
]))),
]))
], use_json_field=True, blank=True, null=True)
panels = [
FieldPanel("footer_links"),
]
class Meta:
verbose_name = "Footer Navigation"
class SocialLinkBlock(blocks.StructBlock):
SOCIAL_MEDIA_CHOICES = [
("facebook", "Facebook"),
("twitter", "Twitter"),
("instagram", "Instagram"),
("thread", "Thread"),
("linkedin", "LinkedIn"),
("youtube", "YouTube"),
]
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES)
url = blocks.URLBlock()
class Meta:
icon = "link"
label = "Social Link"
@register_setting
class SocialMediaSettings(BaseGenericSetting):
links = StreamField([
("link", SocialLinkBlock()),
], use_json_field=True)
panels = [FieldPanel("links")]
@register_snippet
class FooterText(
DraftStateMixin,
RevisionMixin,
PreviewableMixin,
TranslatableMixin,
models.Model,
):
body = RichTextField()
panels = [
FieldPanel("body"),
PublishingPanel(),
]
def __str__(self):
return "Footer text"
def get_preview_template(self, request, mode_name):
return "base.html"
def get_preview_context(self, request, mode_name):
return {"footer_text": self.body}
class Meta(TranslatableMixin.Meta):
verbose_name_plural = "Footer Text"

View File

@ -0,0 +1,5 @@
{% load wagtailcore_tags %}
<div>
{{ footer_text|richtext }}
</div>

View File

@ -0,0 +1,18 @@
from django import template
from base.models import FooterText
register = template.Library()
@register.inclusion_tag("base/includes/footer_text.html", takes_context=True)
def get_footer_text(context):
footer_text = context.get("footer_text", "")
if not footer_text:
instance = FooterText.objects.filter(live=True).first()
footer_text = instance.body if instance else ""
return {
"footer_text": footer_text,
}

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
# Run pending migrations and collect static assets before starting the app
python manage.py migrate --noinput
python manage.py collectstatic --noinput
exec "$@"

View File

@ -0,0 +1,58 @@
from django.core.exceptions import ValidationError
from wagtail.embeds.blocks import EmbedBlock
from wagtail.embeds import embeds as wagtail_embeds
from wagtail import blocks
from urllib.parse import urlparse
from django.conf import settings
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:
# Inherit base validation (already run via super().clean), and add
# optional network validation for selected providers.
url_str = value if isinstance(value, str) else getattr(value, "url", None)
if not url_str:
return value
host = (urlparse(url_str).hostname or "").lower()
strict_hosts = getattr(
settings,
"EMBED_STRICT_HOSTS",
("instagram.com", "facebook.com"),
)
validate_all = getattr(settings, "EMBED_STRICT_VALIDATE_ALL", False)
def _matches(h: str) -> bool:
return host == h or host.endswith("." + h)
must_validate = bool(validate_all or any(_matches(h) for h in strict_hosts))
if must_validate:
try:
# Attempt to resolve and cache embed; will raise on failure
wagtail_embeds.get_embed(url_str)
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,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(),
}

View File

@ -0,0 +1,83 @@
# Generated by Django 5.2.7 on 2025-11-26 08:11
import django.db.models.deletion
import home.models
import modelcluster.contrib.taggit
import modelcluster.fields
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0002_create_homepage'),
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
('wagtailcore', '0095_groupsitepermission'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='CategoryPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page', home.models.CategoryMixin),
),
migrations.CreateModel(
name='LatestPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page', home.models.CategoryMixin),
),
migrations.CreateModel(
name='TrendingPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page', home.models.CategoryMixin),
),
migrations.CreateModel(
name='ArticlePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('date', models.DateTimeField(verbose_name='Published date')),
('intro', models.CharField(blank=True, max_length=250)),
('body', 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']}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 3: ('home.blocks.ValidatingEmbedBlock', (), {}), 4: ('home.blocks.HorizontalRuleBlock', (), {}), 5: ('wagtail.blocks.RawHTMLBlock', (), {'help_text': '僅限信任來源的 blockquote/iframe 原始碼'})})),
('trending', models.BooleanField(default=False, help_text='在熱門區塊顯示', verbose_name='Trending')),
('banner_image', models.ForeignKey(blank=True, help_text='文章內文橫幅圖片', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
('cover_image', models.ForeignKey(blank=True, help_text='列表封面圖', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='ArticlePageTag',
fields=[
('id', models.BigAutoField(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='%(app_label)s_%(class)s_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'),
),
]

View File

@ -1,7 +1,330 @@
import os
from django.db import models from django.db import models
from wagtail.models import Page 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
from wagtail.search import index
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("-date")[:HORIZON_SIZE],
"url": category.url,
"layout": "horizon",
}
)
else:
# If no subcategories, paginate articles under this category
paginator = Paginator(
ArticlePage.objects.child_of(self)
.live()
.order_by("-date"),
PAGE_SIZE,
)
page_number = request.GET.get("page") if request else None
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
blocks.append(
{
"title": self.title,
"items": page_obj,
"url": self.url,
}
)
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,
"items": ArticlePage.objects.live().order_by("-date")[
:BLOCK_SIZE
],
"url": latest_page.url,
}
else:
# Paginated view
paginator = Paginator(
ArticlePage.objects.live().order_by("-date"), PAGE_SIZE
)
page_number = request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
return {
"title": self.title,
"items": page_obj,
"url": self.url,
}
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(
"-date"
)
# 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": trending_page.title,
"items": articles_qs[:HORIZON_SIZE],
"url": trending_page.url,
}
else:
# Paginated view
paginator = Paginator(articles_qs, PAGE_SIZE)
page_number = request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
return {
"title": self.title,
"items": page_obj,
"url": self.url,
}
class HomePage(Page): class HomePage(Page, CategoryMixin):
pass def get_context(self, request):
context = super().get_context(request)
sections = {
"top_section": [],
"category_sections": [],
}
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()
for category in categories:
sections["category_sections"].append(
{
"title": category.title,
"url": category.url,
"items": ArticlePage.objects.descendant_of(category)
.live()
.order_by("-date")[:HORIZON_SIZE],
"layout": "horizon",
}
)
context["sections"] = sections
return context
class LatestPage(Page, CategoryMixin):
template = "home/category_page.html"
def get_context(self, request):
context = super().get_context(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 TrendingPage(Page, CategoryMixin):
template = "home/category_page.html"
def get_context(self, request):
context = super().get_context(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
class CategoryPage(Page, CategoryMixin):
@property
def has_subcategories(self):
return self.get_children().type(CategoryPage).live().exists()
def get_context(self, request):
context = super().get_context(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
from wagtail.admin.panels import FieldPanel
from wagtail import blocks
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(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="列表封面圖",
)
banner_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="文章內文橫幅圖片",
)
date = models.DateTimeField("Published date")
intro = models.CharField(max_length=250, blank=True)
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,
)
trending = models.BooleanField("Trending", default=False, help_text="在熱門區塊顯示")
tags = ClusterTaggableManager(through="home.ArticlePageTag", blank=True)
search_fields = Page.search_fields + [
index.SearchField("intro", partial_match=True),
index.SearchField("body_search_text", partial_match=True),
index.SearchField("tag_names_search_text", partial_match=True),
]
content_panels = Page.content_panels + [
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("-date")[:4]
)
else:
related_articles = ArticlePage.objects.none()
context["related_articles"] = related_articles
return context
@property
def body_search_text(self):
if not self.body:
return ""
excluded_types = {"image", "embed", "hr", "html"}
chunks = []
for block in self.body:
if block.block_type in excluded_types:
continue
# Each block decides how to expose searchable text
block_content = block.block.get_searchable_content(block.value)
if block_content:
chunks.extend(block_content)
return " ".join(text for text in chunks if isinstance(text, str))
@property
def tag_names_search_text(self):
return " ".join(self.tags.values_list("name", flat=True))

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags %}
{% 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 }}">
{% endif %}
<p class="date">{{ page.date }}</p>
<div class="intro">{{ page.intro }}</div>
<div class="body">
{{ page.body }}
</div>
{% with tags=page.tags.all %}
{% if tags %}
<div class="tags">
<span>Hashtags:</span>
<ul>
{% for tag in tags %}
<li><a href="{% url 'hashtag_search' tag.slug %}">#{{ tag }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
{% if related_articles %}
<section class="related-articles">
<h2>相關文章</h2>
{% include "home/includes/article_list.html" with items=related_articles %}
</section>
{% endif %}
</article>
{% endblock %}

View File

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

View File

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

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load wagtailcore_tags %}
{% block content %}
{% if breadcrumbs %}
<nav class="breadcrumbs" aria-label="breadcrumb">
<ol>
{% if breadcrumb_root %}
<li><a href="{{ breadcrumb_root.url }}">首頁</a></li>
{% endif %}
{% for crumb in breadcrumbs %}
{% if not breadcrumb_root or crumb.id != breadcrumb_root.id %}
<li>
{% if crumb.id == page.id %}
<span>{{ crumb.title }}</span>
{% else %}
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ol>
</nav>
{% endif %}
{% if page.has_subcategories %}
{% for section in category_sections %}
{% include "home/includes/category_session.html" with section=section %}
{% endfor %}
{% else %}
{% include "home/includes/page-article-list.html" with category=category_sections.0 %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% load wagtailcore_tags %}
{% block content %}
<nav class="breadcrumbs" aria-label="breadcrumb">
<ol>
<li>
{% if site_root %}
<a href="{{ site_root.url }}">首頁</a>
{% else %}
<a href="/">首頁</a>
{% endif %}
</li>
<li><span>標籤</span></li>
<li><span>#{{ tag.name }}</span></li>
</ol>
</nav>
{% include "home/includes/page-article-list.html" %}
{% endblock %}

View File

@ -1,21 +1,20 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block body_class %}template-homepage{% endblock %} {% block body_class %}template-homepage{% endblock %}
{% block extra_css %}
{% comment %}
Delete the line below if you're just getting started and want to remove the welcome screen!
{% endcomment %}
<link rel="stylesheet" href="{% static 'css/welcome_page.css' %}">
{% endblock extra_css %}
{% block content %} {% block content %}
{% with top_section=sections.top_section %}
<h2>
<a href="{{ top_section.0.url }}">最新文章</a>
</h2>
{% 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 %}
{% comment %} {% for section in sections.category_sections %}
Delete the line below if you're just getting started and want to remove the welcome screen! {% include "home/includes/category_session.html" with section=section %}
{% endcomment %} {% endfor %}
{% include 'home/welcome_page.html' %}
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,21 @@
{% load wagtailimages_tags static %}
<ul class="article-list">
{% for article in items %}
<li>
<article>
<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;"/>
{% 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;"/>
{% endif %}
{{ article.title }}
</a>
</article>
</li>
{% empty %}
<li class="empty">目前沒有文章</li>
{% endfor %}
</ul>

View File

@ -0,0 +1,19 @@
{% load wagtailimages_tags static %}
<ul class="block-list">
{% for article in items %}
<li>
<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;"/>
{% 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;"/>
{% endif %}
{{ article.title }}
</a>
</li>
{% empty %}
<li class="empty">目前沒有文章</li>
{% endfor %}
</ul>

View File

@ -0,0 +1,17 @@
{% load wagtailimages_tags static %}
<section class="article-section article-section--{{ section.layout }}">
<h2>
{% if section.url %}
<a href="{{ section.url }}">{{ section.title }}</a>
{% else %}
{{ section.title }}
{% endif %}
</h2>
{% 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 %}
</section>

View File

@ -0,0 +1,19 @@
{% load wagtailimages_tags static %}
<ul class="horizontal-list">
{% for article in items %}
<li>
<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:cover;display:block;"/>
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}" height="200" width="200" style="width:200px;height:200px;object-fit:cover;display:block;"/>
{% endif %}
<span>{{ article.title }}</span>
</a>
</li>
{% empty %}
<li class="empty">目前沒有文章</li>
{% endfor %}
</ul>

View File

@ -0,0 +1,22 @@
{% load wagtailimages_tags static %}
<div class="page-article-list">
{% with category=category_sections.0 %}
<h2><a href="{{ category.url }}">{{ category.title }}</a></h2>
{% include "home/includes/article_list.html" with items=category.items %}
{% if category.items.paginator.num_pages > 1 %}
<div class="pagination">
{% if category.items.has_previous %}
<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>
{% endif %}
</div>
{% endif %}
{% endwith %}
</div>

View File

@ -1,52 +0,0 @@
{% load i18n wagtailcore_tags %}
<header class="header">
<div class="logo">
<a href="https://wagtail.org/">
<svg class="figure-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342.5 126.2"><title>{% trans "Visit the Wagtail website" %}</title><path fill="#FFF" d="M84 1.9v5.7s-10.2-3.8-16.8 3.1c-4.8 5-5.2 10.6-3 18.1 21.6 0 25 12.1 25 12.1L87 27l6.8-8.3c0-9.8-8.1-16.3-9.8-16.8z"/><circle cx="85.9" cy="15.9" r="2.6"/><path d="M89.2 40.9s-3.3-16.6-24.9-12.1c-2.2-7.5-1.8-13 3-18.1C73.8 3.8 84 7.6 84 7.6V1.9C80.4.3 77 0 73.2 0 59.3 0 51.6 10.4 48.3 17.4L9.2 89.3l11-2.1-20.2 39 14.1-2.5L24.9 93c30.6 0 69.8-11 64.3-52.1z"/><path d="M102.4 27l-8.6-8.3L87 27z"/><path fill="#FFF" d="M30 84.1s1-.2 2.8-.6c1.8-.4 4.3-1 7.3-1.8 1.5-.4 3.1-.9 4.8-1.5 1.7-.6 3.5-1.2 5.2-2 1.8-.7 3.6-1.6 5.4-2.6 1.8-1 3.5-2.1 5.1-3.4.4-.3.8-.6 1.2-1l1.2-1c.7-.7 1.5-1.4 2.2-2.2.7-.7 1.3-1.5 1.9-2.3l.9-1.2.4-.6.4-.6c.2-.4.5-.8.7-1.2.2-.4.4-.8.7-1.2l.3-.6.3-.6c.2-.4.4-.8.5-1.2l.9-2.4c.2-.8.5-1.6.7-2.3.2-.7.3-1.5.5-2.1.1-.7.2-1.3.3-2 .1-.6.2-1.2.2-1.7.1-.5.1-1 .2-1.5.1-1.8.1-2.8.1-2.8l1.6.1s-.1 1.1-.2 2.9c-.1.5-.1 1-.2 1.5-.1.6-.1 1.2-.3 1.8-.1.6-.3 1.3-.4 2-.2.7-.4 1.4-.6 2.2-.2.8-.5 1.5-.8 2.4-.3.8-.6 1.6-1 2.5l-.6 1.2-.3.6-.3.6c-.2.4-.5.8-.7 1.3-.3.4-.5.8-.8 1.2-.1.2-.3.4-.4.6l-.4.6-.9 1.2c-.7.8-1.3 1.6-2.1 2.3-.7.8-1.5 1.4-2.3 2.2l-1.2 1c-.4.3-.8.6-1.3.9-1.7 1.2-3.5 2.3-5.3 3.3-1.8.9-3.7 1.8-5.5 2.5-1.8.7-3.6 1.3-5.3 1.8-1.7.5-3.3 1-4.9 1.3-3 .7-5.6 1.3-7.4 1.6-1.6.6-2.6.8-2.6.8z"/><g fill="#231F20"><path d="M127 83.9h-8.8l-12.6-36.4h7.9l9 27.5 9-27.5h7.9l9 27.5 9-27.5h7.9L153 83.9h-8.8L135.6 59 127 83.9zM200.1 83.9h-7V79c-3 3.6-7 5.4-12.1 5.4-3.8 0-6.9-1.1-9.4-3.2s-3.7-5-3.7-8.6c0-3.6 1.3-6.3 4-8 2.6-1.8 6.2-2.7 10.7-2.7h9.9v-1.4c0-4.8-2.7-7.3-8.1-7.3-3.4 0-6.9 1.2-10.5 3.7l-3.4-4.8c4.4-3.5 9.4-5.3 15.1-5.3 4.3 0 7.8 1.1 10.5 3.2 2.7 2.2 4.1 5.6 4.1 10.2v23.7zm-7.7-13.6v-3.1h-8.6c-5.5 0-8.3 1.7-8.3 5.2 0 1.8.7 3.1 2.1 4.1 1.4.9 3.3 1.4 5.7 1.4 2.4 0 4.6-.7 6.4-2.1 1.8-1.3 2.7-3.1 2.7-5.5zM241.7 47.5v31.7c0 6.4-1.7 11.3-5.2 14.5-3.5 3.2-8 4.8-13.4 4.8-5.5 0-10.4-1.7-14.8-5.1l3.6-5.8c3.6 2.7 7.1 4 10.8 4 3.6 0 6.5-.9 8.6-2.8 2.1-1.9 3.2-4.9 3.2-9v-4.7c-1.1 2.1-2.8 3.9-4.9 5.1-2.1 1.3-4.5 1.9-7.1 1.9-4.8 0-8.8-1.7-11.9-5.1-3.1-3.4-4.7-7.6-4.7-12.6s1.6-9.2 4.7-12.6c3.1-3.4 7.1-5.1 11.9-5.1 4.8 0 8.7 2 11.7 6v-5.4h7.5zm-28.4 16.8c0 3 .9 5.6 2.8 7.7 1.8 2.2 4.3 3.2 7.5 3.2 3.1 0 5.7-1 7.6-3.1 1.9-2.1 2.9-4.7 2.9-7.8 0-3.1-1-5.8-2.9-7.9-2-2.2-4.5-3.2-7.6-3.2-3.1 0-5.6 1.1-7.4 3.4-2 2.1-2.9 4.7-2.9 7.7zM260.9 53.6v18.5c0 1.7.5 3.1 1.4 4.1.9 1 2.2 1.5 3.8 1.5 1.6 0 3.2-.8 4.7-2.4l3.1 5.4c-2.7 2.4-5.7 3.6-8.9 3.6-3.3 0-6-1.1-8.3-3.4-2.3-2.3-3.5-5.3-3.5-9.1V53.6h-4.6v-6.2h4.6V36.1h7.7v11.4h9.6v6.2h-9.6zM309.5 83.9h-7V79c-3 3.6-7 5.4-12.1 5.4-3.8 0-6.9-1.1-9.4-3.2s-3.7-5-3.7-8.6c0-3.6 1.3-6.3 4-8 2.6-1.8 6.2-2.7 10.7-2.7h9.9v-1.4c0-4.8-2.7-7.3-8.1-7.3-3.4 0-6.9 1.2-10.5 3.7l-3.4-4.8c4.4-3.5 9.4-5.3 15.1-5.3 4.3 0 7.8 1.1 10.5 3.2 2.7 2.2 4.1 5.6 4.1 10.2v23.7zm-7.7-13.6v-3.1h-8.6c-5.5 0-8.3 1.7-8.3 5.2 0 1.8.7 3.1 2.1 4.1 1.4.9 3.3 1.4 5.7 1.4 2.4 0 4.6-.7 6.4-2.1 1.8-1.3 2.7-3.1 2.7-5.5zM319.3 40.2c-1-1-1.4-2.1-1.4-3.4 0-1.3.5-2.5 1.4-3.4 1-1 2.1-1.4 3.4-1.4 1.3 0 2.5.5 3.4 1.4 1 1 1.4 2.1 1.4 3.4 0 1.3-.5 2.5-1.4 3.4s-2.1 1.4-3.4 1.4c-1.3.1-2.4-.4-3.4-1.4zm7.2 43.7h-7.7V47.5h7.7v36.4zM342.5 83.9h-7.7V33.1h7.7v50.8z"/></g></svg>
</a>
</div>
<div class="header-link">
{% comment %}
This works for all cases but prerelease versions:
{% endcomment %}
<a href="{% wagtail_documentation_path %}/releases/{% wagtail_release_notes_path %}">
{% trans "View the release notes" %}
</a>
</div>
</header>
<main class="main">
<div class="figure">
<svg class="figure-space" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" aria-hidden="true">
<path class="egg" fill="currentColor" d="M150 250c-42.741 0-75-32.693-75-90s42.913-110 75-110c32.088 0 75 52.693 75 110s-32.258 90-75 90z"/>
<ellipse fill="#ddd" cx="150" cy="270" rx="40" ry="7"/>
</svg>
</div>
<div class="main-text">
<h1>{% trans "Welcome to your new Wagtail site!" %}</h1>
<p>{% trans 'Please feel free to <a href="https://github.com/wagtail/wagtail/wiki/Slack">join our community on Slack</a>, or get started with one of the links below.' %}</p>
</div>
</main>
<footer class="footer" role="contentinfo">
<a class="option option-one" href="{% wagtail_documentation_path %}/">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 21c0 .5.4 1 1 1h4c.6 0 1-.5 1-1v-1H9v1zm3-19C8.1 2 5 5.1 5 9c0 2.4 1.2 4.5 3 5.7V17c0 .5.4 1 1 1h6c.6 0 1-.5 1-1v-2.3c1.8-1.3 3-3.4 3-5.7 0-3.9-3.1-7-7-7zm2.9 11.1l-.9.6V16h-4v-2.3l-.9-.6C7.8 12.2 7 10.6 7 9c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.6-.8 3.2-2.1 4.1z"/></svg>
<div>
<h2>{% trans "Wagtail Documentation" %}</h2>
<p>{% trans "Topics, references, & how-tos" %}</p>
</div>
</a>
<a class="option option-two" href="{% wagtail_documentation_path %}/getting_started/tutorial.html">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
<div>
<h2>{% trans "Tutorial" %}</h2>
<p>{% trans "Build your first Wagtail site" %}</p>
</div>
</a>
<a class="option option-three" href="{% url 'wagtailadmin_home' %}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.5 13c-1.2 0-3.07.34-4.5 1-1.43-.67-3.3-1-4.5-1C5.33 13 1 14.08 1 16.25V19h22v-2.75c0-2.17-4.33-3.25-6.5-3.25zm-4 4.5h-10v-1.25c0-.54 2.56-1.75 5-1.75s5 1.21 5 1.75v1.25zm9 0H14v-1.25c0-.46-.2-.86-.52-1.22.88-.3 1.96-.53 3.02-.53 2.44 0 5 1.21 5 1.75v1.25zM7.5 12c1.93 0 3.5-1.57 3.5-3.5S9.43 5 7.5 5 4 6.57 4 8.5 5.57 12 7.5 12zm0-5.5c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm9 5.5c1.93 0 3.5-1.57 3.5-3.5S18.43 5 16.5 5 13 6.57 13 8.5s1.57 3.5 3.5 3.5zm0-5.5c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z"/></svg>
<div>
<h2>{% trans "Admin Interface" %}</h2>
<p>{% trans "Create your superuser first!" %}</p>
</div>
</a>
</footer>

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,44 @@
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.shortcuts import get_object_or_404, render
from taggit.models import Tag
from wagtail.models import Site
from .models import ArticlePage, PAGE_SIZE
def hashtag_search(request, slug):
tag = get_object_or_404(Tag, slug=slug)
articles = (
ArticlePage.objects.live()
.filter(tags__slug=slug)
.order_by("-date")
)
paginator = Paginator(articles, PAGE_SIZE)
page_number = request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
site = Site.find_for_request(request)
site_root = site.root_page if site else None
context = {
"tag": tag,
"category_sections": [
{
"title": f"#{tag.name}",
"items": page_obj,
"url": request.path,
}
],
"site_root": site_root,
"page": site_root.specific if site_root else None,
}
return render(request, "home/hashtag_page.html", context)

View File

@ -3,6 +3,10 @@
import os import os
import sys import sys
from dotenv import load_dotenv
env_file = os.environ.get("ENV_FILE", "../.env")
load_dotenv(env_file)
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""

View File

@ -16,6 +16,15 @@ import os
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_DIR = os.path.dirname(PROJECT_DIR) BASE_DIR = os.path.dirname(PROJECT_DIR)
def env_list(name, default):
"""
Return a list from a comma-separated env var; fall back to provided default list.
"""
value = os.environ.get(name)
if value:
return [item.strip() for item in value.split(",") if item.strip()]
return default
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
@ -28,6 +37,7 @@ INSTALLED_APPS = [
"search", "search",
"wagtail.contrib.forms", "wagtail.contrib.forms",
"wagtail.contrib.redirects", "wagtail.contrib.redirects",
"wagtail.contrib.settings",
"wagtail.embeds", "wagtail.embeds",
"wagtail.sites", "wagtail.sites",
"wagtail.users", "wagtail.users",
@ -46,6 +56,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"base",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -74,6 +85,8 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"wagtail.contrib.settings.context_processors.settings",
"home.context_processors.navigation_pages",
], ],
}, },
}, },
@ -121,6 +134,28 @@ USE_I18N = True
USE_TZ = 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 _ from django.utils.translation import gettext_lazy as _
LANGUAGES = [ LANGUAGES = [
@ -144,19 +179,30 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATIC_URL = "/static/" STATIC_URL = "/static/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media") MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/" MEDIA_URL = f'{os.environ.get("AWS_S3_ENDPOINT_URL")}/{os.environ.get("AWS_STORAGE_BUCKET_NAME")}/'
# Default storage settings # Default storage settings
# See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STORAGES # See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STORAGES
STORAGES = { STORAGES = {
"default": { "default": {
"BACKEND": "django.core.files.storage.FileSystemStorage", "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
"OPTIONS": {
"endpoint_url": os.environ.get("AWS_S3_ENDPOINT_URL"),
"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"),
"addressing_style": "path",
},
}, },
"staticfiles": { "staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
}, },
} }
# Avoid overwriting user uploads when using S3 storage unless explicitly enabled via env
AWS_S3_FILE_OVERWRITE = os.environ.get("AWS_S3_FILE_OVERWRITE", "False").lower() == "true"
# Django sets a maximum of 1000 fields per form by default, but particularly complex page models # Django sets a maximum of 1000 fields per form by default, but particularly complex page models
# can exceed this limit within Wagtail's page editor. # can exceed this limit within Wagtail's page editor.
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000 DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000
@ -184,8 +230,10 @@ WAGTAILADMIN_BASE_URL = "http://example.com"
# see https://docs.wagtail.org/en/stable/advanced_topics/deploying.html#user-uploaded-files # see https://docs.wagtail.org/en/stable/advanced_topics/deploying.html#user-uploaded-files
WAGTAILDOCS_EXTENSIONS = ['csv', 'docx', 'key', 'odt', 'pdf', 'pptx', 'rtf', 'txt', 'xlsx', 'zip'] WAGTAILDOCS_EXTENSIONS = ['csv', 'docx', 'key', 'odt', 'pdf', 'pptx', 'rtf', 'txt', 'xlsx', 'zip']
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = env_list(
'https://innovedus-cms.fly.dev', "CSRF_TRUSTED_ORIGINS"
] )
ALLOWED_HOSTS = ['innovedus-cms.fly.dev'] ALLOWED_HOSTS = env_list(
"ALLOWED_HOSTS"
)

View File

@ -34,10 +34,16 @@
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
{% wagtailuserbar %} {% wagtailuserbar %}
{% include "includes/header.html" %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
{% include "includes/footer.html" %}
{# Global javascript #} {# Global javascript #}
<script type="text/javascript" src="{% static 'js/mysite.js' %}"></script> <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 %} {% block extra_js %}
{# Override this in templates to add extra javascript #} {# Override this in templates to add extra javascript #}

View File

@ -0,0 +1,32 @@
{% load navigation_tags %}
<footer>
{% if settings.base.NavigationSettings.footer_links %}
<div class="footer-sections">
{% for section in settings.base.NavigationSettings.footer_links %}
<div class="footer-section">
{% if section.value.title %}
<h3>{{ section.value.title }}</h3>
{% endif %}
<ul>
{% for link in section.value.links %}
<li><a href="{{ link.url }}" target="_blank">{{ link.label }}</a></li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% endif %}
{% with social_links=settings.base.SocialMediaSettings.links %}
{% if social_links %}
<p>Follow us
{% for item in social_links %}
<a href="{{ item.value.url }}" target="_blank" alt="{{ item.value.platform }}">{{ item.value.platform }}</a>
{% endfor %}
</p>
{% endif %}
{% endwith %}
{% get_footer_text %}
</footer>

View File

@ -0,0 +1,70 @@
{% load wagtailsettings_tags wagtailimages_tags %}
{% get_settings use_default_site=True as settings %}
<header class="site-header">
<div class="header-inner">
{% if settings.base.HeaderSettings.logo %}
<a href="/" class="logo">
{% image settings.base.HeaderSettings.logo fill-60x60 %}
{% if settings.base.HeaderSettings.site_name %}
<span class="site-name">{{ settings.base.HeaderSettings.site_name }}</span>
{% endif %}
</a>
{% endif %}
<nav class="main-nav">
<ul>
{% with site_root=page.get_site.root_page %}
{# Top-level menu: direct children of site root #}
<li>
<a href="#">
最新文章
</a>
{% if nav_latest_page or nav_trending_page %}
<ul class="submenu">
{% if nav_latest_page %}
<li><a href="{{ nav_latest_page.url }}">{{ nav_latest_page.title }}</a></li>
{% endif %}
{% if nav_trending_page %}
<li><a href="{{ nav_trending_page.url }}">{{ nav_trending_page.title }}</a></li>
{% endif %}
</ul>
{% endif %}
</li>
{% 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>
{% endfor %}
{% endif %}
</ul>
</nav>
<form class="header-search" action="{% url 'search' %}" method="get" role="search">
<input
type="search"
name="query"
placeholder="搜尋文章"
value="{{ request.GET.query|default:'' }}"
aria-label="搜尋文章">
<button type="submit">搜尋</button>
</form>
</div>
</header>

View File

@ -7,11 +7,14 @@ from wagtail import urls as wagtail_urls
from wagtail.documents import urls as wagtaildocs_urls from wagtail.documents import urls as wagtaildocs_urls
from search import views as search_views from search import views as search_views
from home import views as home_views
urlpatterns = [ urlpatterns = [
path("django-admin/", admin.site.urls), path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)), path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)), path("documents/", include(wagtaildocs_urls)),
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
path("search/", search_views.search, name="search"), path("search/", search_views.search, name="search"),
] ]

View File

@ -3,3 +3,5 @@ wagtail>=7.1,<7.2
gunicorn gunicorn
dj-database-url dj-database-url
psycopg[binary] psycopg[binary]
python-dotenv
django-storages[boto3]

View File

@ -1,38 +1,38 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static wagtailcore_tags %} {% load wagtailcore_tags %}
{% block body_class %}template-searchresults{% endblock %} {% block body_class %}template-searchresults{% endblock %}
{% block title %}Search{% endblock %} {% block title %}
{% if search_query %}搜尋:{{ search_query }}{% else %}搜尋{% endif %}
{% endblock %}
{% block content %} {% block content %}
<h1>Search</h1> <nav class="breadcrumbs" aria-label="breadcrumb">
<ol>
<form action="{% url 'search' %}" method="get"> <li>
<input type="text" name="query"{% if search_query %} value="{{ search_query }}"{% endif %}> {% if site_root %}
<input type="submit" value="Search" class="button"> <a href="{{ site_root.url }}">首頁</a>
</form> {% else %}
<a href="/">首頁</a>
{% if search_results %}
<ul>
{% for result in search_results %}
<li>
<h4><a href="{% pageurl result %}">{{ result }}</a></h4>
{% if result.search_description %}
{{ result.search_description }}
{% endif %} {% endif %}
</li> </li>
{% endfor %} <li><span>搜尋</span></li>
</ul> {% if search_query %}
<li><span>{{ search_query }}</span></li>
{% endif %}
</ol>
</nav>
{% if search_results.has_previous %} <section class="search-results">
<a href="{% url 'search' %}?query={{ search_query|urlencode }}&amp;page={{ search_results.previous_page_number }}">Previous</a> {% if search_query %}
{% endif %} {% if results_count %}
{% include "home/includes/page-article-list.html" %}
{% if search_results.has_next %} {% else %}
<a href="{% url 'search' %}?query={{ search_query|urlencode }}&amp;page={{ search_results.next_page_number }}">Next</a> <p>找不到與「{{ search_query }}」相關的文章。</p>
{% endif %} {% endif %}
{% elif search_query %} {% else %}
No results found <p>請輸入關鍵字後再進行搜尋。</p>
{% endif %} {% endif %}
</section>
{% endblock %} {% endblock %}

View File

@ -1,46 +1,54 @@
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from urllib.parse import urlencode
from django.core.paginator import Paginator
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.db.models import Q
from wagtail.models import Page from wagtail.models import Site
# To enable logging of search queries for use with the "Promoted search results" module from home.models import ArticlePage, PAGE_SIZE
# <https://docs.wagtail.org/en/stable/reference/contrib/searchpromotions.html>
# uncomment the following line and the lines indicated in the search function
# (after adding wagtail.contrib.search_promotions to INSTALLED_APPS):
# from wagtail.contrib.search_promotions.models import Query
def search(request): def search(request):
search_query = request.GET.get("query", None) search_query = (request.GET.get("query") or "").strip()
page = request.GET.get("page", 1) page_number = request.GET.get("page", 1)
category_sections = []
results_page = None
results_count = 0
# Search
if search_query: if search_query:
search_results = Page.objects.live().search(search_query) primary_qs = ArticlePage.objects.live().search(search_query)
results_count = primary_qs.count()
# To log this query for use with the "Promoted search results" module: if not results_count:
fallback_filter = Q(intro__icontains=search_query) | Q(body__icontains=search_query)
primary_qs = ArticlePage.objects.live().filter(fallback_filter).order_by("-date")
results_count = primary_qs.count()
# query = Query.get(search_query) if results_count:
# query.add_hit() paginator = Paginator(primary_qs, PAGE_SIZE)
results_page = paginator.get_page(page_number)
query_string = urlencode({"query": search_query})
category_sections = [
{
"title": f"搜尋:{search_query}",
"items": results_page,
"url": f"{request.path}?{query_string}",
}
]
else: site = Site.find_for_request(request)
search_results = Page.objects.none() site_root = site.root_page if site else None
# Pagination
paginator = Paginator(search_results, 10)
try:
search_results = paginator.page(page)
except PageNotAnInteger:
search_results = paginator.page(1)
except EmptyPage:
search_results = paginator.page(paginator.num_pages)
return TemplateResponse( return TemplateResponse(
request, request,
"search/search.html", "search/search.html",
{ {
"search_query": search_query, "search_query": search_query,
"search_results": search_results, "category_sections": category_sections,
"results_page": results_page,
"results_count": results_count,
"site_root": site_root,
"page": site_root.specific if site_root else None,
}, },
) )