From 7cddc03a57b7a2e50fc42782445becf107473257 Mon Sep 17 00:00:00 2001 From: mohamad Date: Mon, 8 Jun 2026 17:08:43 +0330 Subject: [PATCH] change release section put video and more --- apps/core/admin_video.py | 30 ++ apps/core/video.py | 172 +++++++++++ apps/pages/admin.py | 45 ++- apps/pages/migrations/0015_video_support.py | 141 +++++++++ .../0016_video_styled_background.py | 33 ++ .../0017_video_description_format.py | 33 ++ apps/pages/models.py | 58 +++- apps/pages/tests/test_video.py | 187 ++++++++++++ apps/pages/views.py | 18 +- apps/products/admin.py | 103 ++++++- .../products/migrations/0018_video_support.py | 39 +++ .../0019_video_styled_background.py | 18 ++ .../0020_video_description_format.py | 18 ++ .../migrations/0021_release_channels.py | 70 +++++ apps/products/models.py | 126 +++++++- apps/products/release_context.py | 79 +++-- apps/products/tests/test_models.py | 23 ++ apps/products/tests/test_views.py | 50 +++- apps/products/views.py | 4 +- static/css/main.css | 281 +++++++++++++++++- templates/pages/contact.html | 2 + templates/pages/faq.html | 2 + templates/partials/_page_sections.html | 3 + templates/partials/_page_videos.html | 15 + templates/partials/_video_block.html | 55 ++++ templates/partials/_video_player.html | 26 ++ templates/partials/_video_section.html | 7 + .../_installable_download_channel.html | 34 +++ .../products/_release_channel_badge.html | 10 + templates/products/_releases_section.html | 54 +--- .../products/_versions_archive_body.html | 10 +- templates/products/main_detail.html | 2 + templates/products/sub_detail.html | 2 + 33 files changed, 1657 insertions(+), 93 deletions(-) create mode 100644 apps/core/admin_video.py create mode 100644 apps/core/video.py create mode 100644 apps/pages/migrations/0015_video_support.py create mode 100644 apps/pages/migrations/0016_video_styled_background.py create mode 100644 apps/pages/migrations/0017_video_description_format.py create mode 100644 apps/pages/tests/test_video.py create mode 100644 apps/products/migrations/0018_video_support.py create mode 100644 apps/products/migrations/0019_video_styled_background.py create mode 100644 apps/products/migrations/0020_video_description_format.py create mode 100644 apps/products/migrations/0021_release_channels.py create mode 100644 templates/partials/_page_videos.html create mode 100644 templates/partials/_video_block.html create mode 100644 templates/partials/_video_player.html create mode 100644 templates/partials/_video_section.html create mode 100644 templates/products/_installable_download_channel.html create mode 100644 templates/products/_release_channel_badge.html diff --git a/apps/core/admin_video.py b/apps/core/admin_video.py new file mode 100644 index 0000000..2eed616 --- /dev/null +++ b/apps/core/admin_video.py @@ -0,0 +1,30 @@ +from django.utils.html import format_html + +from apps.core.video import VIDEO_SOURCE_UPLOAD, VIDEO_SOURCE_YOUTUBE + + +def video_admin_preview(obj): + if not obj.has_video: + return "No video configured yet." + if obj.video_source == VIDEO_SOURCE_UPLOAD and obj.video_file: + if obj.video_poster: + return format_html( + '', + obj.video_poster.url, + obj.video_file.url, + ) + return format_html( + '', + obj.video_file.url, + ) + if obj.video_source == VIDEO_SOURCE_YOUTUBE and obj.youtube_embed_url: + return format_html( + '', + obj.youtube_embed_url, + ) + return "No video configured yet." + +video_admin_preview.short_description = "Preview" diff --git a/apps/core/video.py b/apps/core/video.py new file mode 100644 index 0000000..ea0305d --- /dev/null +++ b/apps/core/video.py @@ -0,0 +1,172 @@ +import re + +from django.core.exceptions import ValidationError +from django.db import models + +from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content + +VIDEO_SOURCE_YOUTUBE = "youtube" +VIDEO_SOURCE_UPLOAD = "upload" + +VIDEO_SOURCE_CHOICES = [ + (VIDEO_SOURCE_YOUTUBE, "YouTube / external link"), + (VIDEO_SOURCE_UPLOAD, "Uploaded file"), +] + +VIDEO_SIZE_SMALL = "sm" +VIDEO_SIZE_MEDIUM = "md" +VIDEO_SIZE_LARGE = "lg" +VIDEO_SIZE_FULL = "full" + +VIDEO_SIZE_CHOICES = [ + (VIDEO_SIZE_SMALL, "Small (480px)"), + (VIDEO_SIZE_MEDIUM, "Medium (720px)"), + (VIDEO_SIZE_LARGE, "Large (960px)"), + (VIDEO_SIZE_FULL, "Full width"), +] + +VIDEO_ASPECT_16_9 = "16/9" +VIDEO_ASPECT_4_3 = "4/3" +VIDEO_ASPECT_1_1 = "1/1" + +VIDEO_ASPECT_CHOICES = [ + (VIDEO_ASPECT_16_9, "16:9 (widescreen)"), + (VIDEO_ASPECT_4_3, "4:3 (standard)"), + (VIDEO_ASPECT_1_1, "1:1 (square)"), +] + +VIDEO_UPLOAD_EXTENSIONS = frozenset({".mp4", ".webm", ".ogg", ".mov"}) + +VIDEO_ADMIN_FIELDS = ( + "video_source", + "video_url", + "video_file", + "video_poster", + "video_size", + "video_aspect_ratio", + "video_styled_background", +) + +VIDEO_ADMIN_FIELDSET = ( + "Video", + { + "fields": VIDEO_ADMIN_FIELDS + ("video_preview",), + "description": ( + "Choose YouTube link or uploaded file. Set display size and aspect ratio " + "to control how the preview appears on the site." + ), + }, +) + +YOUTUBE_ID_PATTERNS = ( + re.compile(r"(?:youtube\.com/watch\?(?:[^&]+&)*v=|youtube\.com/embed/|youtube\.com/shorts/|youtu\.be/)([\w-]{11})"), + re.compile(r"^([\w-]{11})$"), +) + + +def parse_youtube_video_id(url): + if not url: + return "" + value = url.strip() + for pattern in YOUTUBE_ID_PATTERNS: + match = pattern.search(value) + if match: + return match.group(1) + return "" + + +def youtube_embed_url(url): + video_id = parse_youtube_video_id(url) + if not video_id: + return "" + return f"https://www.youtube-nocookie.com/embed/{video_id}" + + +class VideoBlockMixin(models.Model): + video_source = models.CharField( + max_length=20, + choices=VIDEO_SOURCE_CHOICES, + default=VIDEO_SOURCE_YOUTUBE, + blank=True, + ) + video_url = models.CharField( + max_length=500, + blank=True, + help_text="YouTube watch, embed, or youtu.be link.", + ) + video_file = models.FileField( + upload_to="videos/", + blank=True, + help_text="MP4, WebM, OGG, or MOV file.", + ) + video_poster = models.ImageField( + upload_to="videos/posters/", + blank=True, + null=True, + help_text="Optional thumbnail shown before an uploaded video plays.", + ) + video_size = models.CharField( + max_length=10, + choices=VIDEO_SIZE_CHOICES, + default=VIDEO_SIZE_MEDIUM, + ) + video_aspect_ratio = models.CharField( + max_length=10, + choices=VIDEO_ASPECT_CHOICES, + default=VIDEO_ASPECT_16_9, + ) + video_styled_background = models.BooleanField( + default=False, + help_text="Glass panel with ambient glow (similar to the Downloads section).", + ) + description_format = models.CharField( + max_length=20, + choices=CONTENT_FORMAT_CHOICES, + default=FORMAT_PLAIN, + ) + + class Meta: + abstract = True + + @property + def youtube_embed_url(self): + return youtube_embed_url(self.video_url) + + @property + def has_video(self): + if self.video_source == VIDEO_SOURCE_UPLOAD: + return bool(self.video_file) + return bool(self.youtube_embed_url) + + @property + def video_size_class(self): + return f"video-block--{self.video_size or VIDEO_SIZE_MEDIUM}" + + @property + def video_aspect_class(self): + ratio = (self.video_aspect_ratio or VIDEO_ASPECT_16_9).replace("/", "-") + return f"video-block--ratio-{ratio}" + + @property + def rendered_description(self): + description = getattr(self, "description", "") or "" + return render_content(description, self.description_format) + + def clean_video_fields(self, require=False): + if not require and not self.video_url and not self.video_file: + return + if self.video_source == VIDEO_SOURCE_YOUTUBE: + if not self.video_url.strip(): + raise ValidationError({"video_url": "Enter a YouTube link."}) + if not self.youtube_embed_url: + raise ValidationError({"video_url": "Enter a valid YouTube link."}) + elif self.video_source == VIDEO_SOURCE_UPLOAD: + if not self.video_file: + raise ValidationError({"video_file": "Upload a video file."}) + extension = self.video_file.name.rsplit(".", 1)[-1].lower() if self.video_file.name else "" + if f".{extension}" not in VIDEO_UPLOAD_EXTENSIONS: + raise ValidationError( + { + "video_file": "Unsupported format. Use MP4, WebM, OGG, or MOV.", + } + ) diff --git a/apps/pages/admin.py b/apps/pages/admin.py index 355bd69..1106e98 100644 --- a/apps/pages/admin.py +++ b/apps/pages/admin.py @@ -3,6 +3,9 @@ from django.http import FileResponse, Http404, HttpResponseRedirect from django.urls import path, reverse from django.utils.html import format_html +from apps.core.admin_video import video_admin_preview +from apps.core.video import VIDEO_ADMIN_FIELDSET + from .models import ( AboutSection, AboutSectionItem, @@ -16,6 +19,7 @@ from .models import ( HeroSection, HomepageSection, HomepageSectionItem, + PageVideo, ) @@ -58,12 +62,18 @@ class HomepageSectionAdmin(admin.ModelAdmin): list_filter = ("section_type", "is_active") list_editable = ("order", "is_active") inlines = [HomepageSectionItemInline] + readonly_fields = ("video_preview",) fieldsets = ( - (None, {"fields": ("section_type", "badge", "title", "description")}), + (None, {"fields": ("section_type", "badge", "title", "description_format", "description")}), ("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}), + VIDEO_ADMIN_FIELDSET, ("Settings", {"fields": ("order", "is_active")}), ) + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + class AboutSectionItemInline(admin.TabularInline): model = AboutSectionItem @@ -78,12 +88,18 @@ class AboutSectionAdmin(admin.ModelAdmin): list_filter = ("section_type", "is_active") list_editable = ("order", "is_active") inlines = [AboutSectionItemInline] + readonly_fields = ("video_preview",) fieldsets = ( (None, {"fields": ("section_type", "badge", "title", "subtitle")}), ("Content", {"fields": ("content_format", "content")}), + VIDEO_ADMIN_FIELDSET, ("Settings", {"fields": ("order", "is_active")}), ) + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + class ContactSubmissionAttachmentInline(admin.TabularInline): model = ContactSubmissionAttachment @@ -177,6 +193,7 @@ class CustomPageSectionInline(admin.StackedInline): "badge", "title", "subtitle", + "description_format", "description", "content_format", "content", @@ -221,13 +238,19 @@ class CustomPageSectionAdmin(admin.ModelAdmin): list_filter = ("section_type", "is_active", "page") list_editable = ("order", "is_active") inlines = [CustomPageSectionItemInline] + readonly_fields = ("video_preview",) fieldsets = ( (None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}), - ("Text", {"fields": ("description", "content_format", "content")}), + ("Text", {"fields": ("description_format", "description", "content_format", "content")}), ("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}), + VIDEO_ADMIN_FIELDSET, ("Settings", {"fields": ("order", "is_active")}), ) + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + def save_formset(self, request, form, formset, change): instances = formset.save(commit=False) for instance in instances: @@ -267,6 +290,24 @@ class CustomPageAdmin(admin.ModelAdmin): admin.site.register(CustomPageSection, CustomPageSectionAdmin) +@admin.register(PageVideo) +class PageVideoAdmin(admin.ModelAdmin): + list_display = ("page", "title", "video_source", "video_size", "order", "is_active") + list_filter = ("page", "video_source", "is_active") + list_editable = ("order", "is_active") + search_fields = ("title", "description", "video_url") + readonly_fields = ("video_preview",) + fieldsets = ( + (None, {"fields": ("page", "badge", "title", "description_format", "description")}), + VIDEO_ADMIN_FIELDSET, + ("Settings", {"fields": ("order", "is_active")}), + ) + + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + + @admin.register(DownloadItem) class DownloadItemAdmin(admin.ModelAdmin): list_display = ("name", "platform", "version", "is_active", "order") diff --git a/apps/pages/migrations/0015_video_support.py b/apps/pages/migrations/0015_video_support.py new file mode 100644 index 0000000..bfe7f8c --- /dev/null +++ b/apps/pages/migrations/0015_video_support.py @@ -0,0 +1,141 @@ +# Generated by Django 5.2.13 on 2026-06-08 12:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0014_alter_aboutsection_section_type_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PageVideo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)), + ('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)), + ('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')), + ('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')), + ('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)), + ('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)), + ('page', models.CharField(choices=[('contact', 'Contact'), ('faq', 'FAQ')], max_length=20)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('description', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Page Video', + 'verbose_name_plural': 'Page Videos', + 'ordering': ['page', 'order'], + }, + ), + migrations.AddField( + model_name='aboutsection', + name='video_aspect_ratio', + field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10), + ), + migrations.AddField( + model_name='aboutsection', + name='video_file', + field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'), + ), + migrations.AddField( + model_name='aboutsection', + name='video_poster', + field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'), + ), + migrations.AddField( + model_name='aboutsection', + name='video_size', + field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10), + ), + migrations.AddField( + model_name='aboutsection', + name='video_source', + field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20), + ), + migrations.AddField( + model_name='aboutsection', + name='video_url', + field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500), + ), + migrations.AddField( + model_name='custompagesection', + name='video_aspect_ratio', + field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10), + ), + migrations.AddField( + model_name='custompagesection', + name='video_file', + field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'), + ), + migrations.AddField( + model_name='custompagesection', + name='video_poster', + field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'), + ), + migrations.AddField( + model_name='custompagesection', + name='video_size', + field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10), + ), + migrations.AddField( + model_name='custompagesection', + name='video_source', + field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20), + ), + migrations.AddField( + model_name='custompagesection', + name='video_url', + field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500), + ), + migrations.AddField( + model_name='homepagesection', + name='video_aspect_ratio', + field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10), + ), + migrations.AddField( + model_name='homepagesection', + name='video_file', + field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'), + ), + migrations.AddField( + model_name='homepagesection', + name='video_poster', + field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'), + ), + migrations.AddField( + model_name='homepagesection', + name='video_size', + field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10), + ), + migrations.AddField( + model_name='homepagesection', + name='video_source', + field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20), + ), + migrations.AddField( + model_name='homepagesection', + name='video_url', + field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500), + ), + 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'), ('video', 'Video')], default='custom', max_length=30), + ), + migrations.AlterField( + model_name='custompagesection', + name='section_type', + field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion'), ('video', 'Video')], default='custom', max_length=30), + ), + migrations.AlterField( + model_name='homepagesection', + name='section_type', + field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30), + ), + ] diff --git a/apps/pages/migrations/0016_video_styled_background.py b/apps/pages/migrations/0016_video_styled_background.py new file mode 100644 index 0000000..08267d4 --- /dev/null +++ b/apps/pages/migrations/0016_video_styled_background.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.13 on 2026-06-08 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0015_video_support'), + ] + + operations = [ + migrations.AddField( + model_name='aboutsection', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + migrations.AddField( + model_name='custompagesection', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + migrations.AddField( + model_name='homepagesection', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + migrations.AddField( + model_name='pagevideo', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + ] diff --git a/apps/pages/migrations/0017_video_description_format.py b/apps/pages/migrations/0017_video_description_format.py new file mode 100644 index 0000000..aa07668 --- /dev/null +++ b/apps/pages/migrations/0017_video_description_format.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.13 on 2026-06-08 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0016_video_styled_background'), + ] + + operations = [ + migrations.AddField( + model_name='aboutsection', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='custompagesection', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='homepagesection', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='pagevideo', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + ] diff --git a/apps/pages/models.py b/apps/pages/models.py index a92fcbd..6f34358 100644 --- a/apps/pages/models.py +++ b/apps/pages/models.py @@ -3,6 +3,7 @@ from django.db import models from django.utils.text import slugify from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content +from apps.core.video import VideoBlockMixin from apps.pages.contact_uploads import ( contact_attachment_storage, contact_attachment_upload_to, @@ -41,7 +42,7 @@ class HeroSection(models.Model): return "Hero Section" -class HomepageSection(models.Model): +class HomepageSection(VideoBlockMixin, models.Model): TYPE_FEATURES = "features" TYPE_SCREENSHOTS = "screenshots" TYPE_PRODUCTS = "products" @@ -49,6 +50,7 @@ class HomepageSection(models.Model): TYPE_PROBLEMS = "problems" TYPE_ABOUT_STRIP = "about_strip" TYPE_SUPPORTERS = "supporters" + TYPE_VIDEO = "video" TYPE_CHOICES = [ (TYPE_FEATURES, "Features"), @@ -58,6 +60,7 @@ class HomepageSection(models.Model): (TYPE_PROBLEMS, "Problems / Value Proposition"), (TYPE_ABOUT_STRIP, "About Strip"), (TYPE_SUPPORTERS, "Supporters"), + (TYPE_VIDEO, "Video"), ] section_type = models.CharField(max_length=30, choices=TYPE_CHOICES) @@ -74,6 +77,11 @@ class HomepageSection(models.Model): verbose_name = "Homepage Section" verbose_name_plural = "Homepage Sections" + def clean(self): + super().clean() + if self.section_type == self.TYPE_VIDEO: + self.clean_video_fields(require=True) + def __str__(self): return f"[{self.get_section_type_display()}] {self.title or self.badge}" @@ -96,13 +104,14 @@ class HomepageSectionItem(models.Model): return f"{self.section} › {self.title or self.icon or '(item)'}" -class AboutSection(models.Model): +class AboutSection(VideoBlockMixin, models.Model): TYPE_HERO = "hero" TYPE_INTRO = "intro" TYPE_GRID = "grid" TYPE_HISTORY = "history" TYPE_CUSTOM = "custom" TYPE_SUPPORTERS = "supporters" + TYPE_VIDEO = "video" TYPE_CHOICES = [ (TYPE_HERO, "Hero"), @@ -111,6 +120,7 @@ class AboutSection(models.Model): (TYPE_HISTORY, "History Block"), (TYPE_CUSTOM, "Custom Content"), (TYPE_SUPPORTERS, "Supporters"), + (TYPE_VIDEO, "Video"), ] section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM) @@ -133,6 +143,11 @@ class AboutSection(models.Model): def rendered_content(self): return render_content(self.content, self.content_format) + def clean(self): + super().clean() + if self.section_type == self.TYPE_VIDEO: + self.clean_video_fields(require=True) + def __str__(self): label = self.title or self.badge or self.get_section_type_display() return f"[{self.get_section_type_display()}] {label}" @@ -295,7 +310,7 @@ class CustomPage(models.Model): super().save(*args, **kwargs) -class CustomPageSection(models.Model): +class CustomPageSection(VideoBlockMixin, models.Model): TYPE_HERO = "hero" TYPE_INTRO = "intro" TYPE_GRID = "grid" @@ -309,6 +324,7 @@ class CustomPageSection(models.Model): TYPE_SUPPORTERS = "supporters" TYPE_ABOUT_STRIP = "about_strip" TYPE_FAQ = "faq" + TYPE_VIDEO = "video" TYPE_CHOICES = [ (TYPE_HERO, "Hero"), @@ -324,6 +340,7 @@ class CustomPageSection(models.Model): (TYPE_SUPPORTERS, "Supporters"), (TYPE_ABOUT_STRIP, "About Strip"), (TYPE_FAQ, "FAQ Accordion"), + (TYPE_VIDEO, "Video"), ] page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections") @@ -348,11 +365,46 @@ class CustomPageSection(models.Model): def rendered_content(self): return render_content(self.content, self.content_format) + def clean(self): + super().clean() + if self.section_type == self.TYPE_VIDEO: + self.clean_video_fields(require=True) + def __str__(self): label = self.title or self.badge or self.get_section_type_display() return f"{self.page} › [{self.get_section_type_display()}] {label}" +class PageVideo(VideoBlockMixin, models.Model): + PAGE_CONTACT = "contact" + PAGE_FAQ = "faq" + + PAGE_CHOICES = [ + (PAGE_CONTACT, "Contact"), + (PAGE_FAQ, "FAQ"), + ] + + page = models.CharField(max_length=20, choices=PAGE_CHOICES) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + description = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["page", "order"] + verbose_name = "Page Video" + verbose_name_plural = "Page Videos" + + def clean(self): + super().clean() + self.clean_video_fields(require=True) + + def __str__(self): + label = self.title or self.badge or self.get_page_display() + return f"{self.get_page_display()} › {label}" + + class CustomPageSectionItem(models.Model): page = models.ForeignKey( CustomPage, diff --git a/apps/pages/tests/test_video.py b/apps/pages/tests/test_video.py new file mode 100644 index 0000000..35acd90 --- /dev/null +++ b/apps/pages/tests/test_video.py @@ -0,0 +1,187 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.core.video import parse_youtube_video_id, youtube_embed_url +from apps.pages.models import ( + AboutSection, + CustomPage, + CustomPageSection, + HomepageSection, + PageVideo, +) +from apps.products.models import MainProduct, ProductVideo + + +class YouTubeParsingTest(TestCase): + def test_watch_url(self): + self.assertEqual( + parse_youtube_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), + "dQw4w9WgXcQ", + ) + + def test_short_url(self): + self.assertEqual( + parse_youtube_video_id("https://youtu.be/dQw4w9WgXcQ"), + "dQw4w9WgXcQ", + ) + + def test_embed_url(self): + url = youtube_embed_url("https://youtu.be/dQw4w9WgXcQ") + self.assertEqual(url, "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ") + + +class HomepageVideoSectionTest(TestCase): + def test_video_section_renders_youtube_embed(self): + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_VIDEO, + title="Demo", + video_source="youtube", + video_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ") + self.assertContains(response, "video-block--md") + + +class AboutVideoSectionTest(TestCase): + def test_video_section_renders_on_about_page(self): + AboutSection.objects.create( + section_type=AboutSection.TYPE_VIDEO, + title="Overview", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + video_size="lg", + is_active=True, + ) + response = self.client.get(reverse("pages:about")) + self.assertContains(response, "video-block--lg") + self.assertContains(response, "Overview") + + +class CustomPageVideoSectionTest(TestCase): + def test_video_section_renders_on_custom_page(self): + page = CustomPage.objects.create( + title="Media", + slug="media-page", + is_published=True, + ) + CustomPageSection.objects.create( + page=page, + section_type=CustomPageSection.TYPE_VIDEO, + title="Walkthrough", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "media-page"})) + self.assertContains(response, "Walkthrough") + self.assertContains(response, "iframe") + + +class PageVideoTest(TestCase): + def test_contact_page_video(self): + PageVideo.objects.create( + page=PageVideo.PAGE_CONTACT, + title="Intro", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:contact")) + self.assertContains(response, "Intro") + self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ") + + def test_faq_page_video(self): + PageVideo.objects.create( + page=PageVideo.PAGE_FAQ, + title="Tutorial", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:faq")) + self.assertContains(response, "Tutorial") + + +class ProductVideoTest(TestCase): + def setUp(self): + self.main_product = MainProduct.objects.create( + name="Radiuma", + slug="radiuma", + short_description="Short", + description="Long", + is_active=True, + ) + + def test_product_video_renders_on_detail_page(self): + ProductVideo.objects.create( + main_product=self.main_product, + title="Product Demo", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + video_size="full", + is_active=True, + ) + response = self.client.get( + reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) + ) + self.assertContains(response, "Product Demo") + self.assertContains(response, "video-block--full") + + +class VideoStyledBackgroundTest(TestCase): + def test_styled_background_renders_panel(self): + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_VIDEO, + title="Styled Demo", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + video_styled_background=True, + is_active=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertContains(response, "video-panel--styled") + self.assertContains(response, "video-panel-blob") + + def test_plain_background_without_panel(self): + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_VIDEO, + title="Plain Demo", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + video_styled_background=False, + is_active=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertNotContains(response, "video-panel--styled") + self.assertContains(response, "Plain Demo") + + +class VideoDescriptionFormatTest(TestCase): + def test_plain_description_preserves_line_breaks(self): + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_VIDEO, + title="Demo", + description="First line\nSecond line", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertContains(response, "First line") + self.assertContains(response, "Second line") + self.assertContains(response, "
") + + def test_markdown_description_renders(self): + PageVideo.objects.create( + page=PageVideo.PAGE_FAQ, + title="Guide", + description="**Bold** intro", + description_format="markdown", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:faq")) + self.assertContains(response, "Bold") diff --git a/apps/pages/views.py b/apps/pages/views.py index 2f979d3..ef5aab8 100644 --- a/apps/pages/views.py +++ b/apps/pages/views.py @@ -17,6 +17,7 @@ from .models import ( FAQEntry, HeroSection, HomepageSection, + PageVideo, ) @@ -71,6 +72,14 @@ class FAQView(ListView): context_object_name = "faq_entries" queryset = FAQEntry.objects.filter(is_active=True) + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["faq_videos"] = PageVideo.objects.filter( + page=PageVideo.PAGE_FAQ, + is_active=True, + ).order_by("order") + return ctx + class ContactView(View): template_name = "pages/contact.html" @@ -84,7 +93,14 @@ class ContactView(View): return render( request, self.template_name, - {"form": ContactForm(), "captcha_question": self._new_captcha(request)}, + { + "form": ContactForm(), + "captcha_question": self._new_captcha(request), + "contact_videos": PageVideo.objects.filter( + page=PageVideo.PAGE_CONTACT, + is_active=True, + ).order_by("order"), + }, ) def post(self, request, *args, **kwargs): diff --git a/apps/products/admin.py b/apps/products/admin.py index cc092f1..ee95834 100644 --- a/apps/products/admin.py +++ b/apps/products/admin.py @@ -1,6 +1,9 @@ from django.contrib import admin -from .models import Article, ArticleCitation, ArticleSection, MainProduct, SubProduct, SubProductVersion +from apps.core.admin_video import video_admin_preview +from apps.core.video import VIDEO_ADMIN_FIELDSET + +from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion class ArticleCitationInline(admin.TabularInline): @@ -37,7 +40,10 @@ class MainProductArticleInline(admin.StackedInline): RELEASE_VERSION_INLINE_FIELDS = ( "version", - "is_featured_stable", + "release_channel", + "channel_label", + "is_featured", + "show_on_product_page", "windows_download_url", "macos_download_url", "linux_download_url", @@ -67,6 +73,28 @@ class MainProductVersionInline(admin.TabularInline): show_change_link = True +class ProductVideoInline(admin.StackedInline): + model = ProductVideo + fk_name = "main_product" + extra = 0 + fields = ( + "badge", + "title", + "description_format", + "description", + "video_source", + "video_url", + "video_file", + "video_poster", + "video_size", + "video_aspect_ratio", + "video_styled_background", + "order", + "is_active", + ) + ordering = ("order",) + + class SubProductInline(admin.StackedInline): model = SubProduct extra = 0 @@ -83,7 +111,7 @@ class MainProductAdmin(admin.ModelAdmin): search_fields = ("name", "description") prepopulated_fields = {"slug": ("name",)} list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") - inlines = [MainProductArticleInline, MainProductVersionInline, SubProductInline] + inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline] fieldsets = ( ( None, @@ -107,6 +135,28 @@ class MainProductAdmin(admin.ModelAdmin): ) +class SubProductVideoInline(admin.StackedInline): + model = ProductVideo + fk_name = "sub_product" + extra = 0 + fields = ( + "badge", + "title", + "description_format", + "description", + "video_source", + "video_url", + "video_file", + "video_poster", + "video_size", + "video_aspect_ratio", + "video_styled_background", + "order", + "is_active", + ) + ordering = ("order",) + + @admin.register(SubProduct) class SubProductAdmin(admin.ModelAdmin): list_display = ( @@ -124,7 +174,7 @@ class SubProductAdmin(admin.ModelAdmin): prepopulated_fields = {"slug": ("name",)} list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") raw_id_fields = ("main_product",) - inlines = [SubProductArticleInline, SubProductVersionInline] + inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline] fieldsets = ( ( None, @@ -173,6 +223,33 @@ class ArticleAdmin(admin.ModelAdmin): return "—" +@admin.register(ProductVideo) +class ProductVideoAdmin(admin.ModelAdmin): + list_display = ("title", "parent", "video_source", "video_size", "order", "is_active") + list_filter = ("video_source", "is_active", "main_product", "sub_product__main_product") + list_editable = ("order", "is_active") + search_fields = ("title", "description", "video_url", "main_product__name", "sub_product__name") + raw_id_fields = ("main_product", "sub_product") + readonly_fields = ("video_preview",) + fieldsets = ( + (None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description")}), + VIDEO_ADMIN_FIELDSET, + ("Settings", {"fields": ("order", "is_active")}), + ) + + @admin.display(description="Parent") + def parent(self, obj): + if obj.main_product_id: + return obj.main_product + if obj.sub_product_id: + return obj.sub_product + return "—" + + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + + @admin.register(ArticleSection) class ArticleSectionAdmin(admin.ModelAdmin): list_display = ("title", "article", "value_format", "order") @@ -191,11 +268,14 @@ class SubProductVersionAdmin(admin.ModelAdmin): list_display = ( "parent", "version", - "is_featured_stable", + "release_channel", + "channel_label", + "is_featured", + "show_on_product_page", "is_active", "order", ) - list_filter = ("is_active", "is_featured_stable", "main_product", "sub_product__main_product") + list_filter = ("is_active", "release_channel", "is_featured", "show_on_product_page", "main_product", "sub_product__main_product") search_fields = ("main_product__name", "sub_product__name", "version") list_editable = ("is_active", "order") raw_id_fields = ("main_product", "sub_product") @@ -203,11 +283,20 @@ class SubProductVersionAdmin(admin.ModelAdmin): ( None, { + "description": ( + "Set a preset channel badge (Stable, Beta, Previous, etc.) or write a custom " + "label. Mark one release as Primary (featured) for the main download block. " + "Enable “Show on product page” for additional inline channels such as beta or " + "previous versions." + ), "fields": ( "main_product", "sub_product", "version", - "is_featured_stable", + "release_channel", + "channel_label", + "is_featured", + "show_on_product_page", ) }, ), diff --git a/apps/products/migrations/0018_video_support.py b/apps/products/migrations/0018_video_support.py new file mode 100644 index 0000000..de6afc5 --- /dev/null +++ b/apps/products/migrations/0018_video_support.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.13 on 2026-06-08 12:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0017_alter_subproductversion_linux_download_file_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProductVideo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)), + ('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)), + ('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')), + ('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')), + ('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)), + ('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('description', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('main_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.mainproduct')), + ('sub_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.subproduct')), + ], + options={ + 'verbose_name': 'Product Video', + 'verbose_name_plural': 'Product Videos', + 'ordering': ['order', 'pk'], + 'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='product_video_exactly_one_parent')], + }, + ), + ] diff --git a/apps/products/migrations/0019_video_styled_background.py b/apps/products/migrations/0019_video_styled_background.py new file mode 100644 index 0000000..8a4bdb9 --- /dev/null +++ b/apps/products/migrations/0019_video_styled_background.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-06-08 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0018_video_support'), + ] + + operations = [ + migrations.AddField( + model_name='productvideo', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + ] diff --git a/apps/products/migrations/0020_video_description_format.py b/apps/products/migrations/0020_video_description_format.py new file mode 100644 index 0000000..14a3bb5 --- /dev/null +++ b/apps/products/migrations/0020_video_description_format.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-06-08 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0019_video_styled_background'), + ] + + operations = [ + migrations.AddField( + model_name='productvideo', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + ] diff --git a/apps/products/migrations/0021_release_channels.py b/apps/products/migrations/0021_release_channels.py new file mode 100644 index 0000000..1e41dff --- /dev/null +++ b/apps/products/migrations/0021_release_channels.py @@ -0,0 +1,70 @@ +from django.db import migrations, models + + +def migrate_featured_stable_to_channels(apps, schema_editor): + Version = apps.get_model("products", "SubProductVersion") + for version in Version.objects.filter(is_featured_stable=True): + version.is_featured = True + if not version.release_channel: + version.release_channel = "stable" + version.save(update_fields=["is_featured", "release_channel"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0020_video_description_format"), + ] + + operations = [ + migrations.AddField( + model_name="subproductversion", + name="channel_label", + field=models.CharField( + blank=True, + help_text="Optional custom badge text. Overrides the preset channel label when set.", + max_length=50, + ), + ), + migrations.AddField( + model_name="subproductversion", + name="is_featured", + field=models.BooleanField( + default=False, + help_text="Primary release shown at the top of the downloads section.", + ), + ), + migrations.AddField( + model_name="subproductversion", + name="release_channel", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("stable", "Stable"), + ("beta", "Beta"), + ("rc", "Release candidate"), + ("preview", "Preview"), + ("nightly", "Nightly"), + ("current", "Current"), + ("previous", "Previous"), + ], + default="", + help_text="Preset badge for this release (Stable, Beta, Previous, etc.).", + max_length=20, + ), + ), + migrations.AddField( + model_name="subproductversion", + name="show_on_product_page", + field=models.BooleanField( + default=False, + help_text="Also show this release on the product page (e.g. beta or previous version).", + ), + ), + migrations.RunPython(migrate_featured_stable_to_channels, migrations.RunPython.noop), + migrations.RemoveField( + model_name="subproductversion", + name="is_featured_stable", + ), + ] diff --git a/apps/products/models.py b/apps/products/models.py index 207fdc5..5cb3f40 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -6,6 +6,7 @@ from django.urls import reverse from django.utils.text import slugify from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content +from apps.core.video import VideoBlockMixin from .release_assets import ( FILE_FIELD_BY_ASSET_KEY, @@ -36,6 +37,35 @@ DISTRIBUTION_CHOICES = [ (DISTRIBUTION_PACKAGE, "Package (external / non-installable)"), ] +RELEASE_CHANNEL_STABLE = "stable" +RELEASE_CHANNEL_BETA = "beta" +RELEASE_CHANNEL_RC = "rc" +RELEASE_CHANNEL_PREVIEW = "preview" +RELEASE_CHANNEL_NIGHTLY = "nightly" +RELEASE_CHANNEL_CURRENT = "current" +RELEASE_CHANNEL_PREVIOUS = "previous" + +RELEASE_CHANNEL_CHOICES = [ + ("", "None"), + (RELEASE_CHANNEL_STABLE, "Stable"), + (RELEASE_CHANNEL_BETA, "Beta"), + (RELEASE_CHANNEL_RC, "Release candidate"), + (RELEASE_CHANNEL_PREVIEW, "Preview"), + (RELEASE_CHANNEL_NIGHTLY, "Nightly"), + (RELEASE_CHANNEL_CURRENT, "Current"), + (RELEASE_CHANNEL_PREVIOUS, "Previous"), +] + +RELEASE_CHANNEL_LABELS = { + RELEASE_CHANNEL_STABLE: "Stable", + RELEASE_CHANNEL_BETA: "Beta", + RELEASE_CHANNEL_RC: "Release candidate", + RELEASE_CHANNEL_PREVIEW: "Preview", + RELEASE_CHANNEL_NIGHTLY: "Nightly", + RELEASE_CHANNEL_CURRENT: "Current", + RELEASE_CHANNEL_PREVIOUS: "Previous", +} + class MainProduct(models.Model): name = models.CharField(max_length=200) @@ -280,9 +310,25 @@ class SubProductVersion(models.Model): blank=True, ) version = models.CharField(max_length=80) - is_featured_stable = models.BooleanField( + release_channel = models.CharField( + max_length=20, + choices=RELEASE_CHANNEL_CHOICES, + blank=True, + default="", + help_text="Preset badge for this release (Stable, Beta, Previous, etc.).", + ) + channel_label = models.CharField( + max_length=50, + blank=True, + help_text="Optional custom badge text. Overrides the preset channel label when set.", + ) + is_featured = models.BooleanField( default=False, - help_text="Highlighted as the main release on the product page.", + help_text="Primary release shown at the top of the downloads section.", + ) + show_on_product_page = models.BooleanField( + default=False, + help_text="Also show this release on the product page (e.g. beta or previous version).", ) windows_download_url = models.URLField(blank=True) macos_download_url = models.URLField(blank=True) @@ -361,6 +407,31 @@ class SubProductVersion(models.Model): parent = self.sub_product or self.main_product return f"{parent.name} v{self.version}" + @property + def display_channel_label(self): + custom = (self.channel_label or "").strip() + if custom: + return custom + if self.release_channel: + return RELEASE_CHANNEL_LABELS.get( + self.release_channel, + self.release_channel.replace("_", " ").title(), + ) + return "" + + @property + def display_channel_css_modifier(self): + if (self.channel_label or "").strip(): + return "custom" + return self.release_channel or "none" + + @property + def is_previous_channel(self): + if self.release_channel == RELEASE_CHANNEL_PREVIOUS: + return True + label = (self.channel_label or "").strip().lower() + return label == "previous" + def save(self, *args, **kwargs): for file_field, name_field in RELEASE_ORIGINAL_FILENAME_FIELDS.items(): field_file = getattr(self, file_field) @@ -476,3 +547,54 @@ class ArticleCitation(models.Model): def __str__(self): return f"{self.article.title} — citation {self.order or self.pk}" + + +class ProductVideo(VideoBlockMixin, models.Model): + main_product = models.ForeignKey( + MainProduct, + on_delete=models.CASCADE, + related_name="videos", + null=True, + blank=True, + ) + sub_product = models.ForeignKey( + SubProduct, + on_delete=models.CASCADE, + related_name="videos", + null=True, + blank=True, + ) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + description = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["order", "pk"] + verbose_name = "Product Video" + verbose_name_plural = "Product Videos" + constraints = [ + models.CheckConstraint( + check=( + models.Q(sub_product__isnull=False, main_product__isnull=True) + | models.Q(sub_product__isnull=True, main_product__isnull=False) + ), + name="product_video_exactly_one_parent", + ), + ] + + def clean(self): + super().clean() + has_main = self.main_product_id is not None + has_sub = self.sub_product_id is not None + if has_main == has_sub: + raise ValidationError( + "A product video must belong to exactly one main product or sub-product." + ) + self.clean_video_fields(require=True) + + def __str__(self): + parent = self.sub_product or self.main_product + label = self.title or self.badge or "Video" + return f"{parent} › {label}" diff --git a/apps/products/release_context.py b/apps/products/release_context.py index 513785a..6b0f7b6 100644 --- a/apps/products/release_context.py +++ b/apps/products/release_context.py @@ -23,10 +23,14 @@ def downloads_section_link(product): def featured_version(qs): ordered = qs.order_by("order", "pk") - cand = ordered.filter(is_featured_stable=True).first() + cand = ordered.filter(is_featured=True).first() return cand if cand else ordered.first() +def version_has_public_assets(version): + return version.has_any_install_asset() or bool((version.package_resource_url or "").strip()) + + def install_specs_from_versions(version_list): present = set() for ver in version_list: @@ -36,13 +40,29 @@ def install_specs_from_versions(version_list): return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present) +def build_installable_channel_block(version): + if not version_has_public_assets(version): + return None + block = { + "version": version, + "install_cells": [], + "is_previous": version.is_previous_channel, + } + if version.has_any_install_asset(): + specs = install_specs_from_versions([version]) + block["install_cells"] = version.install_urls_for_specs(specs) + return block + + def build_release_context(distribution, active_versions, product): context = { "distribution_installable": distribution == DISTRIBUTION_INSTALLABLE, "distribution_package": distribution == DISTRIBUTION_PACKAGE, "show_releases_section": False, "featured_version": None, + "featured_channel": None, "featured_install_cells": [], + "inline_download_channels": [], "show_older_versions_link": False, "package_versions": [], **package_button_labels(product), @@ -51,30 +71,36 @@ def build_release_context(distribution, active_versions, product): if distribution == DISTRIBUTION_INSTALLABLE: featured = featured_version(active_versions) - if ( - featured - and not featured.has_any_install_asset() - and not (featured.package_resource_url or "").strip() - ): + if featured and not version_has_public_assets(featured): for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"): - if cand.has_any_install_asset() or (cand.package_resource_url or "").strip(): + if version_has_public_assets(cand): featured = cand break context["featured_version"] = featured - if featured and ( - featured.has_any_install_asset() - or (featured.package_resource_url or "").strip() - ): - if featured.has_any_install_asset(): - specs = install_specs_from_versions([featured]) - context["featured_install_cells"] = featured.install_urls_for_specs(specs) - context["show_releases_section"] = True - older_list = list( - active_versions.exclude(pk=featured.pk).order_by("order", "pk") - if featured - else active_versions.order_by("order", "pk") - ) - context["show_older_versions_link"] = len(older_list) > 0 + if featured and version_has_public_assets(featured): + featured_block = build_installable_channel_block(featured) + if featured_block: + context["featured_channel"] = featured_block + context["featured_install_cells"] = featured_block["install_cells"] + context["show_releases_section"] = True + + inline_blocks = [] + for ver in active_versions.order_by("order", "pk"): + if featured and ver.pk == featured.pk: + continue + if not ver.show_on_product_page: + continue + block = build_installable_channel_block(ver) + if block: + inline_blocks.append(block) + context["inline_download_channels"] = inline_blocks + + shown_pks = {featured.pk} if featured else set() + shown_pks.update(block["version"].pk for block in inline_blocks) + older_qs = active_versions.order_by("order", "pk") + if shown_pks: + older_qs = older_qs.exclude(pk__in=shown_pks) + context["show_older_versions_link"] = older_qs.exists() elif distribution == DISTRIBUTION_PACKAGE: pkg_versions = list(active_versions.order_by("order", "pk")) @@ -86,9 +112,16 @@ def build_release_context(distribution, active_versions, product): def build_archive_context(distribution, active_versions, product): featured = featured_version(active_versions) + inline_pks = set( + active_versions.filter(show_on_product_page=True).values_list("pk", flat=True) + ) + exclude_pks = set() + if featured: + exclude_pks.add(featured.pk) + exclude_pks.update(inline_pks) archive = list( - active_versions.exclude(pk=featured.pk).order_by("order", "pk") - if featured + active_versions.exclude(pk__in=exclude_pks).order_by("order", "pk") + if exclude_pks else active_versions.order_by("order", "pk") ) context = { diff --git a/apps/products/tests/test_models.py b/apps/products/tests/test_models.py index bd2f67d..825bc0a 100644 --- a/apps/products/tests/test_models.py +++ b/apps/products/tests/test_models.py @@ -213,6 +213,29 @@ class SubProductVersionModelTest(TestCase): with self.assertRaises(ValidationError): version.full_clean() + def test_display_channel_label_uses_custom_text(self): + from apps.products.models import RELEASE_CHANNEL_BETA + + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="2.0", + release_channel=RELEASE_CHANNEL_BETA, + channel_label="Preview build", + ) + self.assertEqual(version.display_channel_label, "Preview build") + self.assertEqual(version.display_channel_css_modifier, "custom") + + def test_display_channel_label_uses_preset(self): + from apps.products.models import RELEASE_CHANNEL_PREVIOUS + + version = SubProductVersion.objects.create( + sub_product=self.sub_product, + version="1.0", + release_channel=RELEASE_CHANNEL_PREVIOUS, + ) + self.assertEqual(version.display_channel_label, "Previous") + self.assertTrue(version.is_previous_channel) + class ArticleSectionModelTest(TestCase): def setUp(self): diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py index cf3ce06..3870e72 100644 --- a/apps/products/tests/test_views.py +++ b/apps/products/tests/test_views.py @@ -160,14 +160,15 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup): SubProductVersion.objects.create( sub_product=self.sub_product, version="9.9", - is_featured_stable=True, + is_featured=True, + release_channel="stable", windows_download_url="https://example.com/w", is_active=True, ) SubProductVersion.objects.create( sub_product=self.sub_product, version="9.8", - is_featured_stable=False, + is_featured=False, windows_download_url="https://example.com/w2", is_active=True, ) @@ -185,7 +186,8 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup): SubProductVersion.objects.create( main_product=self.main_product, version="9.9", - is_featured_stable=True, + is_featured=True, + release_channel="stable", windows_download_url="https://example.com/win.exe", is_active=True, ) @@ -215,6 +217,48 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup): response = self.client.get(url) self.assertTrue(response.context["show_releases_section"]) + def test_installable_shows_release_channel_badges(self): + from apps.products.models import RELEASE_CHANNEL_BETA, RELEASE_CHANNEL_PREVIOUS, SubProductVersion + + SubProductVersion.objects.create( + main_product=self.main_product, + version="2.0", + release_channel=RELEASE_CHANNEL_BETA, + is_featured=True, + windows_download_url="https://example.com/beta.exe", + is_active=True, + ) + SubProductVersion.objects.create( + main_product=self.main_product, + version="1.9", + release_channel=RELEASE_CHANNEL_PREVIOUS, + show_on_product_page=True, + windows_download_url="https://example.com/prev.exe", + is_active=True, + ) + url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) + response = self.client.get(url) + self.assertContains(response, "Beta") + self.assertContains(response, "Previous") + self.assertEqual(len(response.context["inline_download_channels"]), 1) + + def test_custom_channel_label_overrides_preset(self): + from apps.products.models import RELEASE_CHANNEL_STABLE, SubProductVersion + + SubProductVersion.objects.create( + main_product=self.main_product, + version="3.0", + release_channel=RELEASE_CHANNEL_STABLE, + channel_label="Early Access", + is_featured=True, + windows_download_url="https://example.com/ea.exe", + is_active=True, + ) + url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) + response = self.client.get(url) + self.assertContains(response, "Early Access") + self.assertNotContains(response, ">Stable<") + @override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases") class ReleaseAssetDownloadViewTest(ProductViewsSetup): diff --git a/apps/products/views.py b/apps/products/views.py index 38aedd6..ec7e52f 100644 --- a/apps/products/views.py +++ b/apps/products/views.py @@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404 from django.views import View from django.views.generic import DetailView, ListView, TemplateView -from .models import MainProduct, SubProduct, SubProductVersion +from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY from .release_context import build_archive_context, build_release_context @@ -58,6 +58,7 @@ class MainProductDetailView(DetailView): context["articles"] = self.object.articles.prefetch_related( "sections", "citations" ).all() + context["product_videos"] = self.object.videos.filter(is_active=True).order_by("order") release_context = build_release_context( self.object.distribution, self.object.versions.filter(is_active=True), @@ -111,6 +112,7 @@ class SubProductDetailView(TemplateView): context["articles"] = sub_product.articles.prefetch_related( "sections", "citations" ).all() + context["product_videos"] = sub_product.videos.filter(is_active=True).order_by("order") context["siblings"] = ( SubProduct.objects.filter(main_product=main_product, is_active=True) .exclude(pk=sub_product.pk) diff --git a/static/css/main.css b/static/css/main.css index f9e4e83..9a42baf 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1995,12 +1995,48 @@ a.citation-count-badge:hover { border: 1px solid rgba(34, 197, 94, 0.2); } +.release-channel-label--beta { + color: rgb(253, 186, 116); + background: rgba(249, 115, 22, 0.12); + border: 1px solid rgba(249, 115, 22, 0.22); +} + +.release-channel-label--rc { + color: rgb(196, 181, 253); + background: rgba(139, 92, 246, 0.12); + border: 1px solid rgba(139, 92, 246, 0.22); +} + +.release-channel-label--preview { + color: rgb(147, 197, 253); + background: rgba(59, 130, 246, 0.12); + border: 1px solid rgba(59, 130, 246, 0.22); +} + +.release-channel-label--nightly { + color: rgb(252, 165, 165); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.release-channel-label--current { + color: var(--accent-cyan); + background: rgba(34, 211, 238, 0.08); + border: 1px solid rgba(34, 211, 238, 0.2); +} + .release-channel-label--previous { color: var(--text-secondary); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--glass-border); } +.release-channel-label--custom { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--glass-border); +} + .download-card--previous { opacity: 0.78; } @@ -2255,9 +2291,22 @@ a.citation-count-badge:hover { flex-shrink: 0; } -.pkg-stable-inline { +.pkg-version-meta .release-channel-label { font-size: 0.7rem; padding: 0.2rem 0.55rem; + margin-bottom: 0; +} + +.versions-table-version { + display: inline-block; + margin-right: 0.45rem; +} + +.versions-table th[scope="row"] .release-channel-label { + font-size: 0.68rem; + padding: 0.18rem 0.5rem; + margin-bottom: 0; + vertical-align: middle; } .pkg-version-actions { @@ -2350,10 +2399,29 @@ a.citation-count-badge:hover { .versions-table-link { display: inline-flex; align-items: center; + justify-content: center; + padding: 0.35rem; + border-radius: var(--radius-sm); + transition: var(--transition-smooth); +} + +.versions-table-link:hover { + background: rgba(79, 142, 247, 0.1); } .versions-table-icon { display: block; + width: 22px; + height: 22px; + object-fit: contain; + filter: brightness(0) invert(1); + opacity: 0.82; + transition: var(--transition-smooth); +} + +.versions-table-link:hover .versions-table-icon { + opacity: 1; + filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(79, 142, 247, 0.55)); } .versions-table-text-link { @@ -3440,3 +3508,214 @@ a.citation-count-badge:hover { .readmore-btn.is-open svg { transform: rotate(180deg); } + +/* ============================================================ + Video Blocks + ============================================================ */ +.video-block { + width: 100%; + max-width: var(--video-max-width, 100%); + margin-inline: auto; +} + +.video-block--sm { + --video-max-width: 480px; +} + +.video-block--md { + --video-max-width: 720px; +} + +.video-block--lg { + --video-max-width: 960px; +} + +.video-block--full { + --video-max-width: 100%; +} + +.video-block-inner { + position: relative; + width: 100%; + aspect-ratio: var(--video-aspect-ratio, 16 / 9); + overflow: hidden; + border-radius: var(--radius-md); + background: rgba(8, 12, 24, 0.55); +} + +.video-block--ratio-16-9 { + --video-aspect-ratio: 16 / 9; +} + +.video-block--ratio-4-3 { + --video-aspect-ratio: 4 / 3; +} + +.video-block--ratio-1-1 { + --video-aspect-ratio: 1 / 1; +} + +.video-block-inner iframe, +.video-block-inner video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + object-fit: contain; + background: #000; +} + +.video-section .section-desc { + margin-bottom: 1.5rem; + text-align: center; + max-width: 680px; + margin-left: auto; + margin-right: auto; +} + +.product-videos-list { + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +.product-video-header { + margin-bottom: 0.75rem; +} + +.video-panel--styled { + position: relative; + overflow: hidden; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: 1.75rem 1.5rem 1.5rem; + backdrop-filter: blur(12px); +} + +.video-panel-ambient { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} + +.video-panel-blob { + position: absolute; + border-radius: 50%; + filter: blur(52px); +} + +.video-panel-blob--1 { + width: 240px; + height: 240px; + top: -90px; + right: -50px; + background: radial-gradient(circle, rgba(79, 142, 247, 0.42) 0%, transparent 72%); + opacity: 0.85; +} + +.video-panel-blob--2 { + width: 200px; + height: 200px; + bottom: -70px; + left: -40px; + background: radial-gradient(circle, rgba(56, 189, 248, 0.28) 0%, transparent 72%); + opacity: 0.9; +} + +.video-panel-blob--3 { + width: 140px; + height: 140px; + top: 45%; + left: 55%; + background: radial-gradient(circle, rgba(139, 92, 246, 0.18) 0%, transparent 70%); + opacity: 0.75; +} + +.video-panel-inner { + position: relative; + z-index: 1; +} + +.video-panel-header { + text-align: center; + max-width: 680px; + margin: 0 auto 0.35rem; +} + +.video-panel-heading { + display: flex; + flex-direction: column; + align-items: center; +} + +.video-panel-heading .section-badge { + margin-bottom: 0.65rem; +} + +.video-panel-title { + font-size: 1.05rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + gap: 0.55rem; + margin: 0; + color: var(--text-primary); +} + +.video-panel-title svg { + flex-shrink: 0; + color: var(--accent-blue-light); +} + +.video-panel-desc { + text-align: center; + max-width: 680px; + margin: 0 auto 1.25rem; +} + +.video-panel--styled .video-block-inner { + border: 1px solid rgba(79, 142, 247, 0.22); + box-shadow: + 0 16px 48px rgba(8, 12, 24, 0.38), + inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.product-videos-list .product-video-item .section-desc, +.product-videos-list .product-video-item .rich-content.section-desc { + text-align: center; + max-width: 680px; + margin-left: auto; + margin-right: auto; +} + +.product-videos-list .video-panel--styled + .video-panel--styled, +.product-videos-list .product-video-item + .product-video-item { + margin-top: 0; +} + +.product-videos-list .video-panel--styled, +.product-videos-list .product-video-item { + margin-top: 0; +} + +@media (max-width: 640px) { + .video-panel--styled { + padding: 1.25rem 1rem 1rem; + } + + .video-panel-blob--1 { + width: 180px; + height: 180px; + top: -70px; + right: -70px; + } + + .video-panel-blob--2 { + width: 150px; + height: 150px; + } +} diff --git a/templates/pages/contact.html b/templates/pages/contact.html index 7739631..6826e15 100644 --- a/templates/pages/contact.html +++ b/templates/pages/contact.html @@ -20,6 +20,8 @@ +{% include "partials/_page_videos.html" with videos=contact_videos %} +
diff --git a/templates/pages/faq.html b/templates/pages/faq.html index dcb6e73..6a4aadc 100644 --- a/templates/pages/faq.html +++ b/templates/pages/faq.html @@ -20,6 +20,8 @@
+{% include "partials/_page_videos.html" with videos=faq_videos %} +

FAQ List

diff --git a/templates/partials/_page_sections.html b/templates/partials/_page_sections.html index 08571c9..1bfa190 100644 --- a/templates/partials/_page_sections.html +++ b/templates/partials/_page_sections.html @@ -411,6 +411,9 @@
+{% elif section.section_type == "video" %} +{% include "partials/_video_section.html" with section=section %} + {% endif %} {% empty %}
diff --git a/templates/partials/_page_videos.html b/templates/partials/_page_videos.html new file mode 100644 index 0000000..9a6be3e --- /dev/null +++ b/templates/partials/_page_videos.html @@ -0,0 +1,15 @@ +{% if videos %} +
+
+
+ {% for video in videos %} + {% if video.has_video %} +
+ {% include "partials/_video_block.html" with video=video compact_header=True %} +
+ {% endif %} + {% endfor %} +
+
+
+{% endif %} diff --git a/templates/partials/_video_block.html b/templates/partials/_video_block.html new file mode 100644 index 0000000..f62ef46 --- /dev/null +++ b/templates/partials/_video_block.html @@ -0,0 +1,55 @@ +{% if video.has_video %} +{% if video.video_styled_background %} +
+ +
+ {% if video.badge or video.title %} +
+
+ {% if video.badge %}
{{ video.badge }}
{% endif %} + {% if video.title %} +

+ + {{ video.title }} +

+ {% else %} +

Video

+ {% endif %} +
+
+ {% else %} +

Video

+ {% endif %} + {% if video.description %} +
{{ video.rendered_description }}
+ {% elif video.content %} +
{{ video.rendered_content }}
+ {% endif %} + {% include "partials/_video_player.html" with video=video %} +
+
+{% else %} +{% if video.badge or video.title %} +
+ {% if video.badge %}
{{ video.badge }}
{% endif %} + {% if video.title %} +

{{ video.title }}

+ {% else %} +

Video

+ {% endif %} +
+{% endif %} +{% if video.description %} +
{{ video.rendered_description }}
+{% elif video.content %} +
{{ video.rendered_content }}
+{% endif %} +{% include "partials/_video_player.html" with video=video %} +{% endif %} +{% endif %} diff --git a/templates/partials/_video_player.html b/templates/partials/_video_player.html new file mode 100644 index 0000000..4f5bf9b --- /dev/null +++ b/templates/partials/_video_player.html @@ -0,0 +1,26 @@ +{% if video.has_video %} +
+
+ {% if video.video_source == "upload" and video.video_file %} + + {% elif video.youtube_embed_url %} + + {% endif %} +
+
+{% endif %} diff --git a/templates/partials/_video_section.html b/templates/partials/_video_section.html new file mode 100644 index 0000000..4d11a68 --- /dev/null +++ b/templates/partials/_video_section.html @@ -0,0 +1,7 @@ +{% if section.has_video %} +
+
+ {% include "partials/_video_block.html" with video=section %} +
+
+{% endif %} diff --git a/templates/products/_installable_download_channel.html b/templates/products/_installable_download_channel.html new file mode 100644 index 0000000..d8f7eaa --- /dev/null +++ b/templates/products/_installable_download_channel.html @@ -0,0 +1,34 @@ +{% load static %} +
+ {% include "products/_release_channel_badge.html" with version=channel.version %} + {% if channel.install_cells %} +
+ {% for cell in channel.install_cells %} +
+
+ {% if cell.icon %} + + {% else %} + + {% endif %} +
+ {{ cell.label }} + {{ channel.version.version }} + {% if cell.label == "Source code" %}Source{% else %}Download{% endif %} +
+ {% endfor %} +
+ {% endif %} + {% if channel.version.package_resource_url %} +

+ Package resource +

+ {% endif %} + {% if channel.version.release_notes %} +

{{ channel.version.release_notes }}

+ {% endif %} +
diff --git a/templates/products/_release_channel_badge.html b/templates/products/_release_channel_badge.html new file mode 100644 index 0000000..e0f8363 --- /dev/null +++ b/templates/products/_release_channel_badge.html @@ -0,0 +1,10 @@ +{% if version.display_channel_label %} + + {% if version.release_channel == "stable" %} + + {% endif %} + {{ version.display_channel_label }} + +{% endif %} diff --git a/templates/products/_releases_section.html b/templates/products/_releases_section.html index 29ac4bc..48390e8 100644 --- a/templates/products/_releases_section.html +++ b/templates/products/_releases_section.html @@ -14,48 +14,13 @@ {% endif %} -
- {% if featured_version.is_featured_stable %} - - - Stable - - {% endif %} - {% if featured_install_cells %} -
- {% for cell in featured_install_cells %} -
-
- {% if cell.icon %} - - {% else %} - - {% endif %} -
- {{ cell.label }} - {{ featured_version.version }} - {% if cell.label == "Source code" %}Source{% else %}Download{% endif %} -
- {% endfor %} -
- {% endif %} -
- - {% if featured_version.package_resource_url %} -

- Package resource -

+ {% if featured_version %} + {% include "products/_installable_download_channel.html" with channel=featured_channel %} {% endif %} - {% if featured_version.release_notes %} -

{{ featured_version.release_notes }}

- {% endif %} + {% for channel in inline_download_channels %} + {% include "products/_installable_download_channel.html" %} + {% endfor %} {% if show_older_versions_link %}

@@ -83,14 +48,7 @@

  • {{ ver.version }} - {% if ver.is_featured_stable %} - - - Stable - - {% endif %} + {% include "products/_release_channel_badge.html" with version=ver %}
    {% if ver.package_resource_url %} diff --git a/templates/products/_versions_archive_body.html b/templates/products/_versions_archive_body.html index 098902c..ab39e92 100644 --- a/templates/products/_versions_archive_body.html +++ b/templates/products/_versions_archive_body.html @@ -15,7 +15,10 @@ {% for row in archive_rows_installable %} - {{ row.version_obj.version }} + + {{ row.version_obj.version }} + {% include "products/_release_channel_badge.html" with version=row.version_obj %} + {% for cell in row.cells %} {% if cell.url %} @@ -50,7 +53,10 @@ {% for ver in archive_versions %}
  • - {{ ver.version }} +
    + {{ ver.version }} + {% include "products/_release_channel_badge.html" with version=ver %} +
    {% if ver.package_resource_url %} {{ package_resource_button_text }} {% endif %} diff --git a/templates/products/main_detail.html b/templates/products/main_detail.html index 6887af6..4511b01 100644 --- a/templates/products/main_detail.html +++ b/templates/products/main_detail.html @@ -53,6 +53,8 @@
  • +{% include "partials/_page_videos.html" with videos=product_videos %} + {% if articles %}
    diff --git a/templates/products/sub_detail.html b/templates/products/sub_detail.html index 12318a7..f45e57a 100644 --- a/templates/products/sub_detail.html +++ b/templates/products/sub_detail.html @@ -49,6 +49,8 @@ {% include "products/_releases_section.html" %} + {% include "partials/_page_videos.html" with videos=product_videos %} + {% include "products/_article_list.html" %}