diff --git a/.gitignore b/.gitignore
index 61cdb80..3d5de7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
.env
__pycache__
-*.pyc
\ No newline at end of file
+*.pyc
+media/
\ No newline at end of file
diff --git a/innovedus_cms/.dockerignore b/innovedus_cms/.dockerignore
index b7c39d7..63b9dde 100644
--- a/innovedus_cms/.dockerignore
+++ b/innovedus_cms/.dockerignore
@@ -1,3 +1,8 @@
fly.toml
.git/
+.venv
+__pycache__/
+*.pyc
*.sqlite3
+media/
+*.log
diff --git a/innovedus_cms/Dockerfile b/innovedus_cms/Dockerfile
index 3c36327..755d6ea 100644
--- a/innovedus_cms/Dockerfile
+++ b/innovedus_cms/Dockerfile
@@ -2,20 +2,29 @@ ARG PYTHON_VERSION=3.13-slim
FROM python:${PYTHON_VERSION}
-ENV PYTHONDONTWRITEBYTECODE 1
-ENV PYTHONUNBUFFERED 1
-
-RUN mkdir -p /code
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=on \
+ DJANGO_SETTINGS_MODULE=mysite.settings.production
WORKDIR /code
+# Create an unprivileged user to run the app
+RUN adduser --disabled-password --gecos '' app
+
COPY requirements.txt /tmp/requirements.txt
RUN set -ex && \
pip install --upgrade pip && \
pip install -r /tmp/requirements.txt && \
rm -rf /root/.cache/
+
COPY . /code
+COPY entrypoint.sh /entrypoint.sh
+
+RUN chmod +x /entrypoint.sh && chown -R app:app /code
+USER app
EXPOSE 8000
+ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn","--bind",":8000","--workers","2","mysite.wsgi"]
diff --git a/innovedus_cms/base/__init__.py b/innovedus_cms/base/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/innovedus_cms/base/admin.py b/innovedus_cms/base/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/innovedus_cms/base/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/innovedus_cms/base/apps.py b/innovedus_cms/base/apps.py
new file mode 100644
index 0000000..05011e8
--- /dev/null
+++ b/innovedus_cms/base/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class BaseConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'base'
diff --git a/innovedus_cms/base/migrations/0001_initial.py b/innovedus_cms/base/migrations/0001_initial.py
new file mode 100644
index 0000000..a81d612
--- /dev/null
+++ b/innovedus_cms/base/migrations/0001_initial.py
@@ -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),
+ ),
+ ]
diff --git a/innovedus_cms/base/migrations/0002_socialmediasettings_alter_navigationsettings_options_and_more.py b/innovedus_cms/base/migrations/0002_socialmediasettings_alter_navigationsettings_options_and_more.py
new file mode 100644
index 0000000..3aa1e59
--- /dev/null
+++ b/innovedus_cms/base/migrations/0002_socialmediasettings_alter_navigationsettings_options_and_more.py
@@ -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',
+ },
+ ),
+ ]
diff --git a/innovedus_cms/base/migrations/0003_alter_socialmediasettings_links_bannersnippet.py b/innovedus_cms/base/migrations/0003_alter_socialmediasettings_links_bannersnippet.py
new file mode 100644
index 0000000..9314ad7
--- /dev/null
+++ b/innovedus_cms/base/migrations/0003_alter_socialmediasettings_links_bannersnippet.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.2.7 on 2026-01-09 05:52
+
+import django.db.models.deletion
+import wagtail.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('base', '0002_socialmediasettings_alter_navigationsettings_options_and_more'),
+ ('wagtailimages', '0027_image_description'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='socialmediasettings',
+ name='links',
+ field=wagtail.fields.StreamField([('link', 2)], block_lookup={0: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('facebook', 'Facebook'), ('twitter', 'Twitter'), ('instagram', 'Instagram'), ('threads', 'Threads'), ('linkedin', 'LinkedIn'), ('youtube', 'YouTube')]}), 1: ('wagtail.blocks.URLBlock', (), {}), 2: ('wagtail.blocks.StructBlock', [[('platform', 0), ('url', 1)]], {})}),
+ ),
+ migrations.CreateModel(
+ name='BannerSnippet',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('key', models.CharField(blank=True, help_text='識別用 key(例如 home / category)', max_length=50)),
+ ('title', models.CharField(blank=True, max_length=255)),
+ ('link_url', models.URLField(blank=True)),
+ ('link_text', models.CharField(blank=True, max_length=100)),
+ ('is_active', models.BooleanField(default=True)),
+ ('sort_order', models.PositiveIntegerField(default=0)),
+ ('image', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
+ ],
+ options={
+ 'verbose_name': 'Banner',
+ 'verbose_name_plural': 'Banners',
+ 'ordering': ['sort_order', 'id'],
+ },
+ ),
+ ]
diff --git a/innovedus_cms/base/migrations/0004_remove_headersettings_logo_headersettings_logo_dark_and_more.py b/innovedus_cms/base/migrations/0004_remove_headersettings_logo_headersettings_logo_dark_and_more.py
new file mode 100644
index 0000000..d64738e
--- /dev/null
+++ b/innovedus_cms/base/migrations/0004_remove_headersettings_logo_headersettings_logo_dark_and_more.py
@@ -0,0 +1,29 @@
+# Generated by Django 5.2.7 on 2026-01-09 09:01
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('base', '0003_alter_socialmediasettings_links_bannersnippet'),
+ ('wagtailimages', '0027_image_description'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='headersettings',
+ name='logo',
+ ),
+ migrations.AddField(
+ model_name='headersettings',
+ name='logo_dark',
+ field=models.ForeignKey(blank=True, help_text='淺色底用(深色 logo)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image'),
+ ),
+ migrations.AddField(
+ model_name='headersettings',
+ name='logo_light',
+ field=models.ForeignKey(blank=True, help_text='深色底用(亮色 logo)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image'),
+ ),
+ ]
diff --git a/innovedus_cms/base/migrations/__init__.py b/innovedus_cms/base/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py
new file mode 100644
index 0000000..0824424
--- /dev/null
+++ b/innovedus_cms/base/models.py
@@ -0,0 +1,175 @@
+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_light = models.ForeignKey(
+ "wagtailimages.Image",
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name="+",
+ help_text="深色底用(亮色 logo)",
+ )
+ logo_dark = models.ForeignKey(
+ "wagtailimages.Image",
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name="+",
+ help_text="淺色底用(深色 logo)",
+ )
+ 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_light"),
+ FieldPanel("logo_dark"),
+ 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"),
+ ("threads", "Threads"),
+ ("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 BannerSnippet(models.Model):
+ key = models.CharField(max_length=50, blank=True, help_text="識別用 key(例如 home / category)")
+ title = models.CharField(max_length=255, blank=True)
+ image = models.ForeignKey(
+ "wagtailimages.Image",
+ null=True,
+ blank=False,
+ on_delete=models.SET_NULL,
+ related_name="+",
+ )
+ link_url = models.URLField(blank=True)
+ link_text = models.CharField(max_length=100, blank=True)
+ is_active = models.BooleanField(default=True)
+ sort_order = models.PositiveIntegerField(default=0)
+
+ panels = [
+ FieldPanel("key"),
+ FieldPanel("title"),
+ FieldPanel("image"),
+ FieldPanel("link_url"),
+ FieldPanel("link_text"),
+ FieldPanel("is_active"),
+ FieldPanel("sort_order"),
+ ]
+
+ class Meta:
+ ordering = ["sort_order", "id"]
+ verbose_name = "Banner"
+ verbose_name_plural = "Banners"
+
+ def __str__(self):
+ return self.title or f"Banner {self.pk}"
+
+@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"
diff --git a/innovedus_cms/base/templates/base/includes/banner_snippets.html b/innovedus_cms/base/templates/base/includes/banner_snippets.html
new file mode 100644
index 0000000..52f6f39
--- /dev/null
+++ b/innovedus_cms/base/templates/base/includes/banner_snippets.html
@@ -0,0 +1,27 @@
+{% load wagtailimages_tags banner_tags %}
+
+{% get_banners banner_key as banners %}
+{% if banners %}
+
+ {% for banner in banners %}
+
+ {% endfor %}
+
+{% endif %}
diff --git a/innovedus_cms/base/templates/base/includes/footer_text.html b/innovedus_cms/base/templates/base/includes/footer_text.html
new file mode 100644
index 0000000..2dec019
--- /dev/null
+++ b/innovedus_cms/base/templates/base/includes/footer_text.html
@@ -0,0 +1,5 @@
+{% load wagtailcore_tags %}
+
+
+ {{ footer_text|richtext }}
+
\ No newline at end of file
diff --git a/innovedus_cms/base/templates/base/includes/home_banner.html b/innovedus_cms/base/templates/base/includes/home_banner.html
new file mode 100644
index 0000000..b77e214
--- /dev/null
+++ b/innovedus_cms/base/templates/base/includes/home_banner.html
@@ -0,0 +1,20 @@
+{% load wagtailimages_tags banner_tags %}
+
+{% get_banners "home" first=True as banner %}
+{% if banner %}
+
+{% endif %}
diff --git a/innovedus_cms/base/templatetags/__init__.py b/innovedus_cms/base/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/innovedus_cms/base/templatetags/banner_tags.py b/innovedus_cms/base/templatetags/banner_tags.py
new file mode 100644
index 0000000..3b39814
--- /dev/null
+++ b/innovedus_cms/base/templatetags/banner_tags.py
@@ -0,0 +1,15 @@
+from django import template
+
+from base.models import BannerSnippet
+
+register = template.Library()
+
+
+@register.simple_tag
+def get_banners(key=None, first=False):
+ banners = BannerSnippet.objects.filter(is_active=True, image__isnull=False)
+ if key:
+ banners = banners.filter(key=key)
+ if first:
+ return banners.first()
+ return banners
diff --git a/innovedus_cms/base/templatetags/navigation_tags.py b/innovedus_cms/base/templatetags/navigation_tags.py
new file mode 100644
index 0000000..384484e
--- /dev/null
+++ b/innovedus_cms/base/templatetags/navigation_tags.py
@@ -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,
+ }
\ No newline at end of file
diff --git a/innovedus_cms/base/tests.py b/innovedus_cms/base/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/innovedus_cms/base/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/innovedus_cms/base/views.py b/innovedus_cms/base/views.py
new file mode 100644
index 0000000..91ea44a
--- /dev/null
+++ b/innovedus_cms/base/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/innovedus_cms/entrypoint.sh b/innovedus_cms/entrypoint.sh
new file mode 100644
index 0000000..0e3d21d
--- /dev/null
+++ b/innovedus_cms/entrypoint.sh
@@ -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 "$@"
diff --git a/innovedus_cms/home/blocks.py b/innovedus_cms/home/blocks.py
new file mode 100644
index 0000000..6a0071d
--- /dev/null
+++ b/innovedus_cms/home/blocks.py
@@ -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"
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/0003_categorypage_latestpage_trendingpage_articlepage_and_more.py b/innovedus_cms/home/migrations/0003_categorypage_latestpage_trendingpage_articlepage_and_more.py
new file mode 100644
index 0000000..571b554
--- /dev/null
+++ b/innovedus_cms/home/migrations/0003_categorypage_latestpage_trendingpage_articlepage_and_more.py
@@ -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'),
+ ),
+ ]
diff --git a/innovedus_cms/home/migrations/0004_articlepage_not_news.py b/innovedus_cms/home/migrations/0004_articlepage_not_news.py
new file mode 100644
index 0000000..5bf324e
--- /dev/null
+++ b/innovedus_cms/home/migrations/0004_articlepage_not_news.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.7 on 2026-01-09 05:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('home', '0003_categorypage_latestpage_trendingpage_articlepage_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='articlepage',
+ name='not_news',
+ field=models.BooleanField(default=False, help_text='不列入最新消息區塊', verbose_name='Not News'),
+ ),
+ ]
diff --git a/innovedus_cms/home/models.py b/innovedus_cms/home/models.py
index 5076f57..ca613de 100644
--- a/innovedus_cms/home/models.py
+++ b/innovedus_cms/home/models.py
@@ -1,7 +1,336 @@
+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
+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
+
+NEWS_SIZE = _get_env_int("HOMEPAGE_NEWS_SIZE", 8) # Default to 8 articles in news layouts
+BLOCK_SIZE = _get_env_int("HOMEPAGE_BLOCK_SIZE", 7) # Default to 7 articles in block layout
+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
+
+# 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", "-id")[:BLOCK_SIZE],
+ "url": category.url,
+ "layout": "block",
+ }
+ )
+ else:
+ # If no subcategories, paginate articles under this category
+ paginator = Paginator(
+ ArticlePage.objects.child_of(self)
+ .live()
+ .order_by("-date", "-id"),
+ 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.filter(not_news=False).live().order_by("-date", "-id")[
+ :NEWS_SIZE
+ ],
+ "url": latest_page.url,
+ }
+ else:
+ # Paginated view
+ paginator = Paginator(
+ ArticlePage.objects.live().order_by("-date", "-id"), 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", "-id"
+ )
+
+ # 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):
- pass
+class HomePage(Page, CategoryMixin):
+ 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)
+
+ # NOT NEED TRENDING SECTION CURRENTLY
+ # ------------------------------------------------------------------
+ # 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", "-id")[: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="在熱門區塊顯示")
+ not_news = models.BooleanField("Not News", 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("not_news"),
+ 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", "-id")[: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))
diff --git a/innovedus_cms/home/static/css/block_list.css b/innovedus_cms/home/static/css/block_list.css
new file mode 100644
index 0000000..c1f2a64
--- /dev/null
+++ b/innovedus_cms/home/static/css/block_list.css
@@ -0,0 +1,300 @@
+.block-list-wrap {
+ position: relative;
+}
+
+.block-first-article {
+ display: grid;
+ grid-template-columns: 480px 1fr;
+ grid-template-areas:
+ "image content";
+ gap: 16px 40px;
+ align-items: start;
+}
+
+.block-first-article .first-article-image {
+ grid-area: image;
+ position: relative;
+ border-radius: 10px;
+ overflow: hidden;
+ width: 480px;
+}
+
+.block-first-article .first-article-image img{
+ display: block;
+ width:480px;
+ height:293px;
+ object-fit:cover;
+ border-radius: 10px;
+}
+
+.block-first-article .first-article-content {
+ grid-area: content;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-height: 293px;
+ overflow: hidden;
+ position: relative;
+}
+
+.block-first-article .first-article-title {
+ font-size: 40px;
+ font-weight: 400;
+ color: #000000;
+ margin: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ flex: 0 0 auto;
+}
+
+.block-first-article .first-article-date {
+ color: #0e1b4266;
+ flex: 0 0 auto;
+}
+
+.block-first-article .first-article-intro {
+ font-size: 20px;
+ flex: 0 1 auto;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.block-first-article .first-article-body {
+ flex: 1 2 auto;
+ min-height: 0;
+ overflow: hidden;
+ position: relative;
+}
+
+.block-first-article .first-article-content::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 100px;
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #ffffff);
+ pointer-events: none;
+}
+
+.block-list-lower {
+ margin: 24px 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+ gap: 28px;
+}
+
+.block-list-items {
+ display: contents;
+}
+
+.block-list-items a {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: 200px;
+ text-decoration: none;
+}
+
+.block-list-thumb {
+ position: relative;
+ width: 194px;
+ height: 133px;
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.block-list-thumb img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.block-list-items a span{
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.block-list-items .article-title {
+ font-size: 20px;
+}
+
+.block-list-items .article-date {
+ color: #0e1b4266;
+}
+
+.block-list-more-article {
+ max-width: 430px;
+ min-width: 200px;
+ flex: 1 1 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ align-self: stretch;
+ margin-left: auto;
+ text-align: center;
+}
+
+.block-list-more-article a {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ justify-content: center;
+}
+
+.block-list-more-article span {
+ font-size: 18px;
+ font-weight: 700;
+ color: #0e1b42;
+ text-decoration: underline;
+}
+
+.block-list-more-article .right-arrow {
+ background: none;
+ width: 52px;
+ height: 52px;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ box-shadow: #0e1b4266 1px 2px 1px 1px;
+}
+
+.block-list-more-article .right-arrow .right-arrow-icon {
+ width: 16px;
+ height: 32px;
+ transform: rotate(180deg);
+}
+
+@media (min-width: 768px) and (max-width: 1023px) {
+ .block-first-article {
+ grid-template-columns: 318px 1fr;
+ }
+
+ .block-first-article .first-article-image {
+ width: 318px;
+ }
+
+ .block-first-article .first-article-image img{
+ width:318px;
+ height:290px;
+ }
+
+ .block-first-article .first-article-title {
+ font-size: 36px;
+ }
+
+ .block-list-lower {
+ gap: 20px;
+ }
+
+ .block-list-more-article {
+ max-width: 200px;
+ flex: 1 1 200px;
+ }
+}
+
+@media (max-width: 767px) {
+ .block-first-article {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "image"
+ "content";
+ }
+}
+
+@media (min-width: 575px) and (max-width: 767px) {
+ .block-first-article .first-article-image {
+ width: 426px;
+ }
+
+ .block-first-article .first-article-image img{
+ width:426px;
+ height:260px;
+ }
+
+ .block-first-article .first-article-title {
+ font-size: 36px;
+ }
+
+ .block-list-items a {
+ display: grid;
+ grid-template-columns: 194px 1fr;
+ column-gap: 16px;
+ row-gap: 8px;
+ width: 100%;
+ }
+
+ .block-list-items a > div:nth-of-type(1) {
+ grid-row: 1 / span 2;
+ }
+
+ .block-list-items a > div:nth-of-type(2),
+ .block-list-items a > div:nth-of-type(3) {
+ grid-column: 2;
+ min-width: 0;
+ }
+
+ .block-list-more-article {
+ max-width: 100%;
+ }
+}
+
+@media (max-width: 574px) {
+ .block-first-article .first-article-image {
+ width: 300px;
+ }
+
+ .block-first-article .first-article-image img{
+ width: 300px;
+ height: 220px;
+ }
+
+ .block-first-article .first-article-title {
+ font-size: 24px;
+ }
+
+ .block-first-article .first-article-intro {
+ font-size: 16px;
+ }
+
+ .block-list-lower {
+ gap: 16px;
+ }
+
+ .block-list-items a {
+ width: 142px;
+ }
+
+ .block-list-thumb {
+ width: 139px;
+ height: 110px;
+ }
+
+ .block-list-items .article-title {
+ font-size: 16px;
+ }
+
+ .block-list-items .article-date {
+ font-size: 12px;
+ }
+
+ .block-list-more-article span {
+ font-size: 16px;
+ }
+
+ .block-list-more-article .right-arrow {
+ width: 40px;
+ height: 40px;
+ }
+
+ .block-list-more-article .right-arrow .right-arrow-icon {
+ width: 12px;
+ height: 24px;
+ }
+}
diff --git a/innovedus_cms/home/static/css/category.css b/innovedus_cms/home/static/css/category.css
new file mode 100644
index 0000000..e48bd6b
--- /dev/null
+++ b/innovedus_cms/home/static/css/category.css
@@ -0,0 +1,70 @@
+.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;
+}
+
+.subcategory-title {
+ display: flex;
+ align-items: center;
+ margin: 24px 0;
+ color: #0e1b42;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.subcategory-title::before,
+.subcategory-title::after {
+ content: "";
+ flex: 1 1 auto;
+ height: 1px;
+ background: #0e1b42;
+}
+
+.subcategory-title span {
+ display: inline-block;
+ width: 190px;
+ height: 40px;
+ line-height: 40px;
+ text-align: center;
+ border: 1px solid #0e1b42;
+ background: #ffffff;
+}
+
+@media (max-width: 767px) {
+ .subcategory-title::after {
+ display: none;
+ }
+}
+
+@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;
+ line-height: 55px;
+ }
+}
diff --git a/innovedus_cms/home/static/css/home.css b/innovedus_cms/home/static/css/home.css
new file mode 100644
index 0000000..a982be3
--- /dev/null
+++ b/innovedus_cms/home/static/css/home.css
@@ -0,0 +1,108 @@
+.home-hero-band {
+ background-color: #0e1b42;
+ color: #ffffff;
+ padding-bottom: 86px;
+}
+
+.home-hero-band a {
+ color: #ffffff;
+}
+
+.home-banner img {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+
+.list-title {
+ align-items: center;
+ 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;
+ height: 1px;
+ transform: translate(-4px, -4px);
+}
+
+.more-link {
+ text-decoration: none;
+ font-size: 16px;
+}
+
+.section-b .list-title {
+ color: #00abf5;
+}
+
+.section-b .list-title .category-title {
+ background-color: #00abf5;
+ color: #ffffff;
+}
+
+.section-b .list-title a{
+ color: #00abf5;
+}
+
+.section-o .list-title {
+ color: #f4a41c;
+}
+
+.section-o .list-title .category-title {
+ background-color: #f4a41c;
+ color: #ffffff;
+}
+
+.section-o .list-title a{
+ color: #f4a41c;
+}
+
+@media (min-width: 768px) and (max-width: 1023px) {
+ .list-title .category-title {
+ width: 142px;
+ }
+}
+
+@media (max-width: 767px) {
+ .home-banner {
+ width: 100vw;
+ margin-left: calc(50% - 50vw);
+ }
+}
+
+@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;
+ transform: translate(-4px, -4px);
+ }
+
+ .more-link {
+ font-size: 14px;
+ }
+}
diff --git a/innovedus_cms/home/static/css/horizontal_list.css b/innovedus_cms/home/static/css/horizontal_list.css
new file mode 100644
index 0000000..27cff22
--- /dev/null
+++ b/innovedus_cms/home/static/css/horizontal_list.css
@@ -0,0 +1,232 @@
+.horizontal-list-wrap {
+ position: relative;
+}
+
+.horizontal-list-arrow {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 52px;
+ height: 52px;
+ border: 0;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.85);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18);
+ cursor: pointer;
+}
+
+.horizontal-list-arrow[data-dir="left"] {
+ left: 0;
+}
+
+.horizontal-list-arrow[data-dir="right"] {
+ right: 0;
+}
+
+.horizontal-list-arrow-icon {
+ width: 18px;
+ height: 34px;
+ display: block;
+}
+
+.horizontal-list-arrow[data-dir="right"] .horizontal-list-arrow-icon {
+ transform: rotate(180deg);
+}
+
+.horizontal-list-arrow.is-hidden {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.horizontal-list {
+ display: flex;
+ align-items: stretch;
+ gap: 30px;
+ overflow-x: auto;
+ scroll-snap-type: x mandatory;
+ list-style: none;
+ margin: 24px 0;
+ padding: 0;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.horizontal-list::-webkit-scrollbar {
+ display: none;
+}
+
+.horizontal-list li {
+ flex: 0 0 200px;
+ overflow: hidden;
+ scroll-snap-align: start;
+}
+
+.horizontal-list li a {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ color: inherit;
+ text-decoration: none;
+}
+
+.horizontal-list-thumb {
+ position: relative;
+ width: 194px;
+ height: 133px;
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.horizontal-list-thumb::after {
+ content: "";
+ position: absolute;
+ left: 0px;
+ right: 0px;
+ bottom: 0px;
+ height: 25px;
+ pointer-events: none;
+}
+
+.section-b .horizontal-list-thumb::after {
+ background: url("../img/picfrm_b194.png") no-repeat left bottom / cover;
+ background-size: 194px 25px;
+}
+
+.section-o .horizontal-list-thumb::after {
+ background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
+ background-size: 194px 25px;
+}
+
+.horizontal-list li img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.horizontal-list li a:hover,
+.horizontal-list li a:focus {
+ transform: translateY(-2px);
+}
+
+.horizontal-list li a span {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.horizontal-list .article-title {
+ font-size: 20px;
+}
+
+.horizontal-list .article-intro {
+ font-size: 16px;
+}
+
+.horizontal-list .article-date {
+ color: #0e1b4266;
+ font-size: 16px;
+}
+
+.horizontal-list .empty {
+ flex: 1 0 auto;
+ text-align: center;
+ padding: 24px;
+ color: #666;
+ background: #fafafa;
+ border: 1px dashed #ddd;
+ border-radius: 12px;
+}
+
+@media (min-width: 768px) and (max-width: 1023px), (max-width: 574px) {
+ .horizontal-list {
+ gap: 16px;
+ }
+
+ .horizontal-list-thumb {
+ width: 139px;
+ height: 110px;
+ }
+
+ .section-b .horizontal-list-thumb::after {
+ background: url("../img/picfrm_b139.png") no-repeat left bottom / cover;
+ background-size: 139px 25px;
+ }
+
+ .section-o .horizontal-list-thumb::after {
+ background: url("../img/picfrm_o139.png") no-repeat left bottom / cover;
+ background-size: 139px 25px;
+ }
+
+ .horizontal-list a > div:nth-of-type(3) {
+ display: none;
+ }
+}
+
+@media (min-width: 768px) and (max-width: 1023px){
+ .horizontal-list li {
+ flex: 0 0 145px;
+ }
+
+ .horizontal-list .article-title {
+ width: 145px;
+ }
+}
+
+@media (min-width: 575px) and (max-width: 767px) {
+ .horizontal-list {
+ flex-direction: column;
+ gap: 16px;
+ overflow-x: visible;
+ }
+
+ .horizontal-list li {
+ flex: 0 0 auto;
+ }
+
+ .horizontal-list li a {
+ display: grid;
+ grid-template-columns: 200px 1fr;
+ column-gap: 16px;
+ row-gap: 6px;
+ }
+
+ .horizontal-list li a > div:nth-of-type(1) {
+ grid-row: 1 / span 3;
+ }
+
+ .horizontal-list li a > div:nth-of-type(2),
+ .horizontal-list li a > div:nth-of-type(3),
+ .horizontal-list li a > div:nth-of-type(4) {
+ grid-column: 2;
+ }
+}
+
+@media (max-width: 574px) {
+ .horizontal-list-arrow {
+ display: none;
+ }
+
+ .horizontal-list {
+ flex-wrap: wrap;
+ overflow-x: visible;
+ scroll-snap-type: none;
+ }
+
+ .horizontal-list li {
+ flex: 0 0 142px;
+ }
+
+ .horizontal-list .article-title {
+ font-size: 16px;
+ }
+
+ .horizontal-list .article-date {
+ font-size: 12px;
+ }
+}
diff --git a/innovedus_cms/home/static/css/news_list.css b/innovedus_cms/home/static/css/news_list.css
new file mode 100644
index 0000000..eeab0a1
--- /dev/null
+++ b/innovedus_cms/home/static/css/news_list.css
@@ -0,0 +1,416 @@
+.news-title, .more-news-title {
+ background-color: #ffffff;
+ color: #0e1b42;
+}
+
+.news-hero {
+ display: grid;
+ grid-template-columns: 480px 1fr;
+ grid-template-areas:
+ "header header"
+ "image content";
+ gap: 16px 40px;
+ align-items: start;
+}
+
+.news-hero .news-hero-header {
+ grid-area: header;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.news-hero .list-title {
+ grid-area: title;
+ max-width: 310px;
+ flex: 0 1 310px;
+ display: block;
+}
+
+.news-hero .fist-news-title {
+ grid-area: head;
+ max-width: 580px;
+ font-size: 40px;
+ font-weight: 400;
+ color: #eb9f13;
+ margin: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.news-hero .first-news-image {
+ grid-area: image;
+ position: relative;
+ border-radius: 10px;
+ overflow: hidden;
+ width: 480px;
+}
+
+.news-hero .first-news-image::after {
+ content: "";
+ position: absolute;
+ left: 0px;
+ right: 0px;
+ bottom: 0px;
+ height: 25px;
+ background: url("../img/picfrm_b480.png") no-repeat left bottom / cover;
+ background-size: 480px 25px;
+ pointer-events: none;
+}
+
+.news-hero .first-news-image img {
+ display: block;
+ width:480px;
+ height:293px;
+ object-fit:cover;
+ border-radius: 10px;
+}
+
+.news-hero .first-news-content {
+ grid-area: content;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-height: 293px;
+}
+
+.news-hero .fist-news-date,
+.news-hero .first-news-intro {
+ flex: 0 0 auto;
+}
+
+.news-hero .fist-news-date {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.news-hero .first-news-intro {
+ font-size: 20px;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.news-hero .first-news-body {
+ font-size: 13px;
+ flex: 1 1 auto;
+ overflow: hidden;
+ position: relative;
+}
+
+.news-hero .first-news-body::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 100px;
+ background: linear-gradient(to bottom, rgba(14, 27, 66, 0), #0e1b42);
+ pointer-events: none;
+}
+
+.news-list-items {
+ display: contents;
+}
+
+.news-list-lower {
+ margin-top: 24px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 28px;
+}
+
+.news-list-items a {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: 200px;
+ text-decoration: none;
+}
+
+.news-list-thumb {
+ position: relative;
+ width: 194px;
+ height: 133px;
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.news-list-thumb::after {
+ content: "";
+ position: absolute;
+ left: 0px;
+ right: 0px;
+ bottom: 0px;
+ height: 25px;
+ background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
+ background-size: 194px 25px;
+ pointer-events: none;
+}
+
+.news-list-thumb img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.news-list-items a span{
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.news-list-items .article-title {
+ font-size: 20px;
+}
+
+.news-list-items .article-date {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.more-news {
+ flex: 1 1 200px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.more-news .article-title {
+ max-width: 200px;
+ font-size: 20px;
+ display: -webkit-box;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+@media (min-width: 768px) and (max-width: 1023px) {
+ .news-hero {
+ grid-template-columns: 318px 1fr;
+ }
+
+ .news-hero .fist-news-title {
+ max-width: 320px;
+ }
+
+ .news-hero .first-news-image {
+ width: 318px;
+ }
+
+ .news-hero .first-news-image::after {
+ background-size: 318px 25px;
+ }
+
+ .news-hero .first-news-image img {
+ width: 318px;
+ height: 290px;
+ }
+
+ .news-hero .first-news-content {
+ height: 290px;
+ }
+
+ .news-list-lower {
+ gap: 16px;
+ }
+}
+
+@media (min-width: 575px) and (max-width: 1023px) {
+ .news-hero .fist-news-title {
+ font-size: 36px;
+ }
+
+ .more-news {
+ display: grid;
+ grid-template-columns: 200px 1fr 1fr;
+ gap: 8px 16px;
+ align-items: start;
+ }
+
+ .more-news .more-news-title {
+ grid-column: 1;
+ grid-row: 1 / span 2;
+ align-self: stretch;
+ }
+
+ .more-news .article-title {
+ max-width: none;
+ }
+
+ .more-news a:nth-of-type(1) {
+ grid-column: 2;
+ grid-row: 1;
+ }
+
+ .more-news a:nth-of-type(2) {
+ grid-column: 2;
+ grid-row: 2;
+ }
+
+ .more-news a:nth-of-type(3) {
+ grid-column: 3;
+ grid-row: 1;
+ }
+
+ .more-news a:nth-of-type(4) {
+ grid-column: 3;
+ grid-row: 2;
+ }
+}
+
+@media (max-width: 767px) {
+ .news-hero {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "title"
+ "image"
+ "head"
+ "content";
+ }
+
+ .news-hero .news-hero-header {
+ display: contents;
+ }
+
+ .news-hero .list-title {
+ max-width: none;
+ }
+}
+
+@media (min-width: 575px) and (max-width: 767px) {
+ .news-list-items a {
+ display: grid;
+ grid-template-columns: 194px 1fr;
+ column-gap: 16px;
+ row-gap: 8px;
+ width: 100%;
+ }
+
+ .news-hero .first-news-image {
+ width: 426px;
+ }
+
+ .news-hero .first-news-image::after {
+ background: url("../img/picfrm_b426.png") no-repeat left bottom / cover;
+ background-size: 426px 25px;
+ }
+
+ .news-hero .first-news-image img {
+ width:426px;
+ height:260px;
+ }
+
+ .news-list-items a > div:nth-of-type(1) {
+ grid-row: 1 / span 3;
+ }
+
+ .news-list-items a > div:nth-of-type(2),
+ .news-list-items a > div:nth-of-type(3),
+ .news-list-items a > div:nth-of-type(4) {
+ grid-column: 2;
+ }
+
+ .more-news {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .more-news .more-news-title {
+ margin-bottom: 20px;
+ grid-column: 1 / -1;
+ grid-row: 1;
+ }
+
+ .more-news .article-title {
+ margin-top: 10px;
+ }
+
+ .more-news a:nth-of-type(1) {
+ grid-column: 1;
+ grid-row: 2;
+ }
+
+ .more-news a:nth-of-type(2) {
+ grid-column: 2;
+ grid-row: 2;
+ }
+
+ .more-news a:nth-of-type(3) {
+ grid-column: 1;
+ grid-row: 3;
+ }
+
+ .more-news a:nth-of-type(4) {
+ grid-column: 2;
+ grid-row: 3;
+ }
+}
+
+@media (max-width: 574px) {
+ .news-hero .first-news-image {
+ width: 300px;
+ height: 220px;
+ justify-self: center;
+ }
+
+ .news-hero .first-news-image::after {
+ background: url("../img/picfrm_b300.png") no-repeat left bottom / cover;
+ background-size: 300px 25px;
+ }
+
+ .news-hero .first-news-image img {
+ width: 300px;
+ height: 220px;
+ }
+
+
+ .news-hero .fist-news-title {
+ font-size: 24px;
+ }
+
+ .news-hero .first-news-intro {
+ font-size: 16px;
+ }
+
+ .news-list-lower {
+ gap: 16px;
+ }
+
+ .news-list-items a {
+ width: 142px;
+ }
+
+ .news-list-thumb {
+ width: 139px;
+ height: 110px;
+ }
+
+ .news-list-thumb::after {
+ background: url("../img/picfrm_o139.png") no-repeat left bottom / cover;
+ background-size: 139px 25px;
+ }
+
+ .news-list-items a > div:nth-of-type(3) {
+ display: none;
+ }
+
+ .news-list-items .article-title {
+ font-size: 16px;
+ }
+
+ .news-list-items .article-date {
+ font-size: 12px;
+ }
+
+ .more-news{
+ max-width: 142px;
+ gap: 12px;
+ }
+
+ .more-news .article-title {
+ font-size: 16px;
+ }
+}
diff --git a/innovedus_cms/home/static/css/welcome_page.css b/innovedus_cms/home/static/css/welcome_page.css
deleted file mode 100644
index bad2933..0000000
--- a/innovedus_cms/home/static/css/welcome_page.css
+++ /dev/null
@@ -1,184 +0,0 @@
-html {
- box-sizing: border-box;
-}
-
-*,
-*:before,
-*:after {
- box-sizing: inherit;
-}
-
-body {
- max-width: 960px;
- min-height: 100vh;
- margin: 0 auto;
- padding: 0 15px;
- color: #231f20;
- font-family: 'Helvetica Neue', 'Segoe UI', Arial, sans-serif;
- line-height: 1.25;
-}
-
-a {
- background-color: transparent;
- color: #308282;
- text-decoration: underline;
-}
-
-a:hover {
- color: #ea1b10;
-}
-
-h1,
-h2,
-h3,
-h4,
-h5,
-p,
-ul {
- padding: 0;
- margin: 0;
- font-weight: 400;
-}
-
-svg:not(:root) {
- overflow: hidden;
-}
-
-.header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-top: 20px;
- padding-bottom: 10px;
- border-bottom: 1px solid #e6e6e6;
-}
-
-.logo {
- width: 150px;
- margin-inline-end: 20px;
-}
-
-.logo a {
- display: block;
-}
-
-.figure-logo {
- max-width: 150px;
- max-height: 55.1px;
-}
-
-.release-notes {
- font-size: 14px;
-}
-
-.main {
- padding: 40px 0;
- margin: 0 auto;
- text-align: center;
-}
-
-.figure-space {
- max-width: 265px;
-}
-
-@keyframes pos {
- 0%, 100% {
- transform: rotate(-6deg);
- }
- 50% {
- transform: rotate(6deg);
- }
-}
-
-.egg {
- fill: #43b1b0;
- animation: pos 3s ease infinite;
- transform: translateY(50px);
- transform-origin: 50% 80%;
-}
-
-.main-text {
- max-width: 400px;
- margin: 5px auto;
-}
-
-.main-text h1 {
- font-size: 22px;
-}
-
-.main-text p {
- margin: 15px auto 0;
-}
-
-.footer {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- border-top: 1px solid #e6e6e6;
- padding: 10px;
-}
-
-.option {
- display: block;
- padding: 10px 10px 10px 34px;
- position: relative;
- text-decoration: none;
-}
-
-.option svg {
- width: 24px;
- height: 24px;
- fill: gray;
- border: 1px solid #d9d9d9;
- padding: 5px;
- border-radius: 100%;
- top: 10px;
- inset-inline-start: 0;
- position: absolute;
-}
-
-.option h2 {
- font-size: 19px;
- text-decoration: underline;
-}
-
-.option p {
- padding-top: 3px;
- color: #231f20;
- font-size: 15px;
- font-weight: 300;
-}
-
-@media (max-width: 996px) {
- body {
- max-width: 780px;
- }
-}
-
-@media (max-width: 767px) {
- .option {
- flex: 0 0 50%;
- }
-}
-
-@media (max-width: 599px) {
- .main {
- padding: 20px 0;
- }
-
- .figure-space {
- max-width: 200px;
- }
-
- .footer {
- display: block;
- width: 300px;
- margin: 0 auto;
- }
-}
-
-@media (max-width: 360px) {
- .header-link {
- max-width: 100px;
- }
-}
diff --git a/innovedus_cms/home/static/img/default_cover.jpg b/innovedus_cms/home/static/img/default_cover.jpg
new file mode 100644
index 0000000..61cb38d
Binary files /dev/null and b/innovedus_cms/home/static/img/default_cover.jpg differ
diff --git a/innovedus_cms/home/static/img/picfrm_b139.png b/innovedus_cms/home/static/img/picfrm_b139.png
new file mode 100644
index 0000000..b5d4f6c
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_b139.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_b194.png b/innovedus_cms/home/static/img/picfrm_b194.png
new file mode 100644
index 0000000..bada8a3
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_b194.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_b300.png b/innovedus_cms/home/static/img/picfrm_b300.png
new file mode 100644
index 0000000..d9d2f5a
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_b300.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_b318.png b/innovedus_cms/home/static/img/picfrm_b318.png
new file mode 100644
index 0000000..4b18084
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_b318.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_b426.png b/innovedus_cms/home/static/img/picfrm_b426.png
new file mode 100644
index 0000000..25675e1
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_b426.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_b480.png b/innovedus_cms/home/static/img/picfrm_b480.png
new file mode 100644
index 0000000..494a2d3
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_b480.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_o139.png b/innovedus_cms/home/static/img/picfrm_o139.png
new file mode 100644
index 0000000..916760f
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_o139.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_o194.png b/innovedus_cms/home/static/img/picfrm_o194.png
new file mode 100644
index 0000000..b2f646a
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_o194.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_o300.png b/innovedus_cms/home/static/img/picfrm_o300.png
new file mode 100644
index 0000000..045ef82
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_o300.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_o318.png b/innovedus_cms/home/static/img/picfrm_o318.png
new file mode 100644
index 0000000..44ab13a
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_o318.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_o426.png b/innovedus_cms/home/static/img/picfrm_o426.png
new file mode 100644
index 0000000..48ca2c7
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_o426.png differ
diff --git a/innovedus_cms/home/static/img/picfrm_o480.png b/innovedus_cms/home/static/img/picfrm_o480.png
new file mode 100644
index 0000000..0406a77
Binary files /dev/null and b/innovedus_cms/home/static/img/picfrm_o480.png differ
diff --git a/innovedus_cms/home/static/js/block_list.js b/innovedus_cms/home/static/js/block_list.js
new file mode 100644
index 0000000..edd7129
--- /dev/null
+++ b/innovedus_cms/home/static/js/block_list.js
@@ -0,0 +1,45 @@
+(function () {
+ function initBlockList(block) {
+ var list = block.querySelector('.block-list');
+ var left = block.querySelector('[data-dir="left"]');
+ var right = block.querySelector('[data-dir="right"]');
+
+ if (!list || !left || !right) {
+ return;
+ }
+
+ function getScrollAmount() {
+ return Math.max(200, Math.floor(list.clientWidth * 0.8));
+ }
+
+ function updateArrows() {
+ var maxScroll = list.scrollWidth - list.clientWidth;
+ var hasOverflow = maxScroll > 1;
+ var atStart = list.scrollLeft <= 1;
+ var atEnd = list.scrollLeft >= maxScroll - 1;
+
+ left.classList.toggle('is-hidden', !hasOverflow || atStart);
+ right.classList.toggle('is-hidden', !hasOverflow || atEnd);
+ }
+
+ left.addEventListener('click', function () {
+ list.scrollBy({ left: -getScrollAmount(), behavior: 'smooth' });
+ });
+
+ right.addEventListener('click', function () {
+ list.scrollBy({ left: getScrollAmount(), behavior: 'smooth' });
+ });
+
+ list.addEventListener('scroll', function () {
+ window.requestAnimationFrame(updateArrows);
+ });
+
+ window.addEventListener('resize', updateArrows);
+ updateArrows();
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ var blocks = document.querySelectorAll('[data-block-list]');
+ blocks.forEach(initBlockList);
+ });
+})();
diff --git a/innovedus_cms/home/static/js/horizontal_list.js b/innovedus_cms/home/static/js/horizontal_list.js
new file mode 100644
index 0000000..3a52dfb
--- /dev/null
+++ b/innovedus_cms/home/static/js/horizontal_list.js
@@ -0,0 +1,45 @@
+(function () {
+ function initHorizontalList(block) {
+ var list = block.querySelector('.horizontal-list');
+ var left = block.querySelector('[data-dir="left"]');
+ var right = block.querySelector('[data-dir="right"]');
+
+ if (!list || !left || !right) {
+ return;
+ }
+
+ function getScrollAmount() {
+ return Math.max(200, Math.floor(list.clientWidth * 0.8));
+ }
+
+ function updateArrows() {
+ var maxScroll = list.scrollWidth - list.clientWidth;
+ var hasOverflow = maxScroll > 1;
+ var atStart = list.scrollLeft <= 1;
+ var atEnd = list.scrollLeft >= maxScroll - 1;
+
+ left.classList.toggle('is-hidden', !hasOverflow || atStart);
+ right.classList.toggle('is-hidden', !hasOverflow || atEnd);
+ }
+
+ left.addEventListener('click', function () {
+ list.scrollBy({ left: -getScrollAmount(), behavior: 'smooth' });
+ });
+
+ right.addEventListener('click', function () {
+ list.scrollBy({ left: getScrollAmount(), behavior: 'smooth' });
+ });
+
+ list.addEventListener('scroll', function () {
+ window.requestAnimationFrame(updateArrows);
+ });
+
+ window.addEventListener('resize', updateArrows);
+ updateArrows();
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ var blocks = document.querySelectorAll('[data-horizontal-list]');
+ blocks.forEach(initHorizontalList);
+ });
+})();
diff --git a/innovedus_cms/home/templates/home/article_page.html b/innovedus_cms/home/templates/home/article_page.html
new file mode 100644
index 0000000..f2891ab
--- /dev/null
+++ b/innovedus_cms/home/templates/home/article_page.html
@@ -0,0 +1,35 @@
+{% extends "base.html" %}
+{% load wagtailcore_tags wagtailimages_tags %}
+
+{% block content %}
+
+ {{ page.title }}
+ {% if page.banner_image %}
+ {% image page.banner_image original as banner %}
+
+ {% endif %}
+ {{ page.date }}
+ {{ page.intro }}
+
+ {{ page.body }}
+
+ {% with tags=page.tags.all %}
+ {% if tags %}
+
+ {% endif %}
+ {% endwith %}
+ {% if related_articles %}
+
+ 相關文章
+ {% include "home/includes/article_list.html" with items=related_articles %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/innovedus_cms/home/templates/home/blocks/h2_heading.html b/innovedus_cms/home/templates/home/blocks/h2_heading.html
new file mode 100644
index 0000000..1a01967
--- /dev/null
+++ b/innovedus_cms/home/templates/home/blocks/h2_heading.html
@@ -0,0 +1 @@
+{{ value }}
\ No newline at end of file
diff --git a/innovedus_cms/home/templates/home/blocks/horizontal_rule.html b/innovedus_cms/home/templates/home/blocks/horizontal_rule.html
new file mode 100644
index 0000000..10afc6a
--- /dev/null
+++ b/innovedus_cms/home/templates/home/blocks/horizontal_rule.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/innovedus_cms/home/templates/home/category_page.html b/innovedus_cms/home/templates/home/category_page.html
new file mode 100644
index 0000000..aa1361b
--- /dev/null
+++ b/innovedus_cms/home/templates/home/category_page.html
@@ -0,0 +1,37 @@
+{% extends "base.html" %}
+{% load wagtailcore_tags static %}
+{% block extra_css %}
+
+{% endblock %}
+{% block content %}
+ {# Breadcrumbs navigation may not used anymore #}
+ {% if breadcrumbs %}
+
+ {% endif %}
+ {{ self.title}}
+ {% if page.has_subcategories %}
+ {% for section in category_sections %}
+ {{ section.title }}
+ {% include "home/includes/block_list.html" with items=section.items %}
+ {% endfor %}
+ {% else %}
+ {% include "home/includes/page-article-list.html" with category=category_sections.0 %}
+ {% endif %}
+{% endblock %}
diff --git a/innovedus_cms/home/templates/home/hashtag_page.html b/innovedus_cms/home/templates/home/hashtag_page.html
new file mode 100644
index 0000000..287f677
--- /dev/null
+++ b/innovedus_cms/home/templates/home/hashtag_page.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% load wagtailcore_tags %}
+{% block content %}
+
+
+ {% include "home/includes/page-article-list.html" %}
+{% endblock %}
diff --git a/innovedus_cms/home/templates/home/home_page.html b/innovedus_cms/home/templates/home/home_page.html
index db9e9b0..81dc3a1 100644
--- a/innovedus_cms/home/templates/home/home_page.html
+++ b/innovedus_cms/home/templates/home/home_page.html
@@ -1,21 +1,32 @@
{% extends "base.html" %}
{% load static %}
-
-{% block body_class %}template-homepage{% endblock %}
-
+{% block body_class %}template-darkbackground{% endblock %}
{% block extra_css %}
-
-{% comment %}
-Delete the line below if you're just getting started and want to remove the welcome screen!
-{% endcomment %}
-
-{% endblock extra_css %}
-
+
+
+
+
+{% endblock %}
{% block content %}
+
+
+
+ {% include "base/includes/home_banner.html" %}
+ {% with top_section=sections.top_section %}
+ {% for section in top_section %}
+ {% include "home/includes/news_list.html" with section=section %}
+ {% endfor %}
+ {% endwith %}
+
+
+
-{% comment %}
-Delete the line below if you're just getting started and want to remove the welcome screen!
-{% endcomment %}
-{% include 'home/welcome_page.html' %}
-
+ {% for section in sections.category_sections %}
+ {% cycle 'section-b' 'section-o' as section_color silent %}
+ {% include "home/includes/category_session.html" with section=section section_color=section_color %}
+ {% endfor %}
{% endblock content %}
+{% block extra_js %}
+
+
+{% endblock %}
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..7c13c04
--- /dev/null
+++ b/innovedus_cms/home/templates/home/includes/block_list.html
@@ -0,0 +1,74 @@
+{% load wagtailimages_tags static %}
+{% block extra_css %}
+
+{% endblock %}
+
+
+ {% with first_article=items|first %}
+ {% if first_article %}
+
+
+
+
+ {{ first_article.title }}
+
+
+ {{ first_article.date|date:"Y.m.d" }}
+
+ {% if first_article.intro %}
+
+ {{ first_article.intro }}
+
+ {% endif %}
+
+ {{ first_article.body_search_text|truncatechars:200 }}
+
+
+
+ {% else %}
+
目前沒有文章
+ {% endif %}
+ {% endwith %}
+
+ {% if items|length >= 2 %}
+
+
+ {% endif %}
+
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..f24be4a
--- /dev/null
+++ b/innovedus_cms/home/templates/home/includes/category_session.html
@@ -0,0 +1,19 @@
+{% load wagtailimages_tags static %}
+
+
+
+
{{ section.title }}
+
+
+
+
查看全部
+
+ {% 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 %}
+
+
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..8bdfac2
--- /dev/null
+++ b/innovedus_cms/home/templates/home/includes/horizontal_list.html
@@ -0,0 +1,35 @@
+{% load wagtailimages_tags static %}
+
+
diff --git a/innovedus_cms/home/templates/home/includes/news_list.html b/innovedus_cms/home/templates/home/includes/news_list.html
new file mode 100644
index 0000000..428888d
--- /dev/null
+++ b/innovedus_cms/home/templates/home/includes/news_list.html
@@ -0,0 +1,84 @@
+{% load wagtailimages_tags static %}
+
+
+
+
+ {% with first_article=section.items|first %}
+ {% if first_article %}
+
+
+
+ {{ first_article.date|date:"Y.m.d" }}
+
+ {% if first_article.intro %}
+
+ {{ first_article.intro }}
+
+ {% endif %}
+
+ {{ first_article.body_search_text|truncatechars:320 }}
+
+
+ {% else %}
+
目前沒有文章
+ {% endif %}
+ {% endwith %}
+
+
+ {% if section.items|length >= 2 %}
+
+ {% endif %}
+
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 %}
+
+
+ {% 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/home/views.py b/innovedus_cms/home/views.py
new file mode 100644
index 0000000..1dbfe37
--- /dev/null
+++ b/innovedus_cms/home/views.py
@@ -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)
diff --git a/innovedus_cms/manage.py b/innovedus_cms/manage.py
index 71901b0..336e757 100644
--- a/innovedus_cms/manage.py
+++ b/innovedus_cms/manage.py
@@ -3,6 +3,10 @@
import os
import sys
+from dotenv import load_dotenv
+
+env_file = os.environ.get("ENV_FILE", "../.env")
+load_dotenv(env_file)
def main():
"""Run administrative tasks."""
diff --git a/innovedus_cms/mysite/settings/base.py b/innovedus_cms/mysite/settings/base.py
index 7fa2bc7..e040ad4 100644
--- a/innovedus_cms/mysite/settings/base.py
+++ b/innovedus_cms/mysite/settings/base.py
@@ -16,6 +16,15 @@ import os
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
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
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
@@ -28,6 +37,7 @@ INSTALLED_APPS = [
"search",
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
+ "wagtail.contrib.settings",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
@@ -46,6 +56,7 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
+ "base",
]
MIDDLEWARE = [
@@ -74,6 +85,8 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"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
+# --- Wagtail embeds (Instagram/FB via Graph oEmbed) ---
+# Reads a token from env and adds an extra oEmbed finder for IG/FB.
+# Keeps the default oEmbed finder first for YouTube/Vimeo/etc.
+WAGTAIL_EMBED_FINDERS = [
+ {"class": "wagtail.embeds.finders.oembed"},
+ {
+ "class": "wagtail.embeds.finders.oembed",
+ "options": {
+ "providers": [
+ {"endpoint": "https://graph.facebook.com/v11.0/instagram_oembed", "urls": ["https://www.instagram.com/*"]},
+ {"endpoint": "https://graph.facebook.com/v11.0/oembed_post", "urls": ["https://www.facebook.com/*"]},
+ {"endpoint": "https://graph.facebook.com/v11.0/oembed_page", "urls": ["https://www.facebook.com/*"]},
+ {"endpoint": "https://graph.facebook.com/v11.0/oembed_video", "urls": ["https://www.facebook.com/*"]},
+ ],
+ "params": {
+ "access_token": os.environ.get("IG_OEMBED_ACCESS_TOKEN", ""),
+ "omitscript": True,
+ },
+ },
+ },
+]
+
from django.utils.translation import gettext_lazy as _
LANGUAGES = [
@@ -144,19 +179,30 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATIC_URL = "/static/"
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
# See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STORAGES
STORAGES = {
"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": {
"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
# can exceed this limit within Wagtail's page editor.
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000
@@ -184,8 +230,12 @@ WAGTAILADMIN_BASE_URL = "http://example.com"
# 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']
-CSRF_TRUSTED_ORIGINS = [
- 'https://innovedus-cms.fly.dev',
-]
+CSRF_TRUSTED_ORIGINS = env_list(
+ "CSRF_TRUSTED_ORIGINS",
+ default=[]
+)
-ALLOWED_HOSTS = ['innovedus-cms.fly.dev']
\ No newline at end of file
+ALLOWED_HOSTS = env_list(
+ "ALLOWED_HOSTS",
+ default=[]
+)
diff --git a/innovedus_cms/mysite/static/css/mysite.css b/innovedus_cms/mysite/static/css/mysite.css
index e69de29..b3f68a7 100644
--- a/innovedus_cms/mysite/static/css/mysite.css
+++ b/innovedus_cms/mysite/static/css/mysite.css
@@ -0,0 +1,616 @@
+a {
+ text-decoration: none;
+}
+
+.site-container {
+ max-width: 890px;
+ margin: 0 auto;
+ padding: 0 16px;
+}
+
+.full-bleed {
+ width: 100vw;
+ margin-left: calc(50% - 50vw);
+}
+
+.site-header {
+ position: relative;
+ z-index: 10;
+}
+
+.template-darkbackground .site-header {
+ background-color: #0e1b42;
+ color: #ffffff;
+}
+
+.header-inner {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 30px 0;
+}
+
+.logo {
+ display: inline-flex;
+ align-items: center;
+}
+
+.logo--light {
+ display: none;
+}
+
+.logo--dark {
+ display: inline-flex;
+}
+
+.template-darkbackground .logo--light {
+ display: inline-flex;
+}
+
+.template-darkbackground .logo--dark {
+ display: none;
+}
+
+.main-nav {
+ flex: 1 1 auto;
+ margin-left: 16px;
+ top: 56px;
+}
+
+.main-menu {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+}
+
+.menu-item {
+ position: relative;
+ flex: 1 1 0;
+ text-align: left;
+}
+
+.menu-item-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.submenu-toggle {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: 0;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ color: inherit;
+}
+
+.submenu-toggle::after {
+ content: "";
+ width: 8px;
+ height: 8px;
+ border-right: 2px solid currentColor;
+ border-bottom: 2px solid currentColor;
+ transform: rotate(45deg);
+ display: block;
+ transition: transform 160ms ease;
+}
+
+.menu-item.is-open .submenu-toggle::after {
+ transform: rotate(225deg);
+}
+
+.menu-divider {
+ display: none;
+ height: 1px;
+ background: currentColor;
+ opacity: 0.6;
+ margin: 8px 0 0;
+}
+
+.menu-toggle {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 4px;
+ width: 32px;
+ height: 32px;
+ background: none;
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+}
+
+.menu-toggle-bar {
+ width: 20px;
+ height: 2px;
+ background: currentColor;
+ display: block;
+}
+
+.menu-toggle.is-open .menu-toggle-bar:nth-child(1) {
+ transform: translateY(6px) rotate(45deg);
+}
+
+.menu-toggle.is-open .menu-toggle-bar:nth-child(2) {
+ opacity: 0;
+}
+
+.menu-toggle.is-open .menu-toggle-bar:nth-child(3) {
+ transform: translateY(-6px) rotate(-45deg);
+}
+
+.main-menu-link {
+ display: inline-block;
+ padding: 12px 4px;
+ font-variation-settings: normal;
+ color: #0e1b42;
+ font-family: "Inter:Regular", "Noto Sans JP:Regular", sans-serif;
+ word-break: break-word;
+ font-weight: 400;
+ font-style: normal;
+ font-size: 14px;
+ letter-spacing: 0px;
+ line-height: normal;
+ --letter-spacing: 0px;
+}
+
+.template-darkbackground .main-menu-link {
+ color: #ffffff;
+}
+
+.submenu {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-top: -2px;
+ margin-left: 20px;
+ list-style: none;
+ padding-inline-start: 0;
+ border-bottom: #0e1b42;
+ border-style: solid;
+ border-width: 0 0 1px 0;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: opacity 160ms ease, transform 160ms ease;
+}
+
+.template-darkbackground .submenu {
+ border-bottom: #ffffff;
+}
+
+.menu-item:hover .submenu,
+.menu-item:focus-within .submenu {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: translateX(-50%) translateY(2px);
+}
+
+.submenu-item {
+ min-width: 94px;
+ height: 36px;
+ border: #0e1b42;
+ border-style: solid;
+ border-width: 1px 1px 0 1px;
+ background-color: #ffffff;
+}
+
+.template-darkbackground .submenu-item {
+ border: #ffffff;
+ border-style: solid;
+ border-width: 1px 1px 0 1px;
+ background-color: #0e1b42;
+}
+
+.submenu-item a {
+ display: block;
+ font-variation-settings: normal;
+ color: #0e1b42;
+ font-family: "Inter:Regular", "Noto Sans JP:Regular", sans-serif;
+ word-break: break-word;
+ font-weight: 400;
+ font-style: normal;
+ font-size: 14px;
+ letter-spacing: 0px;
+ line-height: normal;
+ --letter-spacing: 0px;
+ padding: 8px 16px;
+}
+
+.template-darkbackground .submenu-item a {
+ color: #ffffff;
+}
+
+.submenu-item a:hover,
+.submenu-item a:focus {
+ background: rgba(0, 0, 0, 0.05);
+}
+
+
+.header-search .search-input {
+ display: flex;
+ align-items: center;
+}
+
+.header-search {
+ margin-left: auto;
+}
+
+.header-search .search-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ background: none;
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+ color: #1b2140;
+}
+
+.template-darkbackground .header-search .search-icon {
+ color: #ffffff;
+}
+
+.header-search .search-icon svg {
+ width: 28px;
+ height: 28px;
+}
+
+.header-search .search-input {
+ background-color: rgba(0, 0, 0, 0.2);
+}
+
+.template-darkbackground .header-search .search-input {
+ background-color: rgba(255, 255, 255, 0.4);
+}
+
+.header-search input[type="search"] {
+ border: 0;
+ background: transparent;
+ outline: none;
+ width: 181px;
+}
+
+.template-darkbackground .header-search input[type="search"] {
+ color: #ffffff;
+}
+
+.template-darkbackground .header-search input[type="search"]::placeholder {
+ color: #ffffff88;
+}
+
+footer {
+ background: #0e1b42;
+ color: #ffffff;
+ padding: 24px 0;
+ font-size: 14px;
+}
+
+.footer-inner {
+ display: flex;
+ justify-content: center;
+ gap: 48px;
+ align-items: flex-start;
+ text-align: left;
+}
+
+footer .company-info {
+ max-width: 300px;
+ margin: 0;
+ padding: 0 16px;
+}
+
+footer .copyright img {
+ margin: 10px 0;
+ width: 265px;
+ height: 37px;
+}
+
+footer .copyright p {
+ font-size: 10px;
+ text-align: left;
+ margin: 0;
+}
+
+.footer-socials {
+ display: flex;
+ gap: 12px;
+ margin: 16px 0;
+ flex-wrap: wrap;
+}
+
+.footer-socials .icon {
+ width: 32px;
+ height: 32px;
+ --fill-0: #ffffff;
+}
+
+.footer-socials .icon circle {
+ fill: #ffffff;
+ fill-opacity: 0.85;
+}
+
+.footer-socials .icon .icon-cutout {
+ fill: #0e1b42;
+}
+
+footer .footer-links {
+ padding: 0 16px;
+}
+
+footer .footer-links li {
+ list-style-type: none;
+}
+
+footer .footer-links a {
+ font-size: 14px;
+ color: #ffffff;
+}
+
+footer .footer-divider {
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ padding: 0 8px;
+ width: 1px;
+ border-left: 1px solid #ffffff;
+}
+
+footer .footer-sections {
+ display: flex;
+ gap: 32px;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 1023px) {
+ .site-container {
+ max-width: 640px;
+ }
+
+ .header-search input[type="search"] {
+ width: 90px;
+ }
+}
+
+@media (max-width: 768px) {
+ .footer-inner {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ }
+
+ footer .company-info,
+ footer .footer-links {
+ max-width: 100%;
+ }
+
+ .footer-socials {
+ justify-content: center;
+ }
+
+ footer .footer-sections {
+ justify-content: center;
+ }
+
+ footer .footer-divider {
+ width: 100%;
+ padding: 12px 0;
+ border-left: 0;
+ border-top: 1px solid #ffffff;
+ }
+
+ footer .copyright p {
+ text-align: center;
+ }
+}
+
+@media (min-width: 575px) and (max-width: 767px) {
+ .site-container {
+ max-width: 426px;
+ }
+
+ .site-header .site-container {
+ padding: 0 44px;
+ margin: 0;
+ }
+
+ .main-menu {
+ justify-content: flex-end;
+ }
+
+ .header-search input[type="search"] {
+ width: 163px;
+ }
+
+ .main-nav {
+ right: -28px;
+ }
+}
+
+@media (max-width: 767px) {
+ .site-header .site-container {
+ position: relative;
+ }
+
+ .header-inner {
+ width: 100%;
+ }
+
+ .main-nav {
+ position: absolute;
+ width: 220px;
+ display: none;
+ padding: 16px 0;
+ background: #0e1b42;
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12);
+ z-index: 5;
+ }
+
+ .main-nav.is-open {
+ display: block;
+ }
+
+ .main-menu {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ width: 220px;
+ padding: 0 16px;
+ }
+
+ .menu-item {
+ flex: 0 0 auto;
+ width: 100%;
+ text-align: left;
+ }
+
+ .menu-item-header {
+ width: 192px;
+ }
+
+ .menu-toggle {
+ display: flex;
+ margin-left: 12px;
+ }
+
+ .template-darkbackground .menu-toggle {
+ color: #ffffff;
+ }
+
+ .main-nav {
+ background: #ffffff;
+ }
+
+ .template-darkbackground .main-nav {
+ background: #0e1b42;
+ }
+
+ .main-menu-link {
+ color: #0e1b42;
+ }
+
+ .template-darkbackground .main-menu-link {
+ color: #ffffff;
+ font-size: 14px;
+ }
+
+ .submenu-toggle {
+ display: inline-flex;
+ color: #0e1b42;
+ }
+
+ .template-darkbackground .submenu-toggle {
+ color: #ffffff;
+ }
+
+ .menu-divider {
+ margin-left: 0;
+ margin-right: 0;
+ width: 92px;
+ }
+
+ .menu-item.is-open .menu-divider {
+ display: block;
+ }
+
+ .submenu {
+ position: static;
+ margin: 8px 0 0;
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ display: none;
+ border: 0;
+ }
+
+ .menu-item.is-open .submenu {
+ display: block;
+ transform: none;
+ }
+
+ .submenu-item {
+ background: none;
+ border: 0;
+ height: auto;
+ }
+
+ .template-darkbackground .submenu-item {
+ border: 0;
+ height: auto;
+ }
+
+ .submenu-item a {
+ padding: 6px 0 6px 18px;
+ color: #0e1b42;
+ font-size: 14px;
+ }
+
+ .template-darkbackground .submenu-item a {
+ color: #ffffff;
+ }
+}
+
+@media (max-width: 574px) {
+ .site-container {
+ max-width: 300px;
+ }
+
+ .header-inner {
+ flex-wrap: wrap;
+ }
+
+ .header-search {
+ width: 100%;
+ order: 3;
+ margin-left: 0;
+ margin-top: 12px;
+ }
+
+ .header-search input[type="search"] {
+ width: 195px;
+ }
+
+ .main-nav {
+ right: 16px;
+ }
+}
+
+@layer figreset {
+ :root {
+ font-family: var( --default-font-family,ui-sans-serif,system-ui,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji' )
+ }
+
+ html {
+ -webkit-text-size-adjust: 100%;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-font-smoothing: antialiased;
+ width: 100%
+ }
+
+ body {
+ margin: 0;
+ width: 100%
+ }
+
+ body: has([data-page-overflowx='hidden']) {
+ overflow-x:hidden
+ }
+
+ body: has([data-page-overflowx='auto']) {
+ overflow-x:auto
+ }
+}
diff --git a/innovedus_cms/mysite/static/js/header.js b/innovedus_cms/mysite/static/js/header.js
new file mode 100644
index 0000000..4a9851f
--- /dev/null
+++ b/innovedus_cms/mysite/static/js/header.js
@@ -0,0 +1,37 @@
+document.addEventListener("DOMContentLoaded", function () {
+ var toggle = document.querySelector(".menu-toggle");
+ var nav = document.querySelector(".main-nav");
+ if (!toggle || !nav) {
+ return;
+ }
+
+ toggle.addEventListener("click", function () {
+ var isOpen = nav.classList.toggle("is-open");
+ toggle.classList.toggle("is-open", isOpen);
+ toggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
+ });
+
+ var submenuToggles = document.querySelectorAll(".submenu-toggle");
+ submenuToggles.forEach(function (button) {
+ button.addEventListener("click", function () {
+ var item = button.closest(".menu-item");
+ if (!item) {
+ return;
+ }
+
+ var isOpen = item.classList.contains("is-open");
+ document.querySelectorAll(".menu-item.is-open").forEach(function (openItem) {
+ if (openItem !== item) {
+ openItem.classList.remove("is-open");
+ var openButton = openItem.querySelector(".submenu-toggle");
+ if (openButton) {
+ openButton.setAttribute("aria-expanded", "false");
+ }
+ }
+ });
+
+ item.classList.toggle("is-open", !isOpen);
+ button.setAttribute("aria-expanded", !isOpen ? "true" : "false");
+ });
+ });
+});
diff --git a/innovedus_cms/mysite/templates/base.html b/innovedus_cms/mysite/templates/base.html
index 79e2a17..a406f9a 100644
--- a/innovedus_cms/mysite/templates/base.html
+++ b/innovedus_cms/mysite/templates/base.html
@@ -34,10 +34,21 @@
{% wagtailuserbar %}
- {% block content %}{% endblock %}
+ {% include "includes/header.html" %}
+
+
+ {% block content %}{% endblock %}
+
+
+
+ {% include "includes/footer.html" %}
+
{# Global javascript #}
+
+ {# Instagram embed script to render IG oEmbeds #}
+
{% block extra_js %}
{# Override this in templates to add extra javascript #}
diff --git a/innovedus_cms/mysite/templates/includes/footer.html b/innovedus_cms/mysite/templates/includes/footer.html
new file mode 100644
index 0000000..b59cc80
--- /dev/null
+++ b/innovedus_cms/mysite/templates/includes/footer.html
@@ -0,0 +1,75 @@
+{% load navigation_tags %}
+
+
diff --git a/innovedus_cms/mysite/templates/includes/header.html b/innovedus_cms/mysite/templates/includes/header.html
new file mode 100644
index 0000000..5d27409
--- /dev/null
+++ b/innovedus_cms/mysite/templates/includes/header.html
@@ -0,0 +1,140 @@
+{% load wagtailsettings_tags wagtailimages_tags %}
+{% get_settings use_default_site=True as settings %}
+
+
diff --git a/innovedus_cms/mysite/urls.py b/innovedus_cms/mysite/urls.py
index c2e8a0c..b60d225 100644
--- a/innovedus_cms/mysite/urls.py
+++ b/innovedus_cms/mysite/urls.py
@@ -7,11 +7,14 @@ from wagtail import urls as wagtail_urls
from wagtail.documents import urls as wagtaildocs_urls
from search import views as search_views
+from home import views as home_views
urlpatterns = [
path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
+ # use so Unicode tag slugs (e.g. 台北美食) still resolve
+ path("tags//", home_views.hashtag_search, name="hashtag_search"),
path("search/", search_views.search, name="search"),
]
diff --git a/innovedus_cms/requirements.txt b/innovedus_cms/requirements.txt
index 729b367..806a9eb 100644
--- a/innovedus_cms/requirements.txt
+++ b/innovedus_cms/requirements.txt
@@ -2,4 +2,6 @@ Django>=5.2,<5.3
wagtail>=7.1,<7.2
gunicorn
dj-database-url
-psycopg[binary]
\ No newline at end of file
+psycopg[binary]
+python-dotenv
+django-storages[boto3]
\ No newline at end of file
diff --git a/innovedus_cms/search/templates/search/search.html b/innovedus_cms/search/templates/search/search.html
index 476427f..6d06863 100644
--- a/innovedus_cms/search/templates/search/search.html
+++ b/innovedus_cms/search/templates/search/search.html
@@ -1,38 +1,38 @@
{% extends "base.html" %}
-{% load static wagtailcore_tags %}
+{% load wagtailcore_tags %}
{% block body_class %}template-searchresults{% endblock %}
-{% block title %}Search{% endblock %}
+{% block title %}
+ {% if search_query %}搜尋:{{ search_query }}{% else %}搜尋{% endif %}
+{% endblock %}
{% block content %}
-Search
-
-
-
-{% if search_results %}
-
- {% for result in search_results %}
- -
-
- {% if result.search_description %}
- {{ result.search_description }}
+
+
+ 搜尋
+ {% if search_query %}
+ {{ search_query }}
+ {% endif %}
+
+
-{% if search_results.has_previous %}
-Previous
-{% endif %}
-
-{% if search_results.has_next %}
-Next
-{% endif %}
-{% elif search_query %}
-No results found
-{% endif %}
+
+ {% if search_query %}
+ {% if results_count %}
+ {% include "home/includes/page-article-list.html" %}
+ {% else %}
+ 找不到與「{{ search_query }}」相關的文章。
+ {% endif %}
+ {% else %}
+ 請輸入關鍵字後再進行搜尋。
+ {% endif %}
+
{% endblock %}
diff --git a/innovedus_cms/search/views.py b/innovedus_cms/search/views.py
index 678bb7e..d93687c 100644
--- a/innovedus_cms/search/views.py
+++ b/innovedus_cms/search/views.py
@@ -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.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
-#
-# 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
+from home.models import ArticlePage, PAGE_SIZE
def search(request):
- search_query = request.GET.get("query", None)
- page = request.GET.get("page", 1)
+ search_query = (request.GET.get("query") or "").strip()
+ page_number = request.GET.get("page", 1)
+ category_sections = []
+ results_page = None
+ results_count = 0
- # Search
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)
- # query.add_hit()
+ if results_count:
+ 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:
- search_results = Page.objects.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)
+ site = Site.find_for_request(request)
+ site_root = site.root_page if site else None
return TemplateResponse(
request,
"search/search.html",
{
"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,
},
)