From 473637f4774e8e1a0a19a899e82a8313900d0e3d Mon Sep 17 00:00:00 2001 From: mohamad Date: Wed, 14 Oct 2026 18:33:11 +0330 Subject: [PATCH] feat: add people sections and opt-in read more --- apps/core/admin.py | 7 ++ ...randing_article_people_eyebrow_and_more.py | 23 +++++ apps/core/models.py | 11 +++ apps/pages/admin.py | 35 ++++--- apps/pages/migrations/0020_blogpostpartner.py | 33 +++++++ .../0021_alter_blogpostpartner_options.py | 17 ++++ ...aboutsection_read_more_content_and_more.py | 53 ++++++++++ ..._alter_blogpostpartner_options_and_more.py | 22 +++++ ...boutsection_read_more_subtitle_and_more.py | 58 +++++++++++ ..._alter_blogpostpartner_options_and_more.py | 35 +++++++ .../0026_alter_blogpostpartner_options.py | 17 ++++ .../0027_aboutsectionitem_role_and_more.py | 23 +++++ apps/pages/models.py | 88 +++++++++++++++++ apps/pages/tests/test_views.py | 97 ++++++++++++++++++- apps/pages/views.py | 4 +- apps/products/admin.py | 24 +++-- ..._article_read_more_description_and_more.py | 43 ++++++++ ...articlecitation_read_more_text_and_more.py | 23 +++++ apps/products/models.py | 8 ++ apps/products/tests/test_views.py | 15 +++ static/css/main.css | 25 ++++- static/js/main.js | 4 +- templates/pages/blog_detail.html | 19 +++- templates/pages/blog_list.html | 2 +- templates/pages/home.html | 4 +- templates/partials/_page_sections.html | 60 ++++++++---- templates/partials/_person_card.html | 8 ++ templates/partials/_standard_item_card.html | 4 +- templates/partials/_video_block.html | 8 +- templates/products/_article_list.html | 6 +- templates/products/main_detail.html | 6 +- templates/products/overview.html | 4 +- templates/products/sub_detail.html | 4 +- 33 files changed, 722 insertions(+), 68 deletions(-) create mode 100644 apps/core/migrations/0006_sitebranding_article_people_eyebrow_and_more.py create mode 100644 apps/pages/migrations/0020_blogpostpartner.py create mode 100644 apps/pages/migrations/0021_alter_blogpostpartner_options.py create mode 100644 apps/pages/migrations/0022_aboutsection_read_more_content_and_more.py create mode 100644 apps/pages/migrations/0023_alter_blogpostpartner_options_and_more.py create mode 100644 apps/pages/migrations/0024_aboutsection_read_more_subtitle_and_more.py create mode 100644 apps/pages/migrations/0025_alter_blogpostpartner_options_and_more.py create mode 100644 apps/pages/migrations/0026_alter_blogpostpartner_options.py create mode 100644 apps/pages/migrations/0027_aboutsectionitem_role_and_more.py create mode 100644 apps/products/migrations/0023_article_read_more_description_and_more.py create mode 100644 apps/products/migrations/0024_articlecitation_read_more_text_and_more.py create mode 100644 templates/partials/_person_card.html diff --git a/apps/core/admin.py b/apps/core/admin.py index 9fcde81..a3c071b 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -36,6 +36,13 @@ class SiteBrandingAdmin(admin.ModelAdmin): "description": "Names and text used throughout the public website.", }, ), + ( + "Blog article people section", + { + "fields": ("article_people_eyebrow", "article_people_title"), + "description": "These headings are shared by every blog article that has people cards.", + }, + ), ( "Brand assets", { diff --git a/apps/core/migrations/0006_sitebranding_article_people_eyebrow_and_more.py b/apps/core/migrations/0006_sitebranding_article_people_eyebrow_and_more.py new file mode 100644 index 0000000..6b30bf0 --- /dev/null +++ b/apps/core/migrations/0006_sitebranding_article_people_eyebrow_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.16 on 2026-10-12 11:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_sitebranding_accent_color_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='sitebranding', + name='article_people_eyebrow', + field=models.CharField(blank=True, default='The people behind this article', help_text='Small heading above the people cards on every blog article.', max_length=160), + ), + migrations.AddField( + model_name='sitebranding', + name='article_people_title', + field=models.CharField(default='Organizers & collaborators', help_text='Heading above the people cards on every blog article.', max_length=200), + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index fe46ece..48c375b 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -30,6 +30,17 @@ class SiteBranding(models.Model): default="Communication to Care — Advanced Medical Imaging and Radiomics Solutions", blank=True, ) + article_people_eyebrow = models.CharField( + max_length=160, + blank=True, + default="The people behind this article", + help_text="Small heading above the people cards on every blog article.", + ) + article_people_title = models.CharField( + max_length=200, + default="Organizers & collaborators", + help_text="Heading above the people cards on every blog article.", + ) icon = models.ImageField( upload_to="branding/", diff --git a/apps/pages/admin.py b/apps/pages/admin.py index 3ad95d8..7d4c801 100644 --- a/apps/pages/admin.py +++ b/apps/pages/admin.py @@ -10,6 +10,7 @@ from .models import ( AboutSection, AboutSectionItem, BlogPost, + BlogPostPartner, ContactSubmission, ContactSubmissionAttachment, CustomPage, @@ -27,7 +28,7 @@ class HeroSectionAdmin(admin.ModelAdmin): fieldsets = ( ("Badge", {"fields": ("badge",)}), ("Title", {"fields": ("title", "title_highlight")}), - ("Text", {"fields": ("subtitle", "description")}), + ("Text", {"fields": ("subtitle", "read_more_subtitle", "description", "read_more_description")}), ("Primary Button", {"fields": ("primary_cta_text", "primary_cta_url")}), ("Secondary Button", {"fields": ("secondary_cta_text", "secondary_cta_url")}), ("Image", {"fields": ("image", "image_alt")}), @@ -51,7 +52,7 @@ class HeroSectionAdmin(admin.ModelAdmin): class HomepageSectionItemInline(admin.TabularInline): model = HomepageSectionItem extra = 1 - fields = ("icon", "title", "content", "url", "image", "order") + fields = ("icon", "title", "content", "read_more_content", "url", "image", "order") ordering = ("order",) @@ -63,7 +64,7 @@ class HomepageSectionAdmin(admin.ModelAdmin): inlines = [HomepageSectionItemInline] readonly_fields = ("video_preview",) fieldsets = ( - (None, {"fields": ("section_type", "badge", "title", "description_format", "description")}), + (None, {"fields": ("section_type", "badge", "title", "description_format", "description", "read_more_description")}), ("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}), VIDEO_ADMIN_FIELDSET, ("Settings", {"fields": ("order", "is_active")}), @@ -76,7 +77,7 @@ class HomepageSectionAdmin(admin.ModelAdmin): class AboutSectionItemInline(admin.TabularInline): model = AboutSectionItem extra = 1 - fields = ("icon", "title", "content", "url", "image", "image_alt", "badge", "is_featured", "order") + fields = ("icon", "title", "role", "content", "read_more_content", "url", "image", "image_alt", "badge", "is_featured", "order") ordering = ("order",) @@ -88,8 +89,8 @@ class AboutSectionAdmin(admin.ModelAdmin): inlines = [AboutSectionItemInline] readonly_fields = ("video_preview",) fieldsets = ( - (None, {"fields": ("section_type", "badge", "title", "subtitle")}), - ("Content", {"fields": ("content_format", "content")}), + (None, {"fields": ("section_type", "badge", "title", "subtitle", "read_more_subtitle")}), + ("Content", {"fields": ("content_format", "content", "read_more_content")}), VIDEO_ADMIN_FIELDSET, ("Settings", {"fields": ("order", "is_active")}), ) @@ -161,6 +162,7 @@ class CustomPageSectionItemInline(admin.TabularInline): "title", "content_format", "content", + "read_more_content", "url", "image", "image_alt", @@ -178,10 +180,13 @@ class CustomPageSectionInline(admin.StackedInline): "badge", "title", "subtitle", + "read_more_subtitle", "description_format", "description", + "read_more_description", "content_format", "content", + "read_more_content", "link_text", "link_url", "order", @@ -204,6 +209,7 @@ class CustomPageSectionItemOnPageInline(admin.TabularInline): "title", "content_format", "content", + "read_more_content", "url", "image", "order", @@ -225,8 +231,8 @@ class CustomPageSectionAdmin(admin.ModelAdmin): inlines = [CustomPageSectionItemInline] readonly_fields = ("video_preview",) fieldsets = ( - (None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}), - ("Text", {"fields": ("description_format", "description", "content_format", "content")}), + (None, {"fields": ("page", "section_type", "badge", "title", "subtitle", "read_more_subtitle")}), + ("Text", {"fields": ("description_format", "description", "read_more_description", "content_format", "content", "read_more_content")}), ("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}), VIDEO_ADMIN_FIELDSET, ("Settings", {"fields": ("order", "is_active")}), @@ -275,6 +281,12 @@ class CustomPageAdmin(admin.ModelAdmin): admin.site.register(CustomPageSection, CustomPageSectionAdmin) +class BlogPostPartnerInline(admin.TabularInline): + model = BlogPostPartner + extra = 1 + fields = ("role", "name", "description", "read_more_description", "image", "image_alt", "url", "order") + + @admin.register(BlogPost) class BlogPostAdmin(admin.ModelAdmin): list_display = ("title", "status", "published_at", "updated_at") @@ -282,9 +294,10 @@ class BlogPostAdmin(admin.ModelAdmin): search_fields = ("title", "excerpt", "content", "author_name") prepopulated_fields = {"slug": ("title",)} date_hierarchy = "published_at" + inlines = [BlogPostPartnerInline] fieldsets = ( - (None, {"fields": ("title", "slug", "excerpt")}), - ("Article", {"fields": ("content_format", "content")}), + (None, {"fields": ("title", "slug", "excerpt", "read_more_excerpt")}), + ("Article", {"fields": ("content_format", "content", "read_more_content")}), ("Cover image", {"fields": ("cover_image", "cover_image_alt")}), ("Publishing", {"fields": ("status", "published_at", "author_name")}), ) @@ -298,7 +311,7 @@ class PageVideoAdmin(admin.ModelAdmin): search_fields = ("title", "description", "video_url") readonly_fields = ("video_preview",) fieldsets = ( - (None, {"fields": ("page", "badge", "title", "description_format", "description")}), + (None, {"fields": ("page", "badge", "title", "description_format", "description", "read_more_description")}), VIDEO_ADMIN_FIELDSET, ("Settings", {"fields": ("order", "is_active")}), ) diff --git a/apps/pages/migrations/0020_blogpostpartner.py b/apps/pages/migrations/0020_blogpostpartner.py new file mode 100644 index 0000000..026ad59 --- /dev/null +++ b/apps/pages/migrations/0020_blogpostpartner.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.16 on 2026-10-12 04:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0019_blogpost_alter_aboutsection_video_styled_background_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='BlogPostPartner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('organizer', 'Organizer'), ('collaborator', 'Collaborator')], default='collaborator', max_length=20)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('image', models.ImageField(blank=True, null=True, upload_to='blog/partners/')), + ('image_alt', models.CharField(blank=True, max_length=240)), + ('url', models.URLField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='partners', to='pages.blogpost')), + ], + options={ + 'verbose_name': 'Article organizer or collaborator', + 'verbose_name_plural': 'Article organizers and collaborators', + 'ordering': ['role', 'order', 'pk'], + }, + ), + ] diff --git a/apps/pages/migrations/0021_alter_blogpostpartner_options.py b/apps/pages/migrations/0021_alter_blogpostpartner_options.py new file mode 100644 index 0000000..62c2795 --- /dev/null +++ b/apps/pages/migrations/0021_alter_blogpostpartner_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.16 on 2026-10-12 04:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0020_blogpostpartner'), + ] + + operations = [ + migrations.AlterModelOptions( + name='blogpostpartner', + options={'ordering': ['order', 'pk'], 'verbose_name': 'Article organizer or collaborator', 'verbose_name_plural': 'Article organizers and collaborators'}, + ), + ] diff --git a/apps/pages/migrations/0022_aboutsection_read_more_content_and_more.py b/apps/pages/migrations/0022_aboutsection_read_more_content_and_more.py new file mode 100644 index 0000000..fb4fb9f --- /dev/null +++ b/apps/pages/migrations/0022_aboutsection_read_more_content_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.16 on 2026-10-12 04:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0021_alter_blogpostpartner_options'), + ] + + operations = [ + migrations.AddField( + model_name='aboutsection', + name='read_more_content', + field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'), + ), + migrations.AddField( + model_name='aboutsectionitem', + name='read_more_content', + field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'), + ), + migrations.AddField( + model_name='blogpost', + name='read_more_content', + field=models.BooleanField(default=False, help_text='Collapse article text with a Read more button.'), + ), + migrations.AddField( + model_name='custompagesection', + name='read_more_content', + field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'), + ), + migrations.AddField( + model_name='custompagesection', + name='read_more_description', + field=models.BooleanField(default=False, help_text='Collapse this description with a Read more button.'), + ), + migrations.AddField( + model_name='custompagesectionitem', + name='read_more_content', + field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'), + ), + migrations.AddField( + model_name='homepagesection', + name='read_more_description', + field=models.BooleanField(default=False, help_text='Collapse this description with a Read more button.'), + ), + migrations.AddField( + model_name='homepagesectionitem', + name='read_more_content', + field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'), + ), + ] diff --git a/apps/pages/migrations/0023_alter_blogpostpartner_options_and_more.py b/apps/pages/migrations/0023_alter_blogpostpartner_options_and_more.py new file mode 100644 index 0000000..7cd0b08 --- /dev/null +++ b/apps/pages/migrations/0023_alter_blogpostpartner_options_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.16 on 2026-10-12 11:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0022_aboutsection_read_more_content_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='blogpostpartner', + options={'ordering': ['-role', 'order', 'pk'], 'verbose_name': 'Article organizer or collaborator', 'verbose_name_plural': 'Article organizers and collaborators'}, + ), + migrations.AlterField( + model_name='blogpostpartner', + name='order', + field=models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first within each role. Organizers always appear before collaborators.', verbose_name='Display order'), + ), + ] diff --git a/apps/pages/migrations/0024_aboutsection_read_more_subtitle_and_more.py b/apps/pages/migrations/0024_aboutsection_read_more_subtitle_and_more.py new file mode 100644 index 0000000..557a146 --- /dev/null +++ b/apps/pages/migrations/0024_aboutsection_read_more_subtitle_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.16 on 2026-10-12 11:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0023_alter_blogpostpartner_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='aboutsection', + name='read_more_subtitle', + field=models.BooleanField(default=False, help_text='Collapse a long hero subtitle with Read more.'), + ), + migrations.AddField( + model_name='blogpost', + name='partners_eyebrow', + field=models.CharField(blank=True, default='The people behind this article', max_length=160), + ), + migrations.AddField( + model_name='blogpost', + name='partners_title', + field=models.CharField(default='Organizers & collaborators', max_length=200), + ), + migrations.AddField( + model_name='blogpost', + name='read_more_excerpt', + field=models.BooleanField(default=False, help_text='Collapse a long excerpt with Read more on the listing and article page.'), + ), + migrations.AddField( + model_name='blogpostpartner', + name='read_more_description', + field=models.BooleanField(default=False, help_text="Collapse this person's description with Read more when it is long."), + ), + migrations.AddField( + model_name='custompagesection', + name='read_more_subtitle', + field=models.BooleanField(default=False, help_text='Collapse a long hero subtitle with Read more.'), + ), + migrations.AddField( + model_name='herosection', + name='read_more_description', + field=models.BooleanField(default=False, help_text='Collapse this description with Read more when it is long.'), + ), + migrations.AddField( + model_name='herosection', + name='read_more_subtitle', + field=models.BooleanField(default=False, help_text='Collapse the subtitle with Read more when it is long.'), + ), + migrations.AddField( + model_name='pagevideo', + name='read_more_description', + field=models.BooleanField(default=False, help_text='Collapse this video description with Read more.'), + ), + ] diff --git a/apps/pages/migrations/0025_alter_blogpostpartner_options_and_more.py b/apps/pages/migrations/0025_alter_blogpostpartner_options_and_more.py new file mode 100644 index 0000000..a4733c1 --- /dev/null +++ b/apps/pages/migrations/0025_alter_blogpostpartner_options_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.16 on 2026-10-12 11:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0024_aboutsection_read_more_subtitle_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='blogpostpartner', + options={'ordering': [models.Case(models.When(role__iexact='organizer', then=models.Value(0)), models.When(role__iexact='collaborator', then=models.Value(1)), default=models.Value(2), output_field=models.IntegerField()), 'order', 'pk'], 'verbose_name': 'Article organizer or collaborator', 'verbose_name_plural': 'Article organizers and collaborators'}, + ), + migrations.RemoveField( + model_name='blogpost', + name='partners_eyebrow', + ), + migrations.RemoveField( + model_name='blogpost', + name='partners_title', + ), + migrations.AlterField( + model_name='blogpostpartner', + name='order', + field=models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first. Organizers appear before collaborators, then custom roles.', verbose_name='Display order'), + ), + migrations.AlterField( + model_name='blogpostpartner', + name='role', + field=models.CharField(default='Collaborator', help_text='Type Organizer, Collaborator, or any other role name you want to display.', max_length=100, verbose_name='Role / group'), + ), + ] diff --git a/apps/pages/migrations/0026_alter_blogpostpartner_options.py b/apps/pages/migrations/0026_alter_blogpostpartner_options.py new file mode 100644 index 0000000..85ace41 --- /dev/null +++ b/apps/pages/migrations/0026_alter_blogpostpartner_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.16 on 2026-10-12 11:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0025_alter_blogpostpartner_options_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='blogpostpartner', + options={'ordering': [models.Case(models.When(role__iexact='organizer', then=models.Value(0)), models.When(role__iexact='collaborator', then=models.Value(1)), default=models.Value(2), output_field=models.IntegerField()), 'order', 'pk'], 'verbose_name': 'Article person', 'verbose_name_plural': 'Article people'}, + ), + ] diff --git a/apps/pages/migrations/0027_aboutsectionitem_role_and_more.py b/apps/pages/migrations/0027_aboutsectionitem_role_and_more.py new file mode 100644 index 0000000..1663f1b --- /dev/null +++ b/apps/pages/migrations/0027_aboutsectionitem_role_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.16 on 2026-10-14 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0026_alter_blogpostpartner_options'), + ] + + operations = [ + migrations.AddField( + model_name='aboutsectionitem', + name='role', + field=models.CharField(blank=True, help_text='For People sections: Organizer, Collaborator, or any custom role name.', max_length=100, verbose_name='Role / group'), + ), + migrations.AlterField( + model_name='aboutsection', + name='section_type', + field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters'), ('people', 'People / Organizers & Collaborators'), ('video', 'Video')], default='custom', max_length=30), + ), + ] diff --git a/apps/pages/models.py b/apps/pages/models.py index 7417c8d..e80a40a 100644 --- a/apps/pages/models.py +++ b/apps/pages/models.py @@ -30,7 +30,9 @@ class HeroSection(models.Model): title = models.CharField(max_length=300, blank=True, help_text="Main title line (e.g. 'Communication to Care,').") title_highlight = models.CharField(max_length=300, blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').") subtitle = models.CharField(max_length=500, blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').") + read_more_subtitle = models.BooleanField(default=False, help_text="Collapse the subtitle with Read more when it is long.") description = models.TextField(blank=True, help_text="Longer paragraph below the subtitle.") + read_more_description = models.BooleanField(default=False, help_text="Collapse this description with Read more when it is long.") primary_cta_text = models.CharField(max_length=100, blank=True, help_text="Primary button label.") primary_cta_url = models.CharField(max_length=300, blank=True, help_text="Primary button URL (relative or absolute).") secondary_cta_text = models.CharField(max_length=100, blank=True, help_text="Secondary (ghost) button label.") @@ -71,6 +73,7 @@ class HomepageSection(VideoBlockMixin, models.Model): badge = models.CharField(max_length=100, blank=True) title = models.CharField(max_length=300, blank=True) description = models.TextField(blank=True) + read_more_description = models.BooleanField(default=False, help_text="Collapse this description with a Read more button.") link_text = models.CharField(max_length=100, blank=True, help_text="CTA button label (About Strip).") link_url = models.CharField(max_length=300, blank=True, help_text="CTA button URL (About Strip).") order = models.PositiveIntegerField(default=0) @@ -95,6 +98,7 @@ class HomepageSectionItem(models.Model): icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).") title = models.CharField(max_length=300, blank=True) content = models.TextField(blank=True) + read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.") image = models.ImageField(upload_to="homepage/items/", blank=True, null=True, help_text="Logo or image (used for Supporters cards).") order = models.PositiveIntegerField(default=0) @@ -115,6 +119,7 @@ class AboutSection(VideoBlockMixin, models.Model): TYPE_HISTORY = "history" TYPE_CUSTOM = "custom" TYPE_SUPPORTERS = "supporters" + TYPE_PEOPLE = "people" TYPE_VIDEO = "video" TYPE_CHOICES = [ @@ -124,6 +129,7 @@ class AboutSection(VideoBlockMixin, models.Model): (TYPE_HISTORY, "History Block"), (TYPE_CUSTOM, "Custom Content"), (TYPE_SUPPORTERS, "Supporters"), + (TYPE_PEOPLE, "People / Organizers & Collaborators"), (TYPE_VIDEO, "Video"), ] @@ -131,7 +137,9 @@ class AboutSection(VideoBlockMixin, models.Model): badge = models.CharField(max_length=100, blank=True) title = models.CharField(max_length=300, blank=True) subtitle = models.CharField(max_length=500, blank=True, help_text="Used as subtitle in Hero and year in History.") + read_more_subtitle = models.BooleanField(default=False, help_text="Collapse a long hero subtitle with Read more.") content = models.TextField(blank=True) + read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN) order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) @@ -147,6 +155,15 @@ class AboutSection(VideoBlockMixin, models.Model): def rendered_content(self): return render_content(self.content, self.content_format) + @property + def ordered_people_items(self): + def sort_key(item): + role = item.role.strip().casefold() + priority = 0 if role == "organizer" else 1 if role == "collaborator" else 2 + return priority, item.order, item.pk or 0 + + return sorted(self.items.all(), key=sort_key) + def clean(self): super().clean() if self.section_type == self.TYPE_VIDEO: @@ -162,7 +179,14 @@ class AboutSectionItem(models.Model): icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).") badge = models.CharField(max_length=100, blank=True) title = models.CharField(max_length=300, blank=True) + role = models.CharField( + "Role / group", + max_length=100, + blank=True, + help_text="For People sections: Organizer, Collaborator, or any custom role name.", + ) content = models.TextField(blank=True, help_text="Description text or link label for History links.") + read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.") image = models.ImageField( upload_to="about/items/", @@ -182,6 +206,16 @@ class AboutSectionItem(models.Model): def __str__(self): return f"{self.section} › {self.title or self.icon or self.badge or '(item)'}" + @property + def name(self): + return self.title + + @property + def role_label(self): + if self.role.casefold() in {"organizer", "collaborator"}: + return self.role.title() + return self.role + class ContactSubmission(models.Model): name = models.CharField(max_length=200) @@ -329,7 +363,9 @@ class BlogPost(models.Model): blank=True, help_text="Short summary shown on the blog listing and in search previews.", ) + read_more_excerpt = models.BooleanField(default=False, help_text="Collapse a long excerpt with Read more on the listing and article page.") content = models.TextField() + read_more_content = models.BooleanField(default=False, help_text="Collapse article text with a Read more button.") content_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, @@ -370,6 +406,53 @@ class BlogPost(models.Model): return reverse("pages:blog_detail", kwargs={"slug": self.slug}) +class BlogPostPartner(models.Model): + ORGANIZER = "organizer" + COLLABORATOR = "collaborator" + + post = models.ForeignKey(BlogPost, on_delete=models.CASCADE, related_name="partners") + role = models.CharField( + "Role / group", + max_length=100, + default="Collaborator", + help_text="Type Organizer, Collaborator, or any other role name you want to display.", + ) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + read_more_description = models.BooleanField(default=False, help_text="Collapse this person's description with Read more when it is long.") + image = models.ImageField(upload_to="blog/partners/", blank=True, null=True) + image_alt = models.CharField(max_length=240, blank=True) + url = models.URLField(blank=True) + order = models.PositiveIntegerField( + "Display order", + default=0, + help_text="Smaller numbers appear first. Organizers appear before collaborators, then custom roles.", + ) + + class Meta: + ordering = [ + models.Case( + models.When(role__iexact="organizer", then=models.Value(0)), + models.When(role__iexact="collaborator", then=models.Value(1)), + default=models.Value(2), + output_field=models.IntegerField(), + ), + "order", + "pk", + ] + verbose_name = "Article person" + verbose_name_plural = "Article people" + + def __str__(self): + return f"{self.name} ({self.role_label})" + + @property + def role_label(self): + if self.role.casefold() in {self.ORGANIZER, self.COLLABORATOR}: + return self.role.title() + return self.role + + class CustomPageSection(VideoBlockMixin, models.Model): TYPE_HERO = "hero" TYPE_INTRO = "intro" @@ -406,8 +489,11 @@ class CustomPageSection(VideoBlockMixin, models.Model): badge = models.CharField(max_length=100, blank=True) title = models.CharField(max_length=300, blank=True) subtitle = models.CharField(max_length=500, blank=True) + read_more_subtitle = models.BooleanField(default=False, help_text="Collapse a long hero subtitle with Read more.") description = models.TextField(blank=True, help_text="Short intro text (homepage-style sections).") + read_more_description = models.BooleanField(default=False, help_text="Collapse this description with a Read more button.") content = models.TextField(blank=True) + read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN) link_text = models.CharField(max_length=100, blank=True) link_url = models.CharField(max_length=300, blank=True) @@ -444,6 +530,7 @@ class PageVideo(VideoBlockMixin, models.Model): badge = models.CharField(max_length=100, blank=True) title = models.CharField(max_length=300, blank=True) description = models.TextField(blank=True) + read_more_description = models.BooleanField(default=False, help_text="Collapse this video description with Read more.") order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) @@ -472,6 +559,7 @@ class CustomPageSectionItem(models.Model): badge = models.CharField(max_length=100, blank=True) title = models.CharField(max_length=300, blank=True) content = models.TextField(blank=True) + read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN) url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.") image = models.ImageField(upload_to="pages/custom/", blank=True, null=True) diff --git a/apps/pages/tests/test_views.py b/apps/pages/tests/test_views.py index cf2b531..6ba2f8b 100644 --- a/apps/pages/tests/test_views.py +++ b/apps/pages/tests/test_views.py @@ -2,7 +2,8 @@ from django.test import TestCase from django.urls import reverse from django.utils import timezone -from apps.pages.models import BlogPost, HomepageSection +from apps.core.models import SiteBranding +from apps.pages.models import AboutSection, AboutSectionItem, BlogPost, BlogPostPartner, HomepageSection class HomeViewTest(TestCase): @@ -34,6 +35,28 @@ class AboutViewTest(TestCase): response = self.client.get(reverse("pages:about")) self.assertTemplateUsed(response, "pages/about.html") + def test_people_section_orders_roles_and_controls_read_more_per_text(self): + section = AboutSection.objects.create( + section_type=AboutSection.TYPE_PEOPLE, + badge="Our team", title="People behind the work", + content="Section introduction", read_more_content=True, + ) + AboutSectionItem.objects.create(section=section, title="Advisor", role="Scientific advisor", content="Advisor profile", order=1) + AboutSectionItem.objects.create(section=section, title="Collaborator", role="Collaborator", content="Collaboration profile", read_more_content=True, order=3) + AboutSectionItem.objects.create(section=section, title="Organizer", role="Organizer", content="Organizer profile", order=9) + html = self.client.get(reverse("pages:about")).content.decode() + self.assertIn("People behind the work", html) + self.assertIn("Scientific advisor", html) + self.assertLess(html.index(">Organizer"), html.index(">Collaborator")) + self.assertLess(html.index(">Collaborator"), html.index(">Advisor")) + self.assertIn('data-readmore="200"', html) + self.assertEqual(html.count('data-readmore="88"'), 1) + + def test_empty_people_section_is_hidden(self): + AboutSection.objects.create(section_type=AboutSection.TYPE_PEOPLE, title="Nobody yet") + response = self.client.get(reverse("pages:about")) + self.assertNotContains(response, "Nobody yet") + class BlogViewTest(TestCase): def setUp(self): @@ -53,6 +76,8 @@ class BlogViewTest(TestCase): self.assertContains(listing, self.post.title) self.assertContains(detail, "

Results

") self.assertContains(detail, "Promising") + self.assertNotContains(detail, "blog-partners") + self.assertNotContains(detail, "blog-detail-layout--with-partners") def test_draft_post_is_not_public(self): self.post.status = BlogPost.STATUS_DRAFT @@ -60,6 +85,76 @@ class BlogViewTest(TestCase): self.assertNotContains(self.client.get(reverse("pages:blog_list")), self.post.title) self.assertEqual(self.client.get(self.post.get_absolute_url()).status_code, 404) + def test_article_partners_render_with_roles_and_optional_details(self): + BlogPostPartner.objects.create( + post=self.post, role=BlogPostPartner.ORGANIZER, name="Research Group", + description="Project lead", image="blog/partners/group.png", + image_alt="Research Group logo", url="https://example.org", + ) + BlogPostPartner.objects.create( + post=self.post, role=BlogPostPartner.COLLABORATOR, name="Care Team" + ) + detail = self.client.get(self.post.get_absolute_url()) + self.assertContains(detail, "Research Group") + self.assertContains(detail, "Collaborator") + self.assertContains(detail, "Research Group logo") + self.assertContains(detail, "https://example.org") + self.assertContains(detail, "Care Team") + + def test_blog_read_more_is_opt_in(self): + detail = self.client.get(self.post.get_absolute_url()) + self.assertNotContains(detail, 'data-readmore="600"') + self.post.read_more_content = True + self.post.save(update_fields=["read_more_content"]) + detail = self.client.get(self.post.get_absolute_url()) + self.assertContains(detail, 'data-readmore="600"') + + def test_partners_follow_article_and_sort_by_role_then_number(self): + BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.COLLABORATOR, name="Second collaborator", order=2) + BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.COLLABORATOR, name="First collaborator", order=1) + BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.ORGANIZER, name="Organizer", order=9) + html = self.client.get(self.post.get_absolute_url()).content.decode() + self.assertLess(html.index('class="blog-article rich-content'), html.index('class="blog-partners"')) + self.assertLess(html.index('>Organizer'), html.index('>First collaborator')) + self.assertLess(html.index('>First collaborator'), html.index('>Second collaborator')) + + def test_partner_headings_are_shared_and_description_read_more_is_independent(self): + branding = SiteBranding.load() + branding.article_people_eyebrow = "Our team" + branding.article_people_title = "Project contributors" + branding.save(update_fields=["article_people_eyebrow", "article_people_title"]) + partner = BlogPostPartner.objects.create( + post=self.post, role=BlogPostPartner.ORGANIZER, + name="Coordinator", description="A detailed profile", read_more_description=True, + ) + html = self.client.get(self.post.get_absolute_url()).content.decode() + self.assertIn("Our team", html) + self.assertIn("Project contributors", html) + self.assertNotIn("The people behind this article", html) + self.assertIn('data-readmore="88"', html) + partner.read_more_description = False + partner.save(update_fields=["read_more_description"]) + html = self.client.get(self.post.get_absolute_url()).content.decode() + self.assertNotIn('data-readmore="88"', html) + + other_post = BlogPost.objects.create( + title="Another update", slug="another-update", content="Another article", + status=BlogPost.STATUS_PUBLISHED, published_at=timezone.now(), + ) + BlogPostPartner.objects.create(post=other_post, name="Contributor") + other_html = self.client.get(other_post.get_absolute_url()).content.decode() + self.assertIn("Our team", other_html) + self.assertIn("Project contributors", other_html) + + def test_custom_role_can_be_named_by_admin(self): + BlogPostPartner.objects.create(post=self.post, role="Scientific advisor", name="Advisor", order=0) + BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.COLLABORATOR, name="Partner", order=5) + BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.ORGANIZER, name="Lead", order=9) + html = self.client.get(self.post.get_absolute_url()).content.decode() + self.assertIn("Scientific advisor", html) + self.assertLess(html.index(">Lead"), html.index(">Partner")) + self.assertLess(html.index(">Partner"), html.index(">Advisor")) + class ContactViewTest(TestCase): def test_contact_returns_200(self): diff --git a/apps/pages/views.py b/apps/pages/views.py index bd56ea3..7d2837c 100644 --- a/apps/pages/views.py +++ b/apps/pages/views.py @@ -184,7 +184,7 @@ class BlogPostListView(ListView): return BlogPost.objects.filter( status=BlogPost.STATUS_PUBLISHED, published_at__lte=timezone.now(), - ) + ).prefetch_related("partners") class BlogPostDetailView(DetailView): @@ -196,7 +196,7 @@ class BlogPostDetailView(DetailView): return BlogPost.objects.filter( status=BlogPost.STATUS_PUBLISHED, published_at__lte=timezone.now(), - ) + ).prefetch_related("partners") class PageVideoArchiveView(ListView): diff --git a/apps/products/admin.py b/apps/products/admin.py index 7b65bba..9760233 100644 --- a/apps/products/admin.py +++ b/apps/products/admin.py @@ -9,14 +9,14 @@ from .models import Article, ArticleCitation, ArticleSection, MainProduct, Produ class ArticleCitationInline(admin.TabularInline): model = ArticleCitation extra = 1 - fields = ("text", "url", "order") + fields = ("text", "read_more_text", "url", "order") ordering = ("order",) class ArticleSectionInline(admin.TabularInline): model = ArticleSection extra = 1 - fields = ("title", "value_format", "value", "order") + fields = ("title", "value_format", "value", "read_more_value", "order") ordering = ("order",) @@ -24,7 +24,7 @@ class SubProductArticleInline(admin.StackedInline): model = Article fk_name = "sub_product" extra = 0 - fields = ("badge", "title", "description", "order") + fields = ("badge", "title", "description", "read_more_description", "order") ordering = ("order",) show_change_link = True @@ -33,7 +33,7 @@ class MainProductArticleInline(admin.StackedInline): model = Article fk_name = "main_product" extra = 0 - fields = ("badge", "title", "description", "order") + fields = ("badge", "title", "description", "read_more_description", "order") ordering = ("order",) show_change_link = True @@ -47,6 +47,7 @@ class ProductVideoInline(admin.StackedInline): "title", "description_format", "description", + "read_more_description", "video_source", "video_url", "video_file", @@ -63,7 +64,7 @@ class ProductVideoInline(admin.StackedInline): class SubProductInline(admin.StackedInline): model = SubProduct extra = 0 - fields = ("name", "slug", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active") + fields = ("name", "slug", "short_description", "read_more_short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active") ordering = ("order",) show_change_link = True prepopulated_fields = {"slug": ("name",)} @@ -85,10 +86,11 @@ class MainProductAdmin(admin.ModelAdmin): "name", "slug", "short_description", + "read_more_short_description", ) }, ), - ("Description", {"fields": ("description_format", "description")}), + ("Description", {"fields": ("description_format", "description", "read_more_description")}), ("Media", {"fields": ("image",)}), ("Homepage", {"fields": ("show_on_homepage", "homepage_order")}), ("Settings", {"fields": ("order", "is_active")}), @@ -104,6 +106,7 @@ class SubProductVideoInline(admin.StackedInline): "title", "description_format", "description", + "read_more_description", "video_source", "video_url", "video_file", @@ -143,10 +146,11 @@ class SubProductAdmin(admin.ModelAdmin): "name", "slug", "short_description", + "read_more_short_description", ) }, ), - ("Description", {"fields": ("description_format", "description")}), + ("Description", {"fields": ("description_format", "description", "read_more_description")}), ("Media", {"fields": ("image", "logo")}), ("Homepage", {"fields": ("show_on_homepage", "homepage_order")}), ("Settings", {"fields": ("order", "is_active")}), @@ -163,7 +167,7 @@ class ArticleAdmin(admin.ModelAdmin): inlines = [ArticleSectionInline, ArticleCitationInline] fieldsets = ( (None, {"fields": ("main_product", "sub_product", "badge", "title")}), - ("Description", {"fields": ("description_format", "description")}), + ("Description", {"fields": ("description_format", "description", "read_more_description")}), ("Citation Badge", {"fields": ("citation_count_display", "citation_count_label"), "description": "Number and optional label for the dashed citation circle badge. Both are optional — a label without a number shows an empty circle with the label; neither hides the badge entirely."}), ("Settings", {"fields": ("order",)}), ) @@ -186,7 +190,7 @@ class ProductVideoAdmin(admin.ModelAdmin): raw_id_fields = ("main_product", "sub_product") readonly_fields = ("video_preview",) fieldsets = ( - (None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description")}), + (None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description", "read_more_description")}), VIDEO_ADMIN_FIELDSET, ("Settings", {"fields": ("order", "is_active")}), ) @@ -212,6 +216,6 @@ class ArticleSectionAdmin(admin.ModelAdmin): raw_id_fields = ("article",) fieldsets = ( (None, {"fields": ("article", "title")}), - ("Content", {"fields": ("value_format", "value")}), + ("Content", {"fields": ("value_format", "value", "read_more_value")}), ("Settings", {"fields": ("order",)}), ) diff --git a/apps/products/migrations/0023_article_read_more_description_and_more.py b/apps/products/migrations/0023_article_read_more_description_and_more.py new file mode 100644 index 0000000..658a6b3 --- /dev/null +++ b/apps/products/migrations/0023_article_read_more_description_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.16 on 2026-10-12 04:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0022_alter_mainproduct_options_alter_productvideo_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='read_more_description', + field=models.BooleanField(default=False, help_text='Collapse this article text with Read more.'), + ), + migrations.AddField( + model_name='articlesection', + name='read_more_value', + field=models.BooleanField(default=False, help_text='Collapse this section text with Read more.'), + ), + migrations.AddField( + model_name='mainproduct', + name='read_more_description', + field=models.BooleanField(default=False, help_text='Collapse the full description with Read more.'), + ), + migrations.AddField( + model_name='mainproduct', + name='read_more_short_description', + field=models.BooleanField(default=False, help_text='Collapse the short description with Read more.'), + ), + migrations.AddField( + model_name='subproduct', + name='read_more_description', + field=models.BooleanField(default=False, help_text='Collapse the full description with Read more.'), + ), + migrations.AddField( + model_name='subproduct', + name='read_more_short_description', + field=models.BooleanField(default=False, help_text='Collapse the short description with Read more.'), + ), + ] diff --git a/apps/products/migrations/0024_articlecitation_read_more_text_and_more.py b/apps/products/migrations/0024_articlecitation_read_more_text_and_more.py new file mode 100644 index 0000000..38da46d --- /dev/null +++ b/apps/products/migrations/0024_articlecitation_read_more_text_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.16 on 2026-10-12 11:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0023_article_read_more_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='articlecitation', + name='read_more_text', + field=models.BooleanField(default=False, help_text='Collapse a long citation with Read more.'), + ), + migrations.AddField( + model_name='productvideo', + name='read_more_description', + field=models.BooleanField(default=False, help_text='Collapse this video description with Read more.'), + ), + ] diff --git a/apps/products/models.py b/apps/products/models.py index 82b7013..53e426b 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -63,7 +63,9 @@ class MainProduct(models.Model): name = models.CharField(max_length=200) slug = models.SlugField(unique=True, blank=True) short_description = models.CharField(max_length=300) + read_more_short_description = models.BooleanField(default=False, help_text="Collapse the short description with Read more.") description = models.TextField() + read_more_description = models.BooleanField(default=False, help_text="Collapse the full description with Read more.") description_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN ) @@ -104,7 +106,9 @@ class SubProduct(models.Model): name = models.CharField(max_length=200) slug = models.SlugField(blank=True) short_description = models.CharField(max_length=300) + read_more_short_description = models.BooleanField(default=False, help_text="Collapse the short description with Read more.") description = models.TextField() + read_more_description = models.BooleanField(default=False, help_text="Collapse the full description with Read more.") description_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN ) @@ -166,6 +170,7 @@ class Article(models.Model): ) title = models.CharField(max_length=300) description = models.TextField() + read_more_description = models.BooleanField(default=False, help_text="Collapse this article text with Read more.") description_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN ) @@ -433,6 +438,7 @@ class ArticleSection(models.Model): ) title = models.CharField(max_length=200) value = models.TextField() + read_more_value = models.BooleanField(default=False, help_text="Collapse this section text with Read more.") value_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN ) @@ -458,6 +464,7 @@ class ArticleCitation(models.Model): related_name="citations", ) text = models.TextField(help_text="Full citation text.") + read_more_text = models.BooleanField(default=False, help_text="Collapse a long citation with Read more.") url = models.URLField(blank=True, help_text="Optional link to the cited source.") order = models.PositiveIntegerField(default=0) @@ -488,6 +495,7 @@ class ProductVideo(VideoBlockMixin, models.Model): badge = models.CharField(max_length=100, blank=True) title = models.CharField(max_length=300, blank=True) description = models.TextField(blank=True) + read_more_description = models.BooleanField(default=False, help_text="Collapse this video description with Read more.") order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py index c6ab259..d1d1097 100644 --- a/apps/products/tests/test_views.py +++ b/apps/products/tests/test_views.py @@ -56,6 +56,21 @@ class ProductOverviewViewTest(ProductViewsSetup): class MainProductDetailViewTest(ProductViewsSetup): + def test_read_more_can_be_set_per_article_and_section(self): + url = self.main_product.get_absolute_url() + article = Article.objects.create(main_product=self.main_product, title="Notes", description="Long notes") + section = ArticleSection.objects.create(article=article, title="Detail", value="Long detail") + response = self.client.get(url) + self.assertNotContains(response, 'data-readmore="260"') + self.assertNotContains(response, 'data-readmore="160"') + article.read_more_description = True + article.save(update_fields=["read_more_description"]) + section.read_more_value = True + section.save(update_fields=["read_more_value"]) + response = self.client.get(url) + self.assertContains(response, 'data-readmore="260"') + self.assertContains(response, 'data-readmore="160"') + def test_detail_returns_200(self): url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"}) response = self.client.get(url) diff --git a/static/css/main.css b/static/css/main.css index 0a7431b..b3a070d 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -112,9 +112,27 @@ body::before { .blog-card-title a { color: var(--text-primary); } .blog-card-excerpt { color: var(--text-secondary); margin-bottom: var(--spacing-sm); } .blog-detail-header { max-width: 900px; } -.blog-article-wrap { max-width: 900px; } -.blog-cover { width: 100%; max-height: 520px; object-fit: cover; border-radius: var(--radius-lg); margin-bottom: var(--spacing-md); } -.blog-article { padding: clamp(1.5rem, 4vw, 3rem); } +.blog-article-wrap { max-width: 1200px; } +.blog-cover { width: 100%; max-width: 900px; max-height: 520px; object-fit: cover; border-radius: var(--radius-lg); margin: 0 auto var(--spacing-md); } +.blog-article { max-width: 900px; margin-inline: auto; padding: clamp(1.5rem, 4vw, 3rem); } +.blog-partners { min-width: 0; margin-top: clamp(2.5rem, 5vw, 4rem); } +.blog-partners--about { margin-top: 0; } +.blog-partners-heading { padding: .3rem .15rem 1.25rem; } +.blog-partners-eyebrow { display: block; color: var(--accent-blue-light); font-size: .65rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.blog-partners-heading h2 { margin-top: .45rem; font-size: clamp(1.2rem, 2vw, 1.5rem); line-height: 1.35; } +.blog-partners-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1rem; } +.blog-partner-card { display: flex; flex-direction: column; gap: .85rem; padding: .8rem; min-width: 0; } +.blog-partner-image { width: 100%; aspect-ratio: 1 / 1; border-radius: 12px; background: rgba(255,255,255,.08); overflow: hidden; display: grid; place-items: center; } +.blog-partner-image img { width: 100%; height: 100%; object-fit: cover; object-position: center 25%; } +.blog-partner-info { min-width: 0; } +.blog-partner-role { display: block; color: var(--accent-blue-light); font-size: .62rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; margin-bottom: .35rem; } +.blog-partner-info h3 { font-size: .9rem; line-height: 1.35; margin: 0 0 .4rem; } +.blog-partner-info h3 a { color: inherit; } +.blog-partner-info h3 a:hover { color: var(--accent-blue-light); } +.blog-partner-info p { color: var(--text-secondary); font-size: .76rem; line-height: 1.55; margin: 0; } +@media (max-width: 950px) { .blog-partners-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 620px) { .blog-partners-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 380px) { .blog-partners-grid { grid-template-columns: 1fr; } } .pagination { display: flex; align-items: center; justify-content: center; gap: var(--spacing-sm); margin-top: var(--spacing-xl); color: var(--text-secondary); } #main-content { @@ -1438,6 +1456,7 @@ a.citation-count-badge:hover { .citation-text { flex: 1; } +.citation-text.readmore-wrap { display: block; min-width: 0; } .citation-actions { flex-shrink: 0; diff --git a/static/js/main.js b/static/js/main.js index edbfcfe..86eb3fc 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -237,7 +237,9 @@ function initReadMore() { btn.innerHTML = 'Read more ' + chevronSVG; el.insertAdjacentElement('afterend', btn); - btn.addEventListener('click', function () { + btn.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); var collapsed = el.classList.toggle('is-clamped'); if (collapsed) { el.style.maxHeight = threshold + 'px'; diff --git a/templates/pages/blog_detail.html b/templates/pages/blog_detail.html index 68eb82e..3d99890 100644 --- a/templates/pages/blog_detail.html +++ b/templates/pages/blog_detail.html @@ -19,13 +19,28 @@

{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}

{{ post.title }}

- {% if post.excerpt %}

{{ post.excerpt }}

{% endif %} + {% if post.excerpt %}

{{ post.excerpt }}

{% endif %}
{% if post.cover_image %}{{ post.cover_image_alt|default:post.title }}{% endif %} -
{{ post.rendered_content }}
+
{{ post.rendered_content }}
+ {% with partners=post.partners.all %} + {% if partners %} +
+
+ {% if site_branding.article_people_eyebrow %}{{ site_branding.article_people_eyebrow }}{% endif %} +

{{ site_branding.article_people_title }}

+
+
+ {% for partner in partners %} + {% include "partials/_person_card.html" with person=partner body=partner.description read_more=partner.read_more_description %} + {% endfor %} +
+
+ {% endif %} + {% endwith %}
diff --git a/templates/pages/blog_list.html b/templates/pages/blog_list.html index b1a1af4..5c4b683 100644 --- a/templates/pages/blog_list.html +++ b/templates/pages/blog_list.html @@ -32,7 +32,7 @@

{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}

{{ post.title }}

- {% if post.excerpt %}

{{ post.excerpt }}

{% endif %} + {% if post.excerpt %}

{{ post.excerpt }}

{% endif %} Read article
diff --git a/templates/pages/home.html b/templates/pages/home.html index e2e77e1..058ebff 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -25,8 +25,8 @@ {% if hero.title_highlight %}{{ hero.title_highlight }}{% endif %} {% endif %} - {% if hero.subtitle %}

{{ hero.subtitle }}

{% endif %} - {% if hero.description %}

{{ hero.description }}

{% endif %} + {% if hero.subtitle %}

{{ hero.subtitle }}

{% endif %} + {% if hero.description %}

{{ hero.description }}

{% endif %} {% if hero.primary_cta_text or hero.secondary_cta_text %}
{% if hero.primary_cta_text %}{{ hero.primary_cta_text }}{% endif %} diff --git a/templates/partials/_page_sections.html b/templates/partials/_page_sections.html index 1e3f21b..92a4e79 100644 --- a/templates/partials/_page_sections.html +++ b/templates/partials/_page_sections.html @@ -14,8 +14,8 @@
{% if section.badge %}
{{ section.badge }}
{% endif %} {% if section.title %}

{{ section.title }}

{% endif %} - {% if section.subtitle %}

{{ section.subtitle }}

{% endif %} - {% if section.content %}
{{ section.rendered_content }}
{% endif %} + {% if section.subtitle %}

{{ section.subtitle }}

{% endif %} + {% if section.content %}
{{ section.rendered_content }}
{% endif %}
{% with items=section.items.all %} {% if items %} @@ -44,7 +44,7 @@
{% if section.title %}

{{ section.title }}

{% endif %} {% if section.content %} -
{{ section.rendered_content }}
+
{{ section.rendered_content }}
{% endif %}
@@ -73,7 +73,7 @@ {% if section.title %}

{{ section.title }}

{% endif %} {% endif %} - {% if section.content %}
{{ section.rendered_content }}
{% endif %} + {% if section.content %}
{{ section.rendered_content }}
{% endif %} {% with items=section.items.all %} {% if items %}
@@ -98,7 +98,7 @@
{% if section.badge %}
{{ section.badge }}
{% endif %} {% if section.title %}

{{ section.title }}

{% endif %} - {% if section.content %}
{{ section.rendered_content }}
{% endif %} + {% if section.content %}
{{ section.rendered_content }}
{% endif %} {% if links %}