feat: dynamic main icon

This commit is contained in:
mohamad
2026-05-25 15:57:24 +03:30
parent 4875d8b6c3
commit f08d0aacc0
38 changed files with 1565 additions and 540 deletions
+1
View File
@@ -47,3 +47,4 @@ venv.bak/
media/ media/
staticfiles/ staticfiles/
*.DS_Store
+92
View File
@@ -0,0 +1,92 @@
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.urls import reverse
from .models import SiteBranding, SiteContact
@admin.register(SiteBranding)
class SiteBrandingAdmin(admin.ModelAdmin):
fieldsets = (
("Icon", {"fields": ("icon", "icon_alt")}),
(
"Sizes",
{
"fields": ("navbar_icon_size", "footer_icon_size"),
"description": "Square dimensions in pixels for each placement.",
},
),
(
"Appearance",
{
"fields": (
"object_fit",
"show_border",
"border_width",
"border_color",
),
},
),
)
def has_add_permission(self, request):
return not SiteBranding.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
branding = SiteBranding.objects.first()
if branding:
return HttpResponseRedirect(
reverse("admin:core_sitebranding_change", args=[branding.pk])
)
return super().changelist_view(request, extra_context)
@admin.register(SiteContact)
class SiteContactAdmin(admin.ModelAdmin):
fieldsets = (
(
"Email",
{
"fields": (
"support_email",
"email_card_title",
"email_card_description",
),
},
),
(
"Discord",
{
"fields": (
"discord_url",
"discord_label",
"discord_card_title",
"discord_card_description",
),
},
),
(
"Office address",
{
"fields": ("office_address", "office_card_title"),
"description": "Enter one address line per row.",
},
),
)
def has_add_permission(self, request):
return not SiteContact.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
contact = SiteContact.objects.first()
if contact:
return HttpResponseRedirect(
reverse("admin:core_sitecontact_change", args=[contact.pk])
)
return super().changelist_view(request, extra_context)
+24 -8
View File
@@ -1,19 +1,35 @@
from django.db.models import Prefetch
from apps.core.models import SiteBranding, SiteContact
from apps.products.models import MainProduct, SubProduct from apps.products.models import MainProduct, SubProduct
def site_branding(request):
return {"site_branding": SiteBranding.load()}
def site_contact(request):
return {"site_contact": SiteContact.load()}
def navigation(request): def navigation(request):
main_products = ( main_products = (
MainProduct.objects.filter(is_active=True) MainProduct.objects.filter(is_active=True)
.prefetch_related("sub_products") .prefetch_related(
Prefetch(
"sub_products",
queryset=SubProduct.objects.filter(is_active=True).order_by(
"order", "name"
),
)
)
.order_by("order", "name") .order_by("order", "name")
) )
all_sub_products = list( all_footer_products = list(main_products[:5])
SubProduct.objects.filter(is_active=True).order_by("order", "name")[:5] footer_main_products = all_footer_products[:4]
) footer_main_products_has_more = len(all_footer_products) == 5
footer_sub_products = all_sub_products[:4]
footer_sub_products_has_more = len(all_sub_products) == 5
return { return {
"nav_main_products": main_products, "nav_main_products": main_products,
"footer_sub_products": footer_sub_products, "footer_main_products": footer_main_products,
"footer_sub_products_has_more": footer_sub_products_has_more, "footer_main_products_has_more": footer_main_products_has_more,
} }
+40 -3
View File
@@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from apps.core.models import SiteContact
from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
@@ -254,9 +255,9 @@ FAQ_ENTRIES = [
{ {
"question": "Where can I get support or report issues?", "question": "Where can I get support or report issues?",
"answer": ( "answer": (
"Support is available via email at support@radiuma.com and through our " "Support is available via email and through our community Discord server "
"community Discord server. For bug reports and feature requests, please " "(see the Contact page for current details). For bug reports and feature "
"use the Discord forum or contact us directly by email." "requests, please use the Discord forum or contact us directly by email."
), ),
"order": 6, "order": 6,
}, },
@@ -402,6 +403,7 @@ class Command(BaseCommand):
self._seed_downloads() self._seed_downloads()
self._seed_homepage_sections() self._seed_homepage_sections()
self._seed_hero() self._seed_hero()
self._seed_site_contact()
self.stdout.write(self.style.SUCCESS("Content seeded successfully.")) self.stdout.write(self.style.SUCCESS("Content seeded successfully."))
@@ -540,3 +542,38 @@ class Command(BaseCommand):
setattr(hero, field, value) setattr(hero, field, value)
hero.save() hero.save()
self.stdout.write(" Updated hero section") self.stdout.write(" Updated hero section")
def _seed_site_contact(self):
contact, created = SiteContact.objects.get_or_create(
pk=1,
defaults={
"support_email": "support@radiuma.com",
"discord_url": "https://discord.gg/9XxA6pV9hb",
"email_card_description": "For direct software support:",
"discord_card_description": "Join for community support and announcements.",
"office_address": (
"BC Cancer Research Center\n"
"675 West 10th Ave, Office 6-112\n"
"Vancouver, BC, V5Z 1L3\n"
"Canada"
),
},
)
if not created:
updates = {
"support_email": "support@radiuma.com",
"discord_url": "https://discord.gg/9XxA6pV9hb",
"email_card_description": "For direct software support:",
"discord_card_description": "Join for community support and announcements.",
"office_address": (
"BC Cancer Research Center\n"
"675 West 10th Ave, Office 6-112\n"
"Vancouver, BC, V5Z 1L3\n"
"Canada"
),
}
for field, value in updates.items():
setattr(contact, field, value)
contact.save()
action = "Created" if created else "Updated"
self.stdout.write(f" {action} site contact")
@@ -0,0 +1,32 @@
# Generated by Django 5.0.2 on 2026-05-25 09:39
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='SiteBranding',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('icon', models.ImageField(blank=True, help_text='Logo shown in the site header and footer. Leave empty to use the default static icon.', null=True, upload_to='branding/')),
('icon_alt', models.CharField(blank=True, help_text='Alt text for the brand icon (decorative icons can stay empty).', max_length=200)),
('navbar_icon_size', models.PositiveSmallIntegerField(default=26, help_text='Width and height in pixels for the header icon.')),
('footer_icon_size', models.PositiveSmallIntegerField(default=34, help_text='Width and height in pixels for the footer icon.')),
('show_border', models.BooleanField(default=False, help_text='Draw a border around the brand icon.')),
('border_color', models.CharField(default='#c4b5fd', help_text='Border color as hex (e.g. #c4b5fd).', max_length=7)),
('border_width', models.PositiveSmallIntegerField(default=1, help_text='Border width in pixels.')),
('object_fit', models.CharField(choices=[('cover', 'Cover (fill square, may crop)'), ('contain', 'Contain (fit inside square)')], default='cover', max_length=10)),
],
options={
'verbose_name': 'Site Branding',
'verbose_name_plural': 'Site Branding',
},
),
]
+52
View File
@@ -0,0 +1,52 @@
# Generated by Django 5.0.2 on 2026-05-25 11:11
from django.db import migrations, models
def seed_site_contact(apps, schema_editor):
SiteContact = apps.get_model("core", "SiteContact")
SiteContact.objects.get_or_create(
pk=1,
defaults={
"support_email": "support@radiuma.com",
"discord_url": "https://discord.gg/9XxA6pV9hb",
"office_address": (
"BC Cancer Research Center\n"
"675 West 10th Ave, Office 6-112\n"
"Vancouver, BC, V5Z 1L3\n"
"Canada"
),
"email_card_description": "For direct software support:",
"discord_card_description": "Join for community support and announcements.",
},
)
class Migration(migrations.Migration):
dependencies = [
('core', '0001_site_branding'),
]
operations = [
migrations.CreateModel(
name='SiteContact',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('support_email', models.EmailField(blank=True, help_text='Shown in the footer and on the contact page. Hidden when empty.', max_length=254)),
('discord_url', models.URLField(blank=True, help_text='Discord invite link. Hidden when empty.')),
('discord_label', models.CharField(blank=True, help_text='Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.', max_length=100)),
('office_address', models.TextField(blank=True, help_text='Physical address; one line per row. Hidden when empty.')),
('email_card_title', models.CharField(blank=True, help_text='Contact page email card heading. Defaults to “Email Support”.', max_length=200)),
('email_card_description', models.CharField(blank=True, help_text='Short text under the email card heading on the contact page.', max_length=500)),
('discord_card_title', models.CharField(blank=True, help_text='Contact page Discord card heading. Defaults to “Discord Community”.', max_length=200)),
('discord_card_description', models.CharField(blank=True, help_text='Short text under the Discord card heading on the contact page.', max_length=500)),
('office_card_title', models.CharField(blank=True, help_text='Contact page office card heading. Defaults to “Office”.', max_length=200)),
],
options={
'verbose_name': 'Site Contact',
'verbose_name_plural': 'Site Contact',
},
),
migrations.RunPython(seed_site_contact, migrations.RunPython.noop),
]
View File
+177
View File
@@ -0,0 +1,177 @@
from django.db import models
class SiteBranding(models.Model):
OBJECT_FIT_COVER = "cover"
OBJECT_FIT_CONTAIN = "contain"
OBJECT_FIT_CHOICES = [
(OBJECT_FIT_COVER, "Cover (fill square, may crop)"),
(OBJECT_FIT_CONTAIN, "Contain (fit inside square)"),
]
icon = models.ImageField(
upload_to="branding/",
blank=True,
null=True,
help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.",
)
icon_alt = models.CharField(
max_length=200,
blank=True,
help_text="Alt text for the brand icon (decorative icons can stay empty).",
)
navbar_icon_size = models.PositiveSmallIntegerField(
default=26,
help_text="Width and height in pixels for the header icon.",
)
footer_icon_size = models.PositiveSmallIntegerField(
default=34,
help_text="Width and height in pixels for the footer icon.",
)
show_border = models.BooleanField(
default=False,
help_text="Draw a border around the brand icon.",
)
border_color = models.CharField(
max_length=7,
default="#c4b5fd",
help_text="Border color as hex (e.g. #c4b5fd).",
)
border_width = models.PositiveSmallIntegerField(
default=1,
help_text="Border width in pixels.",
)
object_fit = models.CharField(
max_length=10,
choices=OBJECT_FIT_CHOICES,
default=OBJECT_FIT_COVER,
)
class Meta:
verbose_name = "Site Branding"
verbose_name_plural = "Site Branding"
def __str__(self):
return "Site Branding"
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def icon_style(self, size_px):
parts = [
f"width:{size_px}px",
f"height:{size_px}px",
f"object-fit:{self.object_fit}",
]
if self.show_border:
parts.append(f"border:{self.border_width}px solid {self.border_color}")
return ";".join(parts)
@property
def navbar_icon_style(self):
return self.icon_style(self.navbar_icon_size)
@property
def footer_icon_style(self):
return self.icon_style(self.footer_icon_size)
class SiteContact(models.Model):
support_email = models.EmailField(
blank=True,
help_text="Shown in the footer and on the contact page. Hidden when empty.",
)
discord_url = models.URLField(
blank=True,
help_text="Discord invite link. Hidden when empty.",
)
discord_label = models.CharField(
max_length=100,
blank=True,
help_text="Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.",
)
office_address = models.TextField(
blank=True,
help_text="Physical address; one line per row. Hidden when empty.",
)
email_card_title = models.CharField(
max_length=200,
blank=True,
help_text="Contact page email card heading. Defaults to “Email Support”.",
)
email_card_description = models.CharField(
max_length=500,
blank=True,
help_text="Short text under the email card heading on the contact page.",
)
discord_card_title = models.CharField(
max_length=200,
blank=True,
help_text="Contact page Discord card heading. Defaults to “Discord Community”.",
)
discord_card_description = models.CharField(
max_length=500,
blank=True,
help_text="Short text under the Discord card heading on the contact page.",
)
office_card_title = models.CharField(
max_length=200,
blank=True,
help_text="Contact page office card heading. Defaults to “Office”.",
)
class Meta:
verbose_name = "Site Contact"
verbose_name_plural = "Site Contact"
def __str__(self):
return "Site Contact"
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
@property
def discord_label_display(self):
return self.discord_label.strip() or "Join Discord"
@property
def office_address_lines(self):
if not self.office_address.strip():
return []
return [line.strip() for line in self.office_address.splitlines() if line.strip()]
@property
def has_support_email(self):
return bool(self.support_email)
@property
def has_discord(self):
return bool(self.discord_url)
@property
def has_office_address(self):
return bool(self.office_address_lines)
@property
def has_footer_contact(self):
return self.has_support_email or self.has_office_address
@property
def has_contact_sidebar(self):
return self.has_support_email or self.has_discord or self.has_office_address
@property
def email_card_title_display(self):
return self.email_card_title.strip() or "Email Support"
@property
def discord_card_title_display(self):
return self.discord_card_title.strip() or "Discord Community"
@property
def office_card_title_display(self):
return self.office_card_title.strip() or "Office"
+1
View File
@@ -0,0 +1 @@
+35
View File
@@ -0,0 +1,35 @@
from django.test import RequestFactory, TestCase
from apps.core.context_processors import site_branding
from apps.core.models import SiteBranding
class SiteBrandingModelTests(TestCase):
def test_load_creates_singleton(self):
branding = SiteBranding.load()
self.assertEqual(branding.pk, 1)
self.assertEqual(SiteBranding.objects.count(), 1)
def test_icon_style_includes_border_when_enabled(self):
branding = SiteBranding.load()
branding.show_border = True
branding.border_color = "#ffffff"
branding.border_width = 2
branding.navbar_icon_size = 30
style = branding.navbar_icon_style
self.assertIn("width:30px", style)
self.assertIn("border:2px solid #ffffff", style)
def test_icon_style_omits_border_when_disabled(self):
branding = SiteBranding.load()
branding.show_border = False
self.assertNotIn("border:", branding.navbar_icon_style)
class SiteBrandingContextProcessorTests(TestCase):
def test_site_branding_in_context(self):
SiteBranding.load()
request = RequestFactory().get("/")
ctx = site_branding(request)
self.assertIn("site_branding", ctx)
self.assertIsInstance(ctx["site_branding"], SiteBranding)
+55
View File
@@ -0,0 +1,55 @@
from django.test import Client, RequestFactory, TestCase
from apps.core.context_processors import site_contact
from apps.core.models import SiteContact
class SiteContactModelTests(TestCase):
def test_office_address_lines_skips_blank_lines(self):
contact = SiteContact.load()
contact.office_address = "Line one\n\nLine two"
self.assertEqual(contact.office_address_lines, ["Line one", "Line two"])
def test_has_footer_contact_requires_email_or_office(self):
contact = SiteContact.load()
contact.support_email = ""
contact.office_address = ""
contact.discord_url = "https://discord.gg/example"
self.assertFalse(contact.has_footer_contact)
self.assertTrue(contact.has_discord)
def test_discord_label_default(self):
contact = SiteContact.load()
contact.discord_label = ""
self.assertEqual(contact.discord_label_display, "Join Discord")
class SiteContactContextProcessorTests(TestCase):
def test_site_contact_in_context(self):
SiteContact.load()
request = RequestFactory().get("/")
ctx = site_contact(request)
self.assertIn("site_contact", ctx)
self.assertIsInstance(ctx["site_contact"], SiteContact)
class SiteContactTemplateTests(TestCase):
def test_footer_hides_email_when_empty(self):
contact = SiteContact.load()
contact.support_email = ""
contact.discord_url = ""
contact.office_address = ""
contact.save()
response = Client().get("/")
self.assertNotContains(response, "mailto:")
self.assertNotContains(response, "discord.gg")
def test_contact_page_hides_sidebar_when_empty(self):
contact = SiteContact.load()
contact.support_email = ""
contact.discord_url = ""
contact.office_address = ""
contact.save()
response = Client().get("/contact/")
self.assertNotContains(response, "contact-sidebar")
self.assertContains(response, "contact-layout--full")
+56 -17
View File
@@ -17,19 +17,25 @@ class ArticleSectionInline(admin.TabularInline):
ordering = ("order",) ordering = ("order",)
class ArticleInline(admin.StackedInline): class SubProductArticleInline(admin.StackedInline):
model = Article model = Article
fk_name = "sub_product"
extra = 0 extra = 0
fields = ("badge", "title", "description", "order") fields = ("badge", "title", "description", "order")
ordering = ("order",) ordering = ("order",)
show_change_link = True show_change_link = True
class SubProductVersionInline(admin.TabularInline): class MainProductArticleInline(admin.StackedInline):
model = SubProductVersion model = Article
fk_name = "main_product"
extra = 0 extra = 0
ordering = ("order", "version") fields = ("badge", "title", "description", "order")
fields = ( ordering = ("order",)
show_change_link = True
RELEASE_VERSION_INLINE_FIELDS = (
"version", "version",
"is_featured_stable", "is_featured_stable",
"windows_download_url", "windows_download_url",
@@ -43,6 +49,22 @@ class SubProductVersionInline(admin.TabularInline):
) )
class SubProductVersionInline(admin.TabularInline):
model = SubProductVersion
fk_name = "sub_product"
extra = 0
ordering = ("order", "version")
fields = RELEASE_VERSION_INLINE_FIELDS
class MainProductVersionInline(admin.TabularInline):
model = SubProductVersion
fk_name = "main_product"
extra = 0
ordering = ("order", "version")
fields = RELEASE_VERSION_INLINE_FIELDS
class SubProductInline(admin.StackedInline): class SubProductInline(admin.StackedInline):
model = SubProduct model = SubProduct
extra = 0 extra = 0
@@ -59,9 +81,9 @@ 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") list_editable = ("order", "is_active")
inlines = [SubProductInline] inlines = [MainProductArticleInline, MainProductVersionInline, SubProductInline]
fieldsets = ( fieldsets = (
(None, {"fields": ("name", "slug", "short_description")}), (None, {"fields": ("name", "slug", "short_description", "distribution")}),
("Description", {"fields": ("description_format", "description")}), ("Description", {"fields": ("description_format", "description")}),
("Media", {"fields": ("image",)}), ("Media", {"fields": ("image",)}),
("Settings", {"fields": ("order", "is_active")}), ("Settings", {"fields": ("order", "is_active")}),
@@ -85,7 +107,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 = [ArticleInline, SubProductVersionInline] inlines = [SubProductArticleInline, SubProductVersionInline]
fieldsets = ( fieldsets = (
(None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}), (None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}),
("Description", {"fields": ("description_format", "description")}), ("Description", {"fields": ("description_format", "description")}),
@@ -97,19 +119,27 @@ class SubProductAdmin(admin.ModelAdmin):
@admin.register(Article) @admin.register(Article)
class ArticleAdmin(admin.ModelAdmin): class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "sub_product", "order", "created_at") list_display = ("title", "parent", "order", "created_at")
list_filter = ("sub_product__main_product",) list_filter = ("main_product", "sub_product__main_product")
search_fields = ("title", "description") search_fields = ("title", "description", "main_product__name", "sub_product__name")
list_editable = ("order",) list_editable = ("order",)
raw_id_fields = ("sub_product",) raw_id_fields = ("main_product", "sub_product")
inlines = [ArticleSectionInline, ArticleCitationInline] inlines = [ArticleSectionInline, ArticleCitationInline]
fieldsets = ( fieldsets = (
(None, {"fields": ("sub_product", "badge", "title")}), (None, {"fields": ("main_product", "sub_product", "badge", "title")}),
("Description", {"fields": ("description_format", "description")}), ("Description", {"fields": ("description_format", "description")}),
("Citation Badge", {"fields": ("citation_count_display", "citation_count_label"), "description": "Number and optional label for the dashed citation circle badge. Both are optional — a label without a number shows an empty circle with the label; neither hides the badge entirely."}), ("Citation Badge", {"fields": ("citation_count_display", "citation_count_label"), "description": "Number and optional label for the dashed citation circle badge. Both are optional — a label without a number shows an empty circle with the label; neither hides the badge entirely."}),
("Settings", {"fields": ("order",)}), ("Settings", {"fields": ("order",)}),
) )
@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.register(ArticleSection) @admin.register(ArticleSection)
class ArticleSectionAdmin(admin.ModelAdmin): class ArticleSectionAdmin(admin.ModelAdmin):
@@ -127,21 +157,22 @@ class ArticleSectionAdmin(admin.ModelAdmin):
@admin.register(SubProductVersion) @admin.register(SubProductVersion)
class SubProductVersionAdmin(admin.ModelAdmin): class SubProductVersionAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"sub_product", "parent",
"version", "version",
"is_featured_stable", "is_featured_stable",
"is_active", "is_active",
"order", "order",
) )
list_filter = ("is_active", "is_featured_stable", "sub_product__distribution") list_filter = ("is_active", "is_featured_stable", "main_product", "sub_product__main_product")
search_fields = ("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 = ("sub_product",) raw_id_fields = ("main_product", "sub_product")
fieldsets = ( fieldsets = (
( (
None, None,
{ {
"fields": ( "fields": (
"main_product",
"sub_product", "sub_product",
"version", "version",
"is_featured_stable", "is_featured_stable",
@@ -163,3 +194,11 @@ class SubProductVersionAdmin(admin.ModelAdmin):
("Details", {"fields": ("release_notes",)}), ("Details", {"fields": ("release_notes",)}),
("Settings", {"fields": ("is_active", "order")}), ("Settings", {"fields": ("is_active", "order")}),
) )
@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 ""
@@ -0,0 +1,28 @@
# Generated by Django 5.0.2 on 2026-05-23 10:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0008_article_citation_count_label'),
]
operations = [
migrations.AddField(
model_name='article',
name='main_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.mainproduct'),
),
migrations.AlterField(
model_name='article',
name='sub_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'),
),
migrations.AddConstraint(
model_name='article',
constraint=models.CheckConstraint(check=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='article_exactly_one_parent'),
),
]
@@ -0,0 +1,45 @@
# Generated by Django 5.0.2 on 2026-05-23 10:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0009_article_main_product'),
]
operations = [
migrations.AlterUniqueTogether(
name='subproductversion',
unique_together=set(),
),
migrations.AddField(
model_name='mainproduct',
name='distribution',
field=models.CharField(choices=[('installable', 'Installable application'), ('package', 'Package (external / non-installable)')], default='installable', help_text='Only affects how releases appear on the public site.', max_length=20),
),
migrations.AddField(
model_name='subproductversion',
name='main_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.mainproduct'),
),
migrations.AlterField(
model_name='subproductversion',
name='sub_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.subproduct'),
),
migrations.AddConstraint(
model_name='subproductversion',
constraint=models.CheckConstraint(check=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='release_version_exactly_one_parent'),
),
migrations.AddConstraint(
model_name='subproductversion',
constraint=models.UniqueConstraint(condition=models.Q(('sub_product__isnull', False)), fields=('sub_product', 'version'), name='release_version_unique_sub_product_version'),
),
migrations.AddConstraint(
model_name='subproductversion',
constraint=models.UniqueConstraint(condition=models.Q(('main_product__isnull', False)), fields=('main_product', 'version'), name='release_version_unique_main_product_version'),
),
]
+89 -8
View File
@@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
@@ -12,6 +13,13 @@ INSTALLABLE_PLATFORM_SPECS = (
("source_code_url", "Source code", None), ("source_code_url", "Source code", None),
) )
DISTRIBUTION_INSTALLABLE = "installable"
DISTRIBUTION_PACKAGE = "package"
DISTRIBUTION_CHOICES = [
(DISTRIBUTION_INSTALLABLE, "Installable application"),
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
]
class MainProduct(models.Model): class MainProduct(models.Model):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
@@ -22,6 +30,12 @@ class MainProduct(models.Model):
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
) )
image = models.ImageField(upload_to="products/main/", blank=True, null=True) image = models.ImageField(upload_to="products/main/", blank=True, null=True)
distribution = models.CharField(
max_length=20,
choices=DISTRIBUTION_CHOICES,
default=DISTRIBUTION_INSTALLABLE,
help_text="Only affects how releases appear on the public site.",
)
order = models.PositiveIntegerField(default=0) order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -47,14 +61,17 @@ class MainProduct(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("products:main_product_detail", kwargs={"main_slug": self.slug}) return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
def get_versions_archive_url(self):
return reverse(
"products:main_product_versions",
kwargs={"main_slug": self.slug},
)
class SubProduct(models.Model): class SubProduct(models.Model):
DISTRIBUTION_INSTALLABLE = "installable" DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE
DISTRIBUTION_PACKAGE = "package" DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE
DISTRIBUTION_CHOICES = [ DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES
(DISTRIBUTION_INSTALLABLE, "Installable application"),
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
]
main_product = models.ForeignKey( main_product = models.ForeignKey(
MainProduct, MainProduct,
@@ -121,10 +138,19 @@ class SubProduct(models.Model):
class Article(models.Model): class Article(models.Model):
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="articles",
null=True,
blank=True,
)
sub_product = models.ForeignKey( sub_product = models.ForeignKey(
SubProduct, SubProduct,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="articles", related_name="articles",
null=True,
blank=True,
) )
badge = models.CharField( badge = models.CharField(
max_length=100, max_length=100,
@@ -155,20 +181,47 @@ class Article(models.Model):
ordering = ["order", "title"] ordering = ["order", "title"]
verbose_name = "Article" verbose_name = "Article"
verbose_name_plural = "Articles" verbose_name_plural = "Articles"
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="article_exactly_one_parent",
),
]
@property @property
def rendered_description(self): def rendered_description(self):
return render_content(self.description, self.description_format) return render_content(self.description, self.description_format)
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(
"An article must belong to exactly one main product or sub-product."
)
def __str__(self): def __str__(self):
return self.title return self.title
class SubProductVersion(models.Model): class SubProductVersion(models.Model):
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="versions",
null=True,
blank=True,
)
sub_product = models.ForeignKey( sub_product = models.ForeignKey(
SubProduct, SubProduct,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="versions", related_name="versions",
null=True,
blank=True,
) )
version = models.CharField(max_length=80) version = models.CharField(max_length=80)
is_featured_stable = models.BooleanField( is_featured_stable = models.BooleanField(
@@ -189,12 +242,40 @@ class SubProductVersion(models.Model):
class Meta: class Meta:
ordering = ["order", "version", "pk"] ordering = ["order", "version", "pk"]
unique_together = [["sub_product", "version"]]
verbose_name = "Release version" verbose_name = "Release version"
verbose_name_plural = "Release versions" verbose_name_plural = "Release versions"
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="release_version_exactly_one_parent",
),
models.UniqueConstraint(
fields=["sub_product", "version"],
condition=models.Q(sub_product__isnull=False),
name="release_version_unique_sub_product_version",
),
models.UniqueConstraint(
fields=["main_product", "version"],
condition=models.Q(main_product__isnull=False),
name="release_version_unique_main_product_version",
),
]
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 release version must belong to exactly one main product or sub-product."
)
def __str__(self): def __str__(self):
return f"{self.sub_product.name} v{self.version}" parent = self.sub_product or self.main_product
return f"{parent.name} v{self.version}"
def install_urls_for_specs(self, specs): def install_urls_for_specs(self, specs):
out = [] out = []
+100
View File
@@ -0,0 +1,100 @@
from .models import DISTRIBUTION_INSTALLABLE, DISTRIBUTION_PACKAGE, INSTALLABLE_PLATFORM_SPECS
def featured_version(qs):
ordered = qs.order_by("order", "pk")
cand = ordered.filter(is_featured_stable=True).first()
return cand if cand else ordered.first()
def install_specs_from_versions(version_list):
present = set()
for ver in version_list:
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
if (getattr(ver, field_name) or "").strip():
present.add(field_name)
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
def build_release_context(distribution, active_versions):
context = {
"distribution_installable": distribution == DISTRIBUTION_INSTALLABLE,
"distribution_package": distribution == DISTRIBUTION_PACKAGE,
"show_releases_section": False,
"featured_version": None,
"featured_install_cells": [],
"show_older_versions_link": False,
"package_versions": [],
}
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()
):
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():
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
elif distribution == DISTRIBUTION_PACKAGE:
pkg_versions = list(active_versions.order_by("order", "pk"))
context["package_versions"] = pkg_versions
context["show_releases_section"] = len(pkg_versions) > 0
return context
def build_archive_context(distribution, active_versions):
featured = featured_version(active_versions)
archive = list(
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
if featured
else active_versions.order_by("order", "pk")
)
context = {
"featured_version": featured,
"archive_versions": archive,
}
if distribution == DISTRIBUTION_INSTALLABLE:
specs = install_specs_from_versions(archive)
context["archive_specs"] = specs
archive_rows_installable = []
for ver in archive:
cells = []
for field_name, label, icon in specs:
u = (getattr(ver, field_name) or "").strip()
cells.append(
{"field": field_name, "label": label, "icon": icon, "url": u}
)
archive_rows_installable.append({"version_obj": ver, "cells": cells})
context["archive_rows_installable"] = archive_rows_installable
context["archive_rows_package"] = False
context["distribution_installable"] = True
context["distribution_package"] = False
else:
context["archive_specs"] = ()
context["archive_rows_installable"] = []
context["archive_rows_package"] = True
context["distribution_installable"] = False
context["distribution_package"] = True
return context
+75 -1
View File
@@ -1,7 +1,15 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct from django.core.exceptions import ValidationError
from apps.products.models import (
Article,
ArticleSection,
MainProduct,
SubProduct,
SubProductVersion,
)
class MainProductModelTest(TestCase): class MainProductModelTest(TestCase):
@@ -112,6 +120,72 @@ class ArticleModelTest(TestCase):
self.sub_product.delete() self.sub_product.delete()
self.assertFalse(Article.objects.filter(pk=article_pk).exists()) self.assertFalse(Article.objects.filter(pk=article_pk).exists())
def test_main_product_article(self):
article = Article.objects.create(
main_product=self.main_product,
title="Product Article",
description="Body.",
)
self.assertEqual(article.main_product, self.main_product)
self.assertIsNone(article.sub_product_id)
def test_cascade_delete_with_main_product(self):
article = Article.objects.create(
main_product=self.main_product,
title="Product Article",
description="Body.",
)
article_pk = article.pk
self.main_product.delete()
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
def test_requires_exactly_one_parent(self):
article = Article(
main_product=self.main_product,
sub_product=self.sub_product,
title="Invalid",
description="Body.",
)
with self.assertRaises(ValidationError):
article.full_clean()
def test_requires_a_parent(self):
article = Article(title="Orphan", description="Body.")
with self.assertRaises(ValidationError):
article.full_clean()
class SubProductVersionModelTest(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Main",
short_description="Short",
description="Desc",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Sub",
short_description="Short",
description="Desc",
)
def test_main_product_version(self):
version = SubProductVersion.objects.create(
main_product=self.main_product,
version="1.0.0",
)
self.assertEqual(version.main_product, self.main_product)
self.assertIsNone(version.sub_product_id)
def test_requires_exactly_one_parent(self):
version = SubProductVersion(
main_product=self.main_product,
sub_product=self.sub_product,
version="1.0.0",
)
with self.assertRaises(ValidationError):
version.full_clean()
class ArticleSectionModelTest(TestCase): class ArticleSectionModelTest(TestCase):
def setUp(self): def setUp(self):
+49 -1
View File
@@ -83,6 +83,18 @@ class MainProductDetailViewTest(ProductViewsSetup):
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_detail_articles_in_context(self):
main_article = Article.objects.create(
main_product=self.main_product,
title="Overview",
description="Main product article.",
)
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
response = self.client.get(url)
self.assertIn("articles", response.context)
self.assertIn(main_article, list(response.context["articles"]))
self.assertNotIn(self.article, list(response.context["articles"]))
class SubProductDetailViewTest(ProductViewsSetup): class SubProductDetailViewTest(ProductViewsSetup):
def test_sub_detail_returns_200(self): def test_sub_detail_returns_200(self):
@@ -161,4 +173,40 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
) )
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "products/sub_versions.html") self.assertTemplateUsed(response, "products/versions_archive.html")
def test_main_versions_returns_200(self):
from apps.products.models import SubProductVersion
SubProductVersion.objects.create(
main_product=self.main_product,
version="9.9",
is_featured_stable=True,
windows_download_url="https://example.com/win.exe",
is_active=True,
)
SubProductVersion.objects.create(
main_product=self.main_product,
version="9.8",
is_active=True,
)
url = reverse(
"products:main_product_versions",
kwargs={"main_slug": "radiuma"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "products/versions_archive.html")
def test_main_detail_releases_in_context(self):
from apps.products.models import SubProductVersion
SubProductVersion.objects.create(
main_product=self.main_product,
version="1.0",
windows_download_url="https://example.com/win.exe",
is_active=True,
)
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
response = self.client.get(url)
self.assertTrue(response.context["show_releases_section"])
+5
View File
@@ -11,6 +11,11 @@ urlpatterns = [
views.MainProductDetailView.as_view(), views.MainProductDetailView.as_view(),
name="main_product_detail", name="main_product_detail",
), ),
path(
"<slug:main_slug>/versions/",
views.MainProductOlderVersionsView.as_view(),
name="main_product_versions",
),
path( path(
"<slug:main_slug>/<slug:sub_slug>/versions/", "<slug:main_slug>/<slug:sub_slug>/versions/",
views.SubProductOlderVersionsView.as_view(), views.SubProductOlderVersionsView.as_view(),
+46 -95
View File
@@ -1,22 +1,8 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from .models import INSTALLABLE_PLATFORM_SPECS, MainProduct, SubProduct from .models import MainProduct, SubProduct
from .release_context import build_archive_context, build_release_context
def _featured_version(qs):
ordered = qs.order_by("order", "pk")
cand = ordered.filter(is_featured_stable=True).first()
return cand if cand else ordered.first()
def _install_specs_from_versions(version_list):
present = set()
for ver in version_list:
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
if (getattr(ver, field_name) or "").strip():
present.add(field_name)
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
class ProductOverviewView(ListView): class ProductOverviewView(ListView):
@@ -37,6 +23,39 @@ class MainProductDetailView(DetailView):
"sub_products" "sub_products"
) )
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["articles"] = self.object.articles.prefetch_related(
"sections", "citations"
).all()
release_context = build_release_context(
self.object.distribution,
self.object.versions.filter(is_active=True),
)
release_context["versions_archive_url"] = self.object.get_versions_archive_url()
context.update(release_context)
return context
class MainProductOlderVersionsView(TemplateView):
template_name = "products/versions_archive.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
main_product = get_object_or_404(
MainProduct,
slug=self.kwargs["main_slug"],
is_active=True,
)
active_versions = main_product.versions.filter(is_active=True)
context["main_product"] = main_product
context["sub_product"] = None
context["product_detail_url"] = main_product.get_absolute_url()
context.update(
build_archive_context(main_product.distribution, active_versions)
)
return context
class SubProductDetailView(TemplateView): class SubProductDetailView(TemplateView):
template_name = "products/sub_detail.html" template_name = "products/sub_detail.html"
@@ -56,63 +75,25 @@ class SubProductDetailView(TemplateView):
) )
context["main_product"] = main_product context["main_product"] = main_product
context["sub_product"] = sub_product context["sub_product"] = sub_product
context["articles"] = sub_product.articles.prefetch_related("sections").all() context["articles"] = sub_product.articles.prefetch_related(
"sections", "citations"
).all()
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)
.order_by("order", "name") .order_by("order", "name")
) )
active_versions = sub_product.versions.filter(is_active=True) active_versions = sub_product.versions.filter(is_active=True)
release_context = build_release_context(
context["distribution_installable"] = ( sub_product.distribution, active_versions
sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE
) )
context["distribution_package"] = ( release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE context.update(release_context)
)
context["show_releases_section"] = False
context["featured_version"] = None
context["featured_install_cells"] = []
context["show_older_versions_link"] = False
context["package_versions"] = []
if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE:
featured = _featured_version(active_versions)
if (
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"):
if cand.has_any_install_asset() or (cand.package_resource_url or "").strip():
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
elif sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE:
pkg_versions = list(active_versions.order_by("order", "pk"))
context["package_versions"] = pkg_versions
context["show_releases_section"] = len(pkg_versions) > 0
return context return context
class SubProductOlderVersionsView(TemplateView): class SubProductOlderVersionsView(TemplateView):
template_name = "products/sub_versions.html" template_name = "products/versions_archive.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@@ -128,40 +109,10 @@ class SubProductOlderVersionsView(TemplateView):
is_active=True, is_active=True,
) )
active_versions = sub_product.versions.filter(is_active=True) active_versions = sub_product.versions.filter(is_active=True)
featured = _featured_version(active_versions)
archive = list(
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
if featured
else active_versions.order_by("order", "pk")
)
context["main_product"] = main_product context["main_product"] = main_product
context["sub_product"] = sub_product context["sub_product"] = sub_product
context["featured_version"] = featured context["product_detail_url"] = sub_product.get_absolute_url()
context["archive_versions"] = archive context.update(
build_archive_context(sub_product.distribution, active_versions)
if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE:
specs = _install_specs_from_versions(archive)
context["archive_specs"] = specs
archive_rows_installable = []
for ver in archive:
cells = []
for field_name, label, icon in specs:
u = getattr(ver, field_name, "") or ""
u = u.strip()
cells.append(
{"field": field_name, "label": label, "icon": icon, "url": u}
) )
archive_rows_installable.append(
{"version_obj": ver, "cells": cells}
)
context["archive_rows_installable"] = archive_rows_installable
context["archive_rows_package"] = False
context["distribution_installable"] = True
context["distribution_package"] = False
else:
context["archive_specs"] = ()
context["archive_rows_installable"] = []
context["archive_rows_package"] = True
context["distribution_installable"] = False
context["distribution_package"] = True
return context return context
+2
View File
@@ -54,6 +54,8 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"apps.core.context_processors.site_branding",
"apps.core.context_processors.site_contact",
"apps.core.context_processors.navigation", "apps.core.context_processors.navigation",
], ],
}, },
+149 -49
View File
@@ -344,8 +344,7 @@ h1, h2, h3, h4, h5, h6 {
.brand-icon { .brand-icon {
width: 26px; width: 26px;
height: 26px; height: 26px;
border-radius: 8px; border-radius: 0;
border: 1px solid #c4b5fd;
object-fit: cover; object-fit: cover;
display: block; display: block;
flex-shrink: 0; flex-shrink: 0;
@@ -390,7 +389,7 @@ h1, h2, h3, h4, h5, h6 {
} }
.nav-arrow { .nav-arrow {
transition: transform 0.22s ease; transition: transform 0.4s ease;
} }
.nav-item.has-megamenu:hover .nav-arrow, .nav-item.has-megamenu:hover .nav-arrow,
@@ -428,7 +427,7 @@ h1, h2, h3, h4, h5, h6 {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(-10px); transform: translateY(-10px);
transition: opacity 0.22s ease, visibility 0.22s ease, transform 0.22s ease; transition: opacity 0.6s ease, visibility 0.6s ease, transform 0.6s ease;
pointer-events: none; pointer-events: none;
z-index: 999; z-index: 999;
} }
@@ -444,37 +443,91 @@ h1, h2, h3, h4, h5, h6 {
.megamenu-inner { .megamenu-inner {
max-width: var(--container-max); max-width: var(--container-max);
margin: 0 auto; margin: 0 auto;
padding: 2rem var(--container-padding); padding: 1.5rem var(--container-padding);
} }
.megamenu-grid { .megamenu-products-scroll {
display: grid; overflow-x: auto;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); overflow-y: hidden;
gap: 1.5rem; -webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: rgba(79, 142, 247, 0.45) transparent;
padding-bottom: 0.35rem;
} }
.megamenu-column { .megamenu-products-scroll::-webkit-scrollbar {
padding: 1rem; height: 6px;
}
.megamenu-products-scroll::-webkit-scrollbar-track {
background: transparent;
}
.megamenu-products-scroll::-webkit-scrollbar-thumb {
background: rgba(79, 142, 247, 0.35);
border-radius: 999px;
}
.megamenu-products-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(79, 142, 247, 0.55);
}
.megamenu-products {
display: flex;
align-items: flex-start;
gap: 1rem;
list-style: none;
margin: 0;
padding: 0;
min-width: min-content;
}
.megamenu-product-item {
flex: 0 0 220px;
max-width: 220px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
background: var(--glass-bg);
transition: var(--transition-fast); transition: var(--transition-fast);
} }
.megamenu-column:hover { .megamenu-product-item:hover,
background: var(--glass-bg); .megamenu-product-item:focus-within {
border-color: rgba(79, 142, 247, 0.35);
background: rgba(79, 142, 247, 0.06);
} }
.megamenu-product-title { .megamenu-product-link {
display: flex; display: block;
align-items: center; padding: 1rem;
justify-content: space-between;
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.4rem;
text-decoration: none; text-decoration: none;
} }
.megamenu-product-title:hover { .megamenu-product-name {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.35rem;
}
.megamenu-product-link:hover .megamenu-product-name,
.megamenu-product-item:focus-within > .megamenu-product-link .megamenu-product-name {
color: var(--accent-blue-light);
}
.megamenu-sub-chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.4s ease, color 0.4s ease;
}
.megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron,
.megamenu-product-item.has-subproducts:focus-within .megamenu-sub-chevron {
transform: rotate(180deg);
color: var(--accent-blue-light); color: var(--accent-blue-light);
} }
@@ -482,44 +535,47 @@ h1, h2, h3, h4, h5, h6 {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.5; line-height: 1.5;
margin-bottom: 0.85rem; display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.megamenu-sublist { .megamenu-sublist {
display: flex; list-style: none;
flex-direction: column; margin: 0;
gap: 0.2rem; padding: 0;
border-top: 1px solid transparent;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.4s ease, opacity 0.35s ease, border-color 0.35s ease;
}
.megamenu-product-item.has-subproducts:hover .megamenu-sublist,
.megamenu-product-item.has-subproducts:focus-within .megamenu-sublist {
max-height: 320px;
opacity: 1;
border-top-color: var(--glass-border);
} }
.megamenu-sublink { .megamenu-sublink {
display: flex; display: block;
align-items: center; padding: 0.45rem 1rem;
gap: 0.5rem;
font-size: 0.82rem; font-size: 0.82rem;
color: var(--text-secondary); color: var(--text-secondary);
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
text-decoration: none; text-decoration: none;
transition: var(--transition-fast); transition: var(--transition-fast);
} }
.megamenu-sublink:hover { .megamenu-sublink:hover,
.megamenu-sublink:focus-visible {
color: var(--accent-blue-light); color: var(--accent-blue-light);
background: rgba(79, 142, 247, 0.08); background: rgba(79, 142, 247, 0.08);
} }
.sublink-dot { .megamenu-sublink:last-child {
width: 5px; height: 5px; border-radius: 0 0 var(--radius-md) var(--radius-md);
border-radius: 50%;
background: var(--accent-blue);
flex-shrink: 0;
opacity: 0.6;
transition: var(--transition-fast);
}
.megamenu-sublink:hover .sublink-dot {
opacity: 1;
box-shadow: 0 0 6px var(--accent-blue);
} }
/* ============================================================ /* ============================================================
@@ -831,7 +887,7 @@ h1, h2, h3, h4, h5, h6 {
width: 32px; width: 32px;
height: 32px; height: 32px;
object-fit: contain; object-fit: contain;
border-radius: var(--radius-sm); border-radius: 0;
opacity: 0.85; opacity: 0.85;
} }
@@ -914,7 +970,7 @@ h1, h2, h3, h4, h5, h6 {
flex-shrink: 0; flex-shrink: 0;
width: 52px; width: 52px;
height: 52px; height: 52px;
border-radius: var(--radius-sm); border-radius: 0;
overflow: hidden; overflow: hidden;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
display: flex; display: flex;
@@ -1054,6 +1110,21 @@ h1, h2, h3, h4, h5, h6 {
line-height: 1.8; line-height: 1.8;
} }
.main-product-layout {
padding-top: var(--spacing-xl);
}
.main-product-main .product-detail-text h2 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-primary);
}
.main-product-main .sub-downloads {
margin-top: 0;
}
/* ============================================================ /* ============================================================
Sub-products Grid Sub-products Grid
============================================================ */ ============================================================ */
@@ -1136,12 +1207,17 @@ h1, h2, h3, h4, h5, h6 {
align-items: start; align-items: start;
} }
.sub-product-main { .sub-product-main,
.main-product-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
} }
.sub-product-main .sub-downloads {
margin-top: 0;
}
.sub-product-image { .sub-product-image {
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
@@ -1563,6 +1639,10 @@ a.citation-count-badge:hover {
padding: 2.5rem; padding: 2.5rem;
} }
.about-custom-card {
padding: 2.5rem;
}
.about-intro h2 { .about-intro h2 {
font-size: 1.4rem; font-size: 1.4rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
@@ -2319,6 +2399,10 @@ a.citation-count-badge:hover {
align-items: start; align-items: start;
} }
.contact-layout--full {
grid-template-columns: 1fr;
}
.contact-form-wrap { .contact-form-wrap {
padding: 2.5rem; padding: 2.5rem;
} }
@@ -2805,11 +2889,27 @@ a.citation-count-badge:hover {
padding: 1rem; padding: 1rem;
} }
.megamenu-grid { .megamenu-products-scroll {
grid-template-columns: 1fr; overflow-x: visible;
padding-bottom: 0;
}
.megamenu-products {
flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
.megamenu-product-item {
flex: 1 1 auto;
max-width: none;
}
.megamenu-product-item.has-subproducts .megamenu-sublist {
max-height: none;
opacity: 1;
border-top-color: var(--glass-border);
}
.nav-item.has-megamenu:hover .megamenu { .nav-item.has-megamenu:hover .megamenu {
transform: none; transform: none;
} }
+1 -1
View File
@@ -77,7 +77,7 @@ function initMegaMenu() {
hideTimer = setTimeout(() => { hideTimer = setTimeout(() => {
productsItem.classList.remove('open'); productsItem.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-expanded', 'false');
}, 180); }, 420);
} }
productsItem.addEventListener('mouseenter', openMenu); productsItem.addEventListener('mouseenter', openMenu);
+2 -2
View File
@@ -105,8 +105,8 @@
</div> </div>
{% endif %} {% endif %}
{% if section.content %} {% if section.content %}
<div class="glass-card fade-in"> <div class="glass-card fade-in about-custom-card">
<div class="rich-content" style="padding: 2rem;" data-readmore="200">{{ section.rendered_content }}</div> <div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
</div> </div>
{% endif %} {% endif %}
{% with items=section.items.all %} {% with items=section.items.all %}
+21 -14
View File
@@ -22,7 +22,7 @@
<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"> <div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}">
<div class="contact-form-wrap glass-card fade-in"> <div class="contact-form-wrap glass-card fade-in">
<h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2> <h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2>
@@ -75,7 +75,9 @@
</form> </form>
</div> </div>
{% if site_contact.has_contact_sidebar %}
<aside class="contact-sidebar"> <aside class="contact-sidebar">
{% if site_contact.has_support_email %}
<div class="contact-card glass-card fade-in"> <div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true"> <div class="contact-icon" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none"> <svg width="28" height="28" viewBox="0 0 32 32" fill="none">
@@ -83,22 +85,30 @@
<path d="M5 9L16 16L27 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path d="M5 9L16 16L27 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg> </svg>
</div> </div>
<h3 class="contact-card-title">Email Support</h3> <h3 class="contact-card-title">{{ site_contact.email_card_title_display }}</h3>
<p class="contact-card-desc">For direct software support:</p> {% if site_contact.email_card_description %}
<a href="mailto:support@radiuma.com" class="contact-email-link">support@radiuma.com</a> <p class="contact-card-desc">{{ site_contact.email_card_description }}</p>
{% endif %}
{% include "partials/_contact_email.html" with link_class="contact-email-link" %}
</div> </div>
{% endif %}
{% if site_contact.has_discord %}
<div class="contact-card glass-card fade-in"> <div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true"> <div class="contact-icon" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"> <svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/> <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
</svg> </svg>
</div> </div>
<h3 class="contact-card-title">Discord Community</h3> <h3 class="contact-card-title">{{ site_contact.discord_card_title_display }}</h3>
<p class="contact-card-desc">Join for community support and announcements.</p> {% if site_contact.discord_card_description %}
<a href="https://discord.gg/9XxA6pV9hb" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">Join Discord</a> <p class="contact-card-desc">{{ site_contact.discord_card_description }}</p>
{% endif %}
{% include "partials/_contact_discord.html" with button_class="btn-primary btn--sm" %}
</div> </div>
{% endif %}
{% if site_contact.has_office_address %}
<div class="contact-card glass-card fade-in"> <div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true"> <div class="contact-icon" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none"> <svg width="28" height="28" viewBox="0 0 32 32" fill="none">
@@ -106,15 +116,12 @@
<circle cx="16" cy="12" r="3" stroke="currentColor" stroke-width="1.5"/> <circle cx="16" cy="12" r="3" stroke="currentColor" stroke-width="1.5"/>
</svg> </svg>
</div> </div>
<h3 class="contact-card-title">Office</h3> <h3 class="contact-card-title">{{ site_contact.office_card_title_display }}</h3>
<address class="contact-address"> {% include "partials/_contact_office.html" with address_class="contact-address" %}
<p>BC Cancer Research Center</p>
<p>675 West 10th Ave, Office 6-112</p>
<p>Vancouver, BC, V5Z 1L3</p>
<p>Canada</p>
</address>
</div> </div>
{% endif %}
</aside> </aside>
{% endif %}
</div> </div>
</div> </div>
+20
View File
@@ -0,0 +1,20 @@
{% load static %}
{% if placement == "footer" %}
<img
{% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %}
{% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
class="brand-icon"
width="{{ site_branding.footer_icon_size }}"
height="{{ site_branding.footer_icon_size }}"
style="{{ site_branding.footer_icon_style }}"
/>
{% else %}
<img
{% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %}
{% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
class="brand-icon"
width="{{ site_branding.navbar_icon_size }}"
height="{{ site_branding.navbar_icon_size }}"
style="{{ site_branding.navbar_icon_style }}"
/>
{% endif %}
+8
View File
@@ -0,0 +1,8 @@
{% if site_contact.has_discord %}
<a href="{{ site_contact.discord_url }}" class="{{ button_class|default:'btn-ghost btn-sm' }}" target="_blank" rel="noopener noreferrer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
</svg>
{{ site_contact.discord_label_display }}
</a>
{% endif %}
+3
View File
@@ -0,0 +1,3 @@
{% if site_contact.has_support_email %}
<a href="mailto:{{ site_contact.support_email }}" class="{{ link_class|default:'footer-email' }}">{{ site_contact.support_email }}</a>
{% endif %}
+7
View File
@@ -0,0 +1,7 @@
{% if site_contact.has_office_address %}
<address class="{{ address_class|default:'footer-address' }}">
{% for line in site_contact.office_address_lines %}
<p>{{ line }}</p>
{% endfor %}
</address>
{% endif %}
+11 -18
View File
@@ -9,18 +9,13 @@
<div class="footer-brand-col"> <div class="footer-brand-col">
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Radiuma home"> <a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Radiuma home">
<img src="{% static 'images/favicon-180.png' %}" alt="" class="brand-icon" width="34" height="34" aria-hidden="true" /> {% include "partials/_brand_icon.html" with placement="footer" %}
<span class="brand-name">Radiuma</span> <span class="brand-name">Radiuma</span>
</a> </a>
<p class="footer-tagline"> <p class="footer-tagline">
Advancing medical imaging and radiomics research through innovative, standardized software solutions. Advancing medical imaging and radiomics research through innovative, standardized software solutions.
</p> </p>
<a href="https://discord.gg/9XxA6pV9hb" class="btn-ghost btn-sm" target="_blank" rel="noopener noreferrer"> {% include "partials/_contact_discord.html" with button_class="btn-ghost btn-sm" %}
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
</svg>
Join Discord
</a>
</div> </div>
<div class="footer-links-col"> <div class="footer-links-col">
@@ -34,29 +29,27 @@
</ul> </ul>
</div> </div>
{% if footer_sub_products %} {% if footer_main_products %}
<div class="footer-links-col"> <div class="footer-links-col">
<h3 class="footer-heading">Products</h3> <h3 class="footer-heading">Products</h3>
<ul class="footer-links" role="list"> <ul class="footer-links" role="list">
{% for sub in footer_sub_products %} {% for product in footer_main_products %}
<li><a href="{{ sub.get_absolute_url }}">{{ sub.name }}</a></li> <li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li>
{% endfor %} {% endfor %}
{% if footer_sub_products_has_more %} {% if footer_main_products_has_more %}
<li><a href="/products/radiuma/">more</a></li> <li><a href="{% url 'products:main_product_detail' main_slug='radiuma' %}">more</a></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
{% if site_contact.has_footer_contact %}
<div class="footer-contact-col"> <div class="footer-contact-col">
<h3 class="footer-heading">Contact</h3> <h3 class="footer-heading">Contact</h3>
<address class="footer-address"> {% include "partials/_contact_office.html" with address_class="footer-address" %}
<p>BC Cancer Research Center</p> {% include "partials/_contact_email.html" with link_class="footer-email" %}
<p>675 West 10th Ave, Office 6-112</p>
<p>Vancouver, BC, V5Z 1L3</p>
</address>
<a href="mailto:support@radiuma.com" class="footer-email">support@radiuma.com</a>
</div> </div>
{% endif %}
</div> </div>
+20 -21
View File
@@ -3,7 +3,7 @@
<div class="navbar-container"> <div class="navbar-container">
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Radiuma home"> <a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Radiuma home">
<img src="{% static 'images/favicon-180.png' %}" alt="" class="brand-icon" width="24" height="24" aria-hidden="true" /> {% include "partials/_brand_icon.html" with placement="navbar" %}
<span class="brand-name">Radiuma</span> <span class="brand-name">Radiuma</span>
</a> </a>
@@ -32,34 +32,33 @@
</a> </a>
<div class="megamenu" role="menu" aria-labelledby="productsTrigger"> <div class="megamenu" role="menu" aria-labelledby="productsTrigger">
<div class="megamenu-inner"> <div class="megamenu-inner">
<div class="megamenu-grid"> <div class="megamenu-products-scroll" tabindex="0" aria-label="Product list">
<ul class="megamenu-products" role="list">
{% for product in nav_main_products %} {% for product in nav_main_products %}
<div class="megamenu-column"> <li class="megamenu-product-item{% if product.sub_products.all %} has-subproducts{% endif %}">
<a href="{{ product.get_absolute_url }}" class="megamenu-product-title" role="menuitem"> <a href="{{ product.get_absolute_url }}" class="megamenu-product-link" role="menuitem">
<span class="megamenu-product-name">
{{ product.name }} {{ product.name }}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> {% if product.sub_products.all %}
<path d="M3 7H11M11 7L8 4M11 7L8 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <svg class="megamenu-sub-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</a>
<p class="megamenu-product-desc">{{ product.short_description }}</p>
{% with subs=product.sub_products.all %}
{% if subs %}
<ul class="megamenu-sublist" role="list">
{% for sub in subs %}
{% if sub.is_active %}
<li>
<a href="{{ sub.get_absolute_url }}" class="megamenu-sublink" role="menuitem">
<span class="sublink-dot"></span>
{{ sub.name }}
</a>
</li>
{% endif %} {% endif %}
</span>
<span class="megamenu-product-desc">{{ product.short_description }}</span>
</a>
{% if product.sub_products.all %}
<ul class="megamenu-sublist" role="list" aria-label="{{ product.name }} modules">
{% for sub in product.sub_products.all %}
<li>
<a href="{{ sub.get_absolute_url }}" class="megamenu-sublink" role="menuitem">{{ sub.name }}</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endwith %} </li>
</div>
{% endfor %} {% endfor %}
</ul>
</div> </div>
</div> </div>
</div> </div>
+69
View File
@@ -0,0 +1,69 @@
{% if articles %}
<section aria-label="Content sections">
<div class="articles-list">
{% for article in articles %}
<article class="article-card glass-card fade-in" aria-labelledby="article-{{ article.pk }}-title">
{% if article.badge %}<div class="article-card-header"><div class="section-badge article-badge">{{ article.badge }}</div></div>{% endif %}
<h3 class="article-title" id="article-{{ article.pk }}-title">{{ article.title }}</h3>
<div class="article-description rich-content" data-readmore="260">{{ article.rendered_description }}</div>
{% if article.sections.all %}
<div class="article-sections">
<dl class="article-sections-list">
{% for section in article.sections.all %}
<div class="article-section-item">
<dt class="section-key">{{ section.title }}</dt>
<dd class="section-value rich-content" data-readmore="160">{{ section.rendered_value }}</dd>
</div>
{% endfor %}
</dl>
</div>
{% endif %}
{% if article.citations.all %}
<div class="article-citations" id="article-{{ article.pk }}-citations">
<h4 class="article-citations-title">Citations</h4>
<ol class="article-citations-list">
{% for citation in article.citations.all %}
<li class="article-citation-item">
<span class="citation-text">{{ citation.text }}</span>
{% if citation.url %}
<span class="citation-actions">
<a href="{{ citation.url }}" class="citation-link" target="_blank" rel="noopener noreferrer" aria-label="Open citation source">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M7 3H3a1 1 0 00-1 1v9a1 1 0 001 1h9a1 1 0 001-1V9M10 2h4m0 0v4m0-4L7 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<button type="button" class="citation-copy" data-citation-url="{{ citation.url }}" aria-label="Copy citation link">
<svg class="citation-copy-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="5" y="5" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<svg class="citation-copy-icon-done" width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="citation-copy-feedback" aria-live="polite" hidden>Copied!</span>
</button>
</span>
{% endif %}
</li>
{% endfor %}
</ol>
</div>
{% endif %}
{% if article.citation_count_display is not None or article.citation_count_label %}
{% if article.citations.exists %}
<a href="#article-{{ article.pk }}-citations" class="citation-count-badge" aria-label="{% if article.citation_count_display %}{{ article.citation_count_display }} {% endif %}{% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %}">
{% if article.citation_count_display is not None %}<span class="citation-count-number">{{ article.citation_count_display }}</span>{% endif %}
{% if article.citation_count_label %}<span class="citation-count-label">{{ article.citation_count_label }}</span>{% endif %}
</a>
{% else %}
<div class="citation-count-badge" aria-label="{% if article.citation_count_display %}{{ article.citation_count_display }} {% endif %}{% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %}">
{% if article.citation_count_display is not None %}<span class="citation-count-number">{{ article.citation_count_display }}</span>{% endif %}
{% if article.citation_count_label %}<span class="citation-count-label">{{ article.citation_count_label }}</span>{% endif %}
</div>
{% endif %}
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endif %}
+101
View File
@@ -0,0 +1,101 @@
{% load static %}
{% if show_releases_section %}
{% if distribution_installable %}
<section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading">
<h2 class="sub-downloads-title" id="sub-dl-heading">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Downloads
</h2>
<div class="sub-downloads-channel">
{% if featured_version.is_featured_stable %}
<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">v{{ featured_version.version }}</span>
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.label == "Source code" %} 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 %}
{% if featured_version.release_notes %}
<p class="sub-release-notes">{{ featured_version.release_notes }}</p>
{% endif %}
{% if show_older_versions_link %}
<p class="sub-older-versions-inner">
<a href="{{ versions_archive_url }}" class="link-arrow">Older releases</a>
</p>
{% endif %}
</section>
{% elif distribution_package %}
<section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading">
<h2 class="sub-downloads-title" id="sub-resources-heading">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
Resources
</h2>
<ul class="pkg-version-list" role="list">
{% for ver in package_versions %}
<li class="pkg-version-row">
<div class="pkg-version-meta">
<span class="download-version">v{{ ver.version }}</span>
{% if ver.is_featured_stable %}
<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 class="pkg-version-actions">
{% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">Open</a>
{% endif %}
{% if ver.source_code_url %}
<a href="{{ ver.source_code_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Source</a>
{% endif %}
</div>
{% if ver.release_notes %}
<p class="pkg-version-notes">{{ ver.release_notes }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endif %}
@@ -0,0 +1,64 @@
{% load static %}
{% if not archive_versions %}
<p class="versions-empty glass-card">There are no archived releases{% if sub_product %} for this module{% else %} for this product{% endif %}.</p>
{% elif distribution_installable %}
<div class="versions-table-wrap glass-card fade-in">
<table class="versions-table">
<thead>
<tr>
<th scope="col">Version</th>
{% for fname, label, icon in archive_specs %}
<th scope="col">{{ label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in archive_rows_installable %}
<tr>
<th scope="row">{{ row.version_obj.version }}</th>
{% for cell in row.cells %}
<td>
{% if cell.url %}
{% if cell.icon %}
<a href="{{ cell.url }}" class="versions-table-link">
<img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
<span class="sr-only">{{ cell.label }}</span>
</a>
{% else %}
<a href="{{ cell.url }}" class="versions-table-text-link" rel="noopener noreferrer">Source</a>
{% endif %}
{% else %}
<span class="versions-na"></span>
{% endif %}
</td>
{% endfor %}
</tr>
{% if row.version_obj.release_notes %}
<tr class="versions-notes-row">
<td colspan="{{ archive_specs|length|add:1 }}">
<span class="versions-notes-label">Notes</span>
<p class="versions-notes-body">{{ row.version_obj.release_notes }}</p>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<ul class="versions-package-list" role="list">
{% for ver in archive_versions %}
<li class="versions-package-card glass-card fade-in">
<div class="versions-package-head">
<span class="download-version">v{{ ver.version }}</span>
{% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Open</a>
{% endif %}
</div>
{% if ver.release_notes %}
<p class="versions-notes-body">{{ ver.release_notes }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
+14 -2
View File
@@ -31,10 +31,10 @@
</div> </div>
</section> </section>
<section class="section" aria-labelledby="product-desc-heading"> <section class="section main-product-layout" aria-labelledby="product-desc-heading">
<div class="container"> <div class="container">
<div class="main-product-main">
<div class="product-detail-intro glass-card fade-in"> <div class="product-detail-intro glass-card fade-in">
<h2 class="sr-only" id="product-desc-heading">About {{ main_product.name }}</h2>
<div class="product-detail-image"> <div class="product-detail-image">
{% if main_product.image %} {% if main_product.image %}
<img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" /> <img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" />
@@ -43,12 +43,24 @@
{% endif %} {% endif %}
</div> </div>
<div class="product-detail-text"> <div class="product-detail-text">
<h2 id="product-desc-heading">About {{ main_product.name }}</h2>
<div class="product-detail-desc rich-content" data-readmore="220">{{ main_product.rendered_description }}</div> <div class="product-detail-desc rich-content" data-readmore="220">{{ main_product.rendered_description }}</div>
</div> </div>
</div> </div>
{% include "products/_releases_section.html" %}
</div>
</div> </div>
</section> </section>
{% if articles %}
<section class="section" aria-label="Product articles">
<div class="container">
{% include "products/_article_list.html" %}
</div>
</section>
{% endif %}
{% with subs=main_product.sub_products.all %} {% with subs=main_product.sub_products.all %}
{% if subs %} {% if subs %}
<section class="section" aria-labelledby="subproducts-heading"> <section class="section" aria-labelledby="subproducts-heading">
+2 -170
View File
@@ -47,177 +47,9 @@
<div class="rich-content" data-readmore="220">{{ sub_product.rendered_description }}</div> <div class="rich-content" data-readmore="220">{{ sub_product.rendered_description }}</div>
</div> </div>
{% if show_releases_section %} {% include "products/_releases_section.html" %}
{% if distribution_installable %}
<section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading">
<h2 class="sub-downloads-title" id="sub-dl-heading">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Downloads
</h2>
<div class="sub-downloads-channel"> {% include "products/_article_list.html" %}
{% if featured_version.is_featured_stable %}
<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">v{{ featured_version.version }}</span>
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.label == "Source code" %} 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 %}
{% if featured_version.release_notes %}
<p class="sub-release-notes">{{ featured_version.release_notes }}</p>
{% endif %}
{% if show_older_versions_link %}
<p class="sub-older-versions-inner">
<a href="{{ sub_product.get_versions_archive_url }}" class="link-arrow">Older releases</a>
</p>
{% endif %}
</section>
{% elif distribution_package %}
<section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading">
<h2 class="sub-downloads-title" id="sub-resources-heading">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
Resources
</h2>
<ul class="pkg-version-list" role="list">
{% for ver in package_versions %}
<li class="pkg-version-row">
<div class="pkg-version-meta">
<span class="download-version">v{{ ver.version }}</span>
{% if ver.is_featured_stable %}
<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 class="pkg-version-actions">
{% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">Open</a>
{% endif %}
{% if ver.source_code_url %}
<a href="{{ ver.source_code_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Source</a>
{% endif %}
</div>
{% if ver.release_notes %}
<p class="pkg-version-notes">{{ ver.release_notes }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endif %}
{% if articles %}
<section aria-label="Content sections">
<div class="articles-list">
{% for article in articles %}
<article class="article-card glass-card fade-in" aria-labelledby="article-{{ article.pk }}-title">
{% if article.badge %}<div class="article-card-header"><div class="section-badge article-badge">{{ article.badge }}</div></div>{% endif %}
<h3 class="article-title" id="article-{{ article.pk }}-title">{{ article.title }}</h3>
<div class="article-description rich-content" data-readmore="260">{{ article.rendered_description }}</div>
{% if article.sections.all %}
<div class="article-sections">
<dl class="article-sections-list">
{% for section in article.sections.all %}
<div class="article-section-item">
<dt class="section-key">{{ section.title }}</dt>
<dd class="section-value rich-content" data-readmore="160">{{ section.rendered_value }}</dd>
</div>
{% endfor %}
</dl>
</div>
{% endif %}
{% if article.citations.all %}
<div class="article-citations" id="article-{{ article.pk }}-citations">
<h4 class="article-citations-title">Citations</h4>
<ol class="article-citations-list">
{% for citation in article.citations.all %}
<li class="article-citation-item">
<span class="citation-text">{{ citation.text }}</span>
{% if citation.url %}
<span class="citation-actions">
<a href="{{ citation.url }}" class="citation-link" target="_blank" rel="noopener noreferrer" aria-label="Open citation source">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M7 3H3a1 1 0 00-1 1v9a1 1 0 001 1h9a1 1 0 001-1V9M10 2h4m0 0v4m0-4L7 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<button type="button" class="citation-copy" data-citation-url="{{ citation.url }}" aria-label="Copy citation link">
<svg class="citation-copy-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="5" y="5" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<svg class="citation-copy-icon-done" width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="citation-copy-feedback" aria-live="polite" hidden>Copied!</span>
</button>
</span>
{% endif %}
</li>
{% endfor %}
</ol>
</div>
{% endif %}
{% if article.citation_count_display is not None or article.citation_count_label %}
{% if article.citations.exists %}
<a href="#article-{{ article.pk }}-citations" class="citation-count-badge" aria-label="{% if article.citation_count_display %}{{ article.citation_count_display }} {% endif %}{% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %}">
{% if article.citation_count_display is not None %}<span class="citation-count-number">{{ article.citation_count_display }}</span>{% endif %}
{% if article.citation_count_label %}<span class="citation-count-label">{{ article.citation_count_label }}</span>{% endif %}
</a>
{% else %}
<div class="citation-count-badge" aria-label="{% if article.citation_count_display %}{{ article.citation_count_display }} {% endif %}{% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %}">
{% if article.citation_count_display is not None %}<span class="citation-count-number">{{ article.citation_count_display }}</span>{% endif %}
{% if article.citation_count_label %}<span class="citation-count-label">{{ article.citation_count_label }}</span>{% endif %}
</div>
{% endif %}
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endif %}
</main> </main>
<aside class="sub-product-sidebar" aria-label="Related modules"> <aside class="sub-product-sidebar" aria-label="Related modules">
-104
View File
@@ -1,104 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Older releases — {{ sub_product.name }} — {{ main_product.name }}{% endblock %}
{% block meta_description %}Archived releases for {{ sub_product.name }}.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="versions-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{{ sub_product.get_absolute_url }}">{{ sub_product.name }}</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li aria-current="page">Older releases</li>
</ol>
</nav>
<div class="page-hero-content">
<div class="section-badge">{{ sub_product.name }}</div>
<h1 class="page-hero-title" id="versions-heading">Older releases</h1>
<p class="page-hero-subtitle">Archived versions of this module.</p>
</div>
</div>
</section>
<section class="section versions-archive-section">
<div class="container">
{% if not archive_versions %}
<p class="versions-empty glass-card">There are no archived releases for this module.</p>
{% elif distribution_installable %}
<div class="versions-table-wrap glass-card fade-in">
<table class="versions-table">
<thead>
<tr>
<th scope="col">Version</th>
{% for fname, label, icon in archive_specs %}
<th scope="col">{{ label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in archive_rows_installable %}
<tr>
<th scope="row">{{ row.version_obj.version }}</th>
{% for cell in row.cells %}
<td>
{% if cell.url %}
{% if cell.icon %}
<a href="{{ cell.url }}" class="versions-table-link">
<img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
<span class="sr-only">{{ cell.label }}</span>
</a>
{% else %}
<a href="{{ cell.url }}" class="versions-table-text-link" rel="noopener noreferrer">Source</a>
{% endif %}
{% else %}
<span class="versions-na"></span>
{% endif %}
</td>
{% endfor %}
</tr>
{% if row.version_obj.release_notes %}
<tr class="versions-notes-row">
<td colspan="{{ archive_specs|length|add:1 }}">
<span class="versions-notes-label">Notes</span>
<p class="versions-notes-body">{{ row.version_obj.release_notes }}</p>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<ul class="versions-package-list" role="list">
{% for ver in archive_versions %}
<li class="versions-package-card glass-card fade-in">
<div class="versions-package-head">
<span class="download-version">v{{ ver.version }}</span>
{% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Open</a>
{% endif %}
</div>
{% if ver.release_notes %}
<p class="versions-notes-body">{{ ver.release_notes }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</section>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Older releases — {% if sub_product %}{{ sub_product.name }} — {% endif %}{{ main_product.name }}{% endblock %}
{% block meta_description %}Archived releases{% if sub_product %} for {{ sub_product.name }}{% else %} for {{ main_product.name }}{% endif %}.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="versions-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
{% if sub_product %}
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{{ sub_product.get_absolute_url }}">{{ sub_product.name }}</a></li>
{% endif %}
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li aria-current="page">Older releases</li>
</ol>
</nav>
<div class="page-hero-content">
<div class="section-badge">{% if sub_product %}{{ sub_product.name }}{% else %}{{ main_product.name }}{% endif %}</div>
<h1 class="page-hero-title" id="versions-heading">Older releases</h1>
<p class="page-hero-subtitle">Archived versions of this {% if sub_product %}module{% else %}product{% endif %}.</p>
</div>
</div>
</section>
<section class="section versions-archive-section">
<div class="container">
{% include "products/_versions_archive_body.html" %}
</div>
</section>
{% endblock %}