change release section put video and more

This commit is contained in:
mohamad
2026-06-08 17:08:43 +03:30
parent 33b3477176
commit 7cddc03a57
33 changed files with 1657 additions and 93 deletions
+30
View File
@@ -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(
'<video controls playsinline preload="metadata" style="max-width:100%;" poster="{}">'
'<source src="{}"></video>',
obj.video_poster.url,
obj.video_file.url,
)
return format_html(
'<video controls playsinline preload="metadata" style="max-width:100%;">'
'<source src="{}"></video>',
obj.video_file.url,
)
if obj.video_source == VIDEO_SOURCE_YOUTUBE and obj.youtube_embed_url:
return format_html(
'<iframe src="{}" title="Preview" width="480" height="270" '
'style="max-width:100%;border:0;" allowfullscreen loading="lazy"></iframe>',
obj.youtube_embed_url,
)
return "No video configured yet."
video_admin_preview.short_description = "Preview"
+172
View File
@@ -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.",
}
)
+43 -2
View File
@@ -3,6 +3,9 @@ from django.http import FileResponse, Http404, HttpResponseRedirect
from django.urls import path, reverse from django.urls import path, reverse
from django.utils.html import format_html 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 ( from .models import (
AboutSection, AboutSection,
AboutSectionItem, AboutSectionItem,
@@ -16,6 +19,7 @@ from .models import (
HeroSection, HeroSection,
HomepageSection, HomepageSection,
HomepageSectionItem, HomepageSectionItem,
PageVideo,
) )
@@ -58,12 +62,18 @@ class HomepageSectionAdmin(admin.ModelAdmin):
list_filter = ("section_type", "is_active") list_filter = ("section_type", "is_active")
list_editable = ("order", "is_active") list_editable = ("order", "is_active")
inlines = [HomepageSectionItemInline] inlines = [HomepageSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = ( 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",)}), ("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}), ("Settings", {"fields": ("order", "is_active")}),
) )
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
class AboutSectionItemInline(admin.TabularInline): class AboutSectionItemInline(admin.TabularInline):
model = AboutSectionItem model = AboutSectionItem
@@ -78,12 +88,18 @@ class AboutSectionAdmin(admin.ModelAdmin):
list_filter = ("section_type", "is_active") list_filter = ("section_type", "is_active")
list_editable = ("order", "is_active") list_editable = ("order", "is_active")
inlines = [AboutSectionItemInline] inlines = [AboutSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = ( fieldsets = (
(None, {"fields": ("section_type", "badge", "title", "subtitle")}), (None, {"fields": ("section_type", "badge", "title", "subtitle")}),
("Content", {"fields": ("content_format", "content")}), ("Content", {"fields": ("content_format", "content")}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}), ("Settings", {"fields": ("order", "is_active")}),
) )
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
class ContactSubmissionAttachmentInline(admin.TabularInline): class ContactSubmissionAttachmentInline(admin.TabularInline):
model = ContactSubmissionAttachment model = ContactSubmissionAttachment
@@ -177,6 +193,7 @@ class CustomPageSectionInline(admin.StackedInline):
"badge", "badge",
"title", "title",
"subtitle", "subtitle",
"description_format",
"description", "description",
"content_format", "content_format",
"content", "content",
@@ -221,13 +238,19 @@ class CustomPageSectionAdmin(admin.ModelAdmin):
list_filter = ("section_type", "is_active", "page") list_filter = ("section_type", "is_active", "page")
list_editable = ("order", "is_active") list_editable = ("order", "is_active")
inlines = [CustomPageSectionItemInline] inlines = [CustomPageSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = ( fieldsets = (
(None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}), (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",)}), ("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}), ("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): def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False) instances = formset.save(commit=False)
for instance in instances: for instance in instances:
@@ -267,6 +290,24 @@ class CustomPageAdmin(admin.ModelAdmin):
admin.site.register(CustomPageSection, CustomPageSectionAdmin) 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) @admin.register(DownloadItem)
class DownloadItemAdmin(admin.ModelAdmin): class DownloadItemAdmin(admin.ModelAdmin):
list_display = ("name", "platform", "version", "is_active", "order") list_display = ("name", "platform", "version", "is_active", "order")
+141
View File
@@ -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),
),
]
@@ -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).'),
),
]
@@ -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),
),
]
+55 -3
View File
@@ -3,6 +3,7 @@ from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content 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 ( from apps.pages.contact_uploads import (
contact_attachment_storage, contact_attachment_storage,
contact_attachment_upload_to, contact_attachment_upload_to,
@@ -41,7 +42,7 @@ class HeroSection(models.Model):
return "Hero Section" return "Hero Section"
class HomepageSection(models.Model): class HomepageSection(VideoBlockMixin, models.Model):
TYPE_FEATURES = "features" TYPE_FEATURES = "features"
TYPE_SCREENSHOTS = "screenshots" TYPE_SCREENSHOTS = "screenshots"
TYPE_PRODUCTS = "products" TYPE_PRODUCTS = "products"
@@ -49,6 +50,7 @@ class HomepageSection(models.Model):
TYPE_PROBLEMS = "problems" TYPE_PROBLEMS = "problems"
TYPE_ABOUT_STRIP = "about_strip" TYPE_ABOUT_STRIP = "about_strip"
TYPE_SUPPORTERS = "supporters" TYPE_SUPPORTERS = "supporters"
TYPE_VIDEO = "video"
TYPE_CHOICES = [ TYPE_CHOICES = [
(TYPE_FEATURES, "Features"), (TYPE_FEATURES, "Features"),
@@ -58,6 +60,7 @@ class HomepageSection(models.Model):
(TYPE_PROBLEMS, "Problems / Value Proposition"), (TYPE_PROBLEMS, "Problems / Value Proposition"),
(TYPE_ABOUT_STRIP, "About Strip"), (TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_SUPPORTERS, "Supporters"), (TYPE_SUPPORTERS, "Supporters"),
(TYPE_VIDEO, "Video"),
] ]
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES) section_type = models.CharField(max_length=30, choices=TYPE_CHOICES)
@@ -74,6 +77,11 @@ class HomepageSection(models.Model):
verbose_name = "Homepage Section" verbose_name = "Homepage Section"
verbose_name_plural = "Homepage Sections" 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): def __str__(self):
return f"[{self.get_section_type_display()}] {self.title or self.badge}" 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)'}" return f"{self.section} {self.title or self.icon or '(item)'}"
class AboutSection(models.Model): class AboutSection(VideoBlockMixin, models.Model):
TYPE_HERO = "hero" TYPE_HERO = "hero"
TYPE_INTRO = "intro" TYPE_INTRO = "intro"
TYPE_GRID = "grid" TYPE_GRID = "grid"
TYPE_HISTORY = "history" TYPE_HISTORY = "history"
TYPE_CUSTOM = "custom" TYPE_CUSTOM = "custom"
TYPE_SUPPORTERS = "supporters" TYPE_SUPPORTERS = "supporters"
TYPE_VIDEO = "video"
TYPE_CHOICES = [ TYPE_CHOICES = [
(TYPE_HERO, "Hero"), (TYPE_HERO, "Hero"),
@@ -111,6 +120,7 @@ class AboutSection(models.Model):
(TYPE_HISTORY, "History Block"), (TYPE_HISTORY, "History Block"),
(TYPE_CUSTOM, "Custom Content"), (TYPE_CUSTOM, "Custom Content"),
(TYPE_SUPPORTERS, "Supporters"), (TYPE_SUPPORTERS, "Supporters"),
(TYPE_VIDEO, "Video"),
] ]
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM) 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): def rendered_content(self):
return render_content(self.content, self.content_format) 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): def __str__(self):
label = self.title or self.badge or self.get_section_type_display() label = self.title or self.badge or self.get_section_type_display()
return f"[{self.get_section_type_display()}] {label}" return f"[{self.get_section_type_display()}] {label}"
@@ -295,7 +310,7 @@ class CustomPage(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class CustomPageSection(models.Model): class CustomPageSection(VideoBlockMixin, models.Model):
TYPE_HERO = "hero" TYPE_HERO = "hero"
TYPE_INTRO = "intro" TYPE_INTRO = "intro"
TYPE_GRID = "grid" TYPE_GRID = "grid"
@@ -309,6 +324,7 @@ class CustomPageSection(models.Model):
TYPE_SUPPORTERS = "supporters" TYPE_SUPPORTERS = "supporters"
TYPE_ABOUT_STRIP = "about_strip" TYPE_ABOUT_STRIP = "about_strip"
TYPE_FAQ = "faq" TYPE_FAQ = "faq"
TYPE_VIDEO = "video"
TYPE_CHOICES = [ TYPE_CHOICES = [
(TYPE_HERO, "Hero"), (TYPE_HERO, "Hero"),
@@ -324,6 +340,7 @@ class CustomPageSection(models.Model):
(TYPE_SUPPORTERS, "Supporters"), (TYPE_SUPPORTERS, "Supporters"),
(TYPE_ABOUT_STRIP, "About Strip"), (TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_FAQ, "FAQ Accordion"), (TYPE_FAQ, "FAQ Accordion"),
(TYPE_VIDEO, "Video"),
] ]
page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections") page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections")
@@ -348,11 +365,46 @@ class CustomPageSection(models.Model):
def rendered_content(self): def rendered_content(self):
return render_content(self.content, self.content_format) 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): def __str__(self):
label = self.title or self.badge or self.get_section_type_display() label = self.title or self.badge or self.get_section_type_display()
return f"{self.page} [{self.get_section_type_display()}] {label}" 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): class CustomPageSectionItem(models.Model):
page = models.ForeignKey( page = models.ForeignKey(
CustomPage, CustomPage,
+187
View File
@@ -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, "<br>")
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, "<strong>Bold</strong>")
+17 -1
View File
@@ -17,6 +17,7 @@ from .models import (
FAQEntry, FAQEntry,
HeroSection, HeroSection,
HomepageSection, HomepageSection,
PageVideo,
) )
@@ -71,6 +72,14 @@ class FAQView(ListView):
context_object_name = "faq_entries" context_object_name = "faq_entries"
queryset = FAQEntry.objects.filter(is_active=True) 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): class ContactView(View):
template_name = "pages/contact.html" template_name = "pages/contact.html"
@@ -84,7 +93,14 @@ class ContactView(View):
return render( return render(
request, request,
self.template_name, 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): def post(self, request, *args, **kwargs):
+96 -7
View File
@@ -1,6 +1,9 @@
from django.contrib import admin 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): class ArticleCitationInline(admin.TabularInline):
@@ -37,7 +40,10 @@ class MainProductArticleInline(admin.StackedInline):
RELEASE_VERSION_INLINE_FIELDS = ( RELEASE_VERSION_INLINE_FIELDS = (
"version", "version",
"is_featured_stable", "release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
"windows_download_url", "windows_download_url",
"macos_download_url", "macos_download_url",
"linux_download_url", "linux_download_url",
@@ -67,6 +73,28 @@ class MainProductVersionInline(admin.TabularInline):
show_change_link = True 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): class SubProductInline(admin.StackedInline):
model = SubProduct model = SubProduct
extra = 0 extra = 0
@@ -83,7 +111,7 @@ class MainProductAdmin(admin.ModelAdmin):
search_fields = ("name", "description") search_fields = ("name", "description")
prepopulated_fields = {"slug": ("name",)} prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
inlines = [MainProductArticleInline, MainProductVersionInline, SubProductInline] inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline]
fieldsets = ( fieldsets = (
( (
None, 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) @admin.register(SubProduct)
class SubProductAdmin(admin.ModelAdmin): class SubProductAdmin(admin.ModelAdmin):
list_display = ( list_display = (
@@ -124,7 +174,7 @@ class SubProductAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)} prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
raw_id_fields = ("main_product",) raw_id_fields = ("main_product",)
inlines = [SubProductArticleInline, SubProductVersionInline] inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline]
fieldsets = ( fieldsets = (
( (
None, None,
@@ -173,6 +223,33 @@ class ArticleAdmin(admin.ModelAdmin):
return "" 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) @admin.register(ArticleSection)
class ArticleSectionAdmin(admin.ModelAdmin): class ArticleSectionAdmin(admin.ModelAdmin):
list_display = ("title", "article", "value_format", "order") list_display = ("title", "article", "value_format", "order")
@@ -191,11 +268,14 @@ class SubProductVersionAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"parent", "parent",
"version", "version",
"is_featured_stable", "release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
"is_active", "is_active",
"order", "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") search_fields = ("main_product__name", "sub_product__name", "version")
list_editable = ("is_active", "order") list_editable = ("is_active", "order")
raw_id_fields = ("main_product", "sub_product") raw_id_fields = ("main_product", "sub_product")
@@ -203,11 +283,20 @@ class SubProductVersionAdmin(admin.ModelAdmin):
( (
None, 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": ( "fields": (
"main_product", "main_product",
"sub_product", "sub_product",
"version", "version",
"is_featured_stable", "release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
) )
}, },
), ),
@@ -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')],
},
),
]
@@ -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).'),
),
]
@@ -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),
),
]
@@ -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",
),
]
+124 -2
View File
@@ -6,6 +6,7 @@ from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content
from apps.core.video import VideoBlockMixin
from .release_assets import ( from .release_assets import (
FILE_FIELD_BY_ASSET_KEY, FILE_FIELD_BY_ASSET_KEY,
@@ -36,6 +37,35 @@ DISTRIBUTION_CHOICES = [
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"), (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): class MainProduct(models.Model):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
@@ -280,9 +310,25 @@ class SubProductVersion(models.Model):
blank=True, blank=True,
) )
version = models.CharField(max_length=80) 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, 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) windows_download_url = models.URLField(blank=True)
macos_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 parent = self.sub_product or self.main_product
return f"{parent.name} v{self.version}" 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): def save(self, *args, **kwargs):
for file_field, name_field in RELEASE_ORIGINAL_FILENAME_FIELDS.items(): for file_field, name_field in RELEASE_ORIGINAL_FILENAME_FIELDS.items():
field_file = getattr(self, file_field) field_file = getattr(self, file_field)
@@ -476,3 +547,54 @@ class ArticleCitation(models.Model):
def __str__(self): def __str__(self):
return f"{self.article.title} — citation {self.order or self.pk}" 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}"
+56 -23
View File
@@ -23,10 +23,14 @@ def downloads_section_link(product):
def featured_version(qs): def featured_version(qs):
ordered = qs.order_by("order", "pk") 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() 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): def install_specs_from_versions(version_list):
present = set() present = set()
for ver in version_list: 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) 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): def build_release_context(distribution, active_versions, product):
context = { context = {
"distribution_installable": distribution == DISTRIBUTION_INSTALLABLE, "distribution_installable": distribution == DISTRIBUTION_INSTALLABLE,
"distribution_package": distribution == DISTRIBUTION_PACKAGE, "distribution_package": distribution == DISTRIBUTION_PACKAGE,
"show_releases_section": False, "show_releases_section": False,
"featured_version": None, "featured_version": None,
"featured_channel": None,
"featured_install_cells": [], "featured_install_cells": [],
"inline_download_channels": [],
"show_older_versions_link": False, "show_older_versions_link": False,
"package_versions": [], "package_versions": [],
**package_button_labels(product), **package_button_labels(product),
@@ -51,30 +71,36 @@ def build_release_context(distribution, active_versions, product):
if distribution == DISTRIBUTION_INSTALLABLE: if distribution == DISTRIBUTION_INSTALLABLE:
featured = featured_version(active_versions) featured = featured_version(active_versions)
if ( if featured and not version_has_public_assets(featured):
featured
and not featured.has_any_install_asset()
and not (featured.package_resource_url or "").strip()
):
for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"): 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 featured = cand
break break
context["featured_version"] = featured context["featured_version"] = featured
if featured and ( if featured and version_has_public_assets(featured):
featured.has_any_install_asset() featured_block = build_installable_channel_block(featured)
or (featured.package_resource_url or "").strip() if featured_block:
): context["featured_channel"] = featured_block
if featured.has_any_install_asset(): context["featured_install_cells"] = featured_block["install_cells"]
specs = install_specs_from_versions([featured]) context["show_releases_section"] = True
context["featured_install_cells"] = featured.install_urls_for_specs(specs)
context["show_releases_section"] = True inline_blocks = []
older_list = list( for ver in active_versions.order_by("order", "pk"):
active_versions.exclude(pk=featured.pk).order_by("order", "pk") if featured and ver.pk == featured.pk:
if featured continue
else active_versions.order_by("order", "pk") if not ver.show_on_product_page:
) continue
context["show_older_versions_link"] = len(older_list) > 0 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: elif distribution == DISTRIBUTION_PACKAGE:
pkg_versions = list(active_versions.order_by("order", "pk")) 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): def build_archive_context(distribution, active_versions, product):
featured = featured_version(active_versions) 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( archive = list(
active_versions.exclude(pk=featured.pk).order_by("order", "pk") active_versions.exclude(pk__in=exclude_pks).order_by("order", "pk")
if featured if exclude_pks
else active_versions.order_by("order", "pk") else active_versions.order_by("order", "pk")
) )
context = { context = {
+23
View File
@@ -213,6 +213,29 @@ class SubProductVersionModelTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
version.full_clean() 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): class ArticleSectionModelTest(TestCase):
def setUp(self): def setUp(self):
+47 -3
View File
@@ -160,14 +160,15 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
SubProductVersion.objects.create( SubProductVersion.objects.create(
sub_product=self.sub_product, sub_product=self.sub_product,
version="9.9", version="9.9",
is_featured_stable=True, is_featured=True,
release_channel="stable",
windows_download_url="https://example.com/w", windows_download_url="https://example.com/w",
is_active=True, is_active=True,
) )
SubProductVersion.objects.create( SubProductVersion.objects.create(
sub_product=self.sub_product, sub_product=self.sub_product,
version="9.8", version="9.8",
is_featured_stable=False, is_featured=False,
windows_download_url="https://example.com/w2", windows_download_url="https://example.com/w2",
is_active=True, is_active=True,
) )
@@ -185,7 +186,8 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
SubProductVersion.objects.create( SubProductVersion.objects.create(
main_product=self.main_product, main_product=self.main_product,
version="9.9", version="9.9",
is_featured_stable=True, is_featured=True,
release_channel="stable",
windows_download_url="https://example.com/win.exe", windows_download_url="https://example.com/win.exe",
is_active=True, is_active=True,
) )
@@ -215,6 +217,48 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
response = self.client.get(url) response = self.client.get(url)
self.assertTrue(response.context["show_releases_section"]) 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") @override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases")
class ReleaseAssetDownloadViewTest(ProductViewsSetup): class ReleaseAssetDownloadViewTest(ProductViewsSetup):
+3 -1
View File
@@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404
from django.views import View from django.views import View
from django.views.generic import DetailView, ListView, TemplateView 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_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 from .release_context import build_archive_context, build_release_context
@@ -58,6 +58,7 @@ class MainProductDetailView(DetailView):
context["articles"] = self.object.articles.prefetch_related( context["articles"] = self.object.articles.prefetch_related(
"sections", "citations" "sections", "citations"
).all() ).all()
context["product_videos"] = self.object.videos.filter(is_active=True).order_by("order")
release_context = build_release_context( release_context = build_release_context(
self.object.distribution, self.object.distribution,
self.object.versions.filter(is_active=True), self.object.versions.filter(is_active=True),
@@ -111,6 +112,7 @@ class SubProductDetailView(TemplateView):
context["articles"] = sub_product.articles.prefetch_related( context["articles"] = sub_product.articles.prefetch_related(
"sections", "citations" "sections", "citations"
).all() ).all()
context["product_videos"] = sub_product.videos.filter(is_active=True).order_by("order")
context["siblings"] = ( context["siblings"] = (
SubProduct.objects.filter(main_product=main_product, is_active=True) SubProduct.objects.filter(main_product=main_product, is_active=True)
.exclude(pk=sub_product.pk) .exclude(pk=sub_product.pk)
+280 -1
View File
@@ -1995,12 +1995,48 @@ a.citation-count-badge:hover {
border: 1px solid rgba(34, 197, 94, 0.2); 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 { .release-channel-label--previous {
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--glass-border); 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 { .download-card--previous {
opacity: 0.78; opacity: 0.78;
} }
@@ -2255,9 +2291,22 @@ a.citation-count-badge:hover {
flex-shrink: 0; flex-shrink: 0;
} }
.pkg-stable-inline { .pkg-version-meta .release-channel-label {
font-size: 0.7rem; font-size: 0.7rem;
padding: 0.2rem 0.55rem; 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 { .pkg-version-actions {
@@ -2350,10 +2399,29 @@ a.citation-count-badge:hover {
.versions-table-link { .versions-table-link {
display: inline-flex; display: inline-flex;
align-items: center; 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 { .versions-table-icon {
display: block; 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 { .versions-table-text-link {
@@ -3440,3 +3508,214 @@ a.citation-count-badge:hover {
.readmore-btn.is-open svg { .readmore-btn.is-open svg {
transform: rotate(180deg); 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;
}
}
+2
View File
@@ -20,6 +20,8 @@
</div> </div>
</section> </section>
{% include "partials/_page_videos.html" with videos=contact_videos %}
<section class="section" aria-labelledby="contact-form-heading"> <section class="section" aria-labelledby="contact-form-heading">
<div class="container"> <div class="container">
<div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}"> <div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}">
+2
View File
@@ -20,6 +20,8 @@
</div> </div>
</section> </section>
{% include "partials/_page_videos.html" with videos=faq_videos %}
<section class="section" aria-labelledby="faq-list-heading"> <section class="section" aria-labelledby="faq-list-heading">
<div class="container"> <div class="container">
<h2 class="sr-only" id="faq-list-heading">FAQ List</h2> <h2 class="sr-only" id="faq-list-heading">FAQ List</h2>
+3
View File
@@ -411,6 +411,9 @@
</div> </div>
</section> </section>
{% elif section.section_type == "video" %}
{% include "partials/_video_section.html" with section=section %}
{% endif %} {% endif %}
{% empty %} {% empty %}
<section class="section"> <section class="section">
+15
View File
@@ -0,0 +1,15 @@
{% if videos %}
<section class="section product-videos-section" aria-label="Product videos">
<div class="container">
<div class="product-videos-list">
{% for video in videos %}
{% if video.has_video %}
<div class="product-video-item">
{% include "partials/_video_block.html" with video=video compact_header=True %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</section>
{% endif %}
+55
View File
@@ -0,0 +1,55 @@
{% if video.has_video %}
{% if video.video_styled_background %}
<div class="video-panel video-panel--styled fade-in">
<div class="video-panel-ambient" aria-hidden="true">
<div class="video-panel-blob video-panel-blob--1"></div>
<div class="video-panel-blob video-panel-blob--2"></div>
<div class="video-panel-blob video-panel-blob--3"></div>
</div>
<div class="video-panel-inner">
{% if video.badge or video.title %}
<div class="video-panel-header">
<div class="video-panel-heading">
{% if video.badge %}<div class="section-badge">{{ video.badge }}</div>{% endif %}
{% if video.title %}
<h2 class="video-panel-title" id="video-heading-{{ video.pk }}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
{{ video.title }}
</h2>
{% else %}
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
{% endif %}
</div>
</div>
{% else %}
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
{% endif %}
{% if video.description %}
<div class="rich-content section-desc video-panel-desc">{{ video.rendered_description }}</div>
{% elif video.content %}
<div class="rich-content section-desc video-panel-desc">{{ video.rendered_content }}</div>
{% endif %}
{% include "partials/_video_player.html" with video=video %}
</div>
</div>
{% else %}
{% if video.badge or video.title %}
<div class="section-header{% if compact_header %} product-video-header{% endif %}">
{% if video.badge %}<div class="section-badge">{{ video.badge }}</div>{% endif %}
{% if video.title %}
<h2 class="section-title" id="video-heading-{{ video.pk }}">{{ video.title }}</h2>
{% else %}
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
{% endif %}
</div>
{% endif %}
{% if video.description %}
<div class="rich-content section-desc">{{ video.rendered_description }}</div>
{% elif video.content %}
<div class="rich-content section-desc">{{ video.rendered_content }}</div>
{% endif %}
{% include "partials/_video_player.html" with video=video %}
{% endif %}
{% endif %}
+26
View File
@@ -0,0 +1,26 @@
{% if video.has_video %}
<div class="video-block {{ video.video_size_class }} {{ video.video_aspect_class }} fade-in">
<div class="video-block-inner glass-card">
{% if video.video_source == "upload" and video.video_file %}
<video
controls
playsinline
preload="metadata"
{% if video.video_poster %}poster="{{ video.video_poster.url }}"{% endif %}
title="{{ video.title|default:'Video' }}"
>
<source src="{{ video.video_file.url }}" />
</video>
{% elif video.youtube_embed_url %}
<iframe
src="{{ video.youtube_embed_url }}"
title="{{ video.title|default:'Video' }}"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
{% endif %}
</div>
</div>
{% endif %}
+7
View File
@@ -0,0 +1,7 @@
{% if section.has_video %}
<section class="section video-section" aria-labelledby="video-heading-{{ section.pk }}">
<div class="container">
{% include "partials/_video_block.html" with video=section %}
</div>
</section>
{% endif %}
@@ -0,0 +1,34 @@
{% load static %}
<div class="sub-downloads-channel{% if channel.is_previous %} sub-downloads-channel--prev{% endif %}">
{% include "products/_release_channel_badge.html" with version=channel.version %}
{% if channel.install_cells %}
<div class="sub-downloads-grid">
{% for cell in channel.install_cells %}
<div class="sub-dl-item">
<div class="sub-dl-icon-wrap">
{% if cell.icon %}
<img src="{% static cell.icon %}" alt="" width="32" height="32" class="platform-icon sub-dl-icon" aria-hidden="true" />
{% else %}
<span class="sub-dl-source-mark" aria-hidden="true">
<svg class="sub-dl-source-svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
</span>
{% endif %}
</div>
<span class="sub-dl-platform">{{ cell.label }}</span>
<span class="download-version">{{ channel.version.version }}</span>
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
</div>
{% endfor %}
</div>
{% endif %}
{% if channel.version.package_resource_url %}
<p class="sub-install-extra-resource">
<a href="{{ channel.version.package_resource_url }}" target="_blank" rel="noopener noreferrer">Package resource</a>
</p>
{% endif %}
{% if channel.version.release_notes %}
<p class="sub-release-notes">{{ channel.version.release_notes }}</p>
{% endif %}
</div>
@@ -0,0 +1,10 @@
{% if version.display_channel_label %}
<span class="release-channel-label release-channel-label--{{ version.display_channel_css_modifier }}">
{% if version.release_channel == "stable" %}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
{% endif %}
{{ version.display_channel_label }}
</span>
{% endif %}
+6 -48
View File
@@ -14,48 +14,13 @@
{% endif %} {% endif %}
</div> </div>
<div class="sub-downloads-channel"> {% if featured_version %}
{% if featured_version.is_featured_stable %} {% include "products/_installable_download_channel.html" with channel=featured_channel %}
<span class="release-channel-label release-channel-label--stable">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Stable
</span>
{% endif %}
{% if featured_install_cells %}
<div class="sub-downloads-grid">
{% for cell in featured_install_cells %}
<div class="sub-dl-item">
<div class="sub-dl-icon-wrap">
{% if cell.icon %}
<img src="{% static cell.icon %}" alt="" width="32" height="32" class="platform-icon sub-dl-icon" aria-hidden="true" />
{% else %}
<span class="sub-dl-source-mark" aria-hidden="true">
<svg class="sub-dl-source-svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
</span>
{% endif %}
</div>
<span class="sub-dl-platform">{{ cell.label }}</span>
<span class="download-version">{{ featured_version.version }}</span>
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if featured_version.package_resource_url %}
<p class="sub-install-extra-resource">
<a href="{{ featured_version.package_resource_url }}" target="_blank" rel="noopener noreferrer">Package resource</a>
</p>
{% endif %} {% endif %}
{% if featured_version.release_notes %} {% for channel in inline_download_channels %}
<p class="sub-release-notes">{{ featured_version.release_notes }}</p> {% include "products/_installable_download_channel.html" %}
{% endif %} {% endfor %}
{% if show_older_versions_link %} {% if show_older_versions_link %}
<p class="sub-older-versions-inner"> <p class="sub-older-versions-inner">
@@ -83,14 +48,7 @@
<li class="pkg-version-row"> <li class="pkg-version-row">
<div class="pkg-version-meta"> <div class="pkg-version-meta">
<span class="download-version">{{ ver.version }}</span> <span class="download-version">{{ ver.version }}</span>
{% if ver.is_featured_stable %} {% include "products/_release_channel_badge.html" with version=ver %}
<span class="release-channel-label release-channel-label--stable pkg-stable-inline">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Stable
</span>
{% endif %}
</div> </div>
<div class="pkg-version-actions"> <div class="pkg-version-actions">
{% if ver.package_resource_url %} {% if ver.package_resource_url %}
@@ -15,7 +15,10 @@
<tbody> <tbody>
{% for row in archive_rows_installable %} {% for row in archive_rows_installable %}
<tr> <tr>
<th scope="row">{{ row.version_obj.version }}</th> <th scope="row">
<span class="versions-table-version">{{ row.version_obj.version }}</span>
{% include "products/_release_channel_badge.html" with version=row.version_obj %}
</th>
{% for cell in row.cells %} {% for cell in row.cells %}
<td> <td>
{% if cell.url %} {% if cell.url %}
@@ -50,7 +53,10 @@
{% for ver in archive_versions %} {% for ver in archive_versions %}
<li class="versions-package-card glass-card fade-in"> <li class="versions-package-card glass-card fade-in">
<div class="versions-package-head"> <div class="versions-package-head">
<span class="download-version">{{ ver.version }}</span> <div class="pkg-version-meta">
<span class="download-version">{{ ver.version }}</span>
{% include "products/_release_channel_badge.html" with version=ver %}
</div>
{% if ver.package_resource_url %} {% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a> <a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
{% endif %} {% endif %}
+2
View File
@@ -53,6 +53,8 @@
</div> </div>
</section> </section>
{% include "partials/_page_videos.html" with videos=product_videos %}
{% if articles %} {% if articles %}
<section class="section" aria-label="Product articles"> <section class="section" aria-label="Product articles">
<div class="container"> <div class="container">
+2
View File
@@ -49,6 +49,8 @@
{% include "products/_releases_section.html" %} {% include "products/_releases_section.html" %}
{% include "partials/_page_videos.html" with videos=product_videos %}
{% include "products/_article_list.html" %} {% include "products/_article_list.html" %}
</main> </main>