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/
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
def site_branding(request):
return {"site_branding": SiteBranding.load()}
def site_contact(request):
return {"site_contact": SiteContact.load()}
def navigation(request):
main_products = (
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")
)
all_sub_products = list(
SubProduct.objects.filter(is_active=True).order_by("order", "name")[:5]
)
footer_sub_products = all_sub_products[:4]
footer_sub_products_has_more = len(all_sub_products) == 5
all_footer_products = list(main_products[:5])
footer_main_products = all_footer_products[:4]
footer_main_products_has_more = len(all_footer_products) == 5
return {
"nav_main_products": main_products,
"footer_sub_products": footer_sub_products,
"footer_sub_products_has_more": footer_sub_products_has_more,
"footer_main_products": footer_main_products,
"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.db import transaction
from apps.core.models import SiteContact
from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
@@ -254,9 +255,9 @@ FAQ_ENTRIES = [
{
"question": "Where can I get support or report issues?",
"answer": (
"Support is available via email at support@radiuma.com and through our "
"community Discord server. For bug reports and feature requests, please "
"use the Discord forum or contact us directly by email."
"Support is available via email and through our community Discord server "
"(see the Contact page for current details). For bug reports and feature "
"requests, please use the Discord forum or contact us directly by email."
),
"order": 6,
},
@@ -402,6 +403,7 @@ class Command(BaseCommand):
self._seed_downloads()
self._seed_homepage_sections()
self._seed_hero()
self._seed_site_contact()
self.stdout.write(self.style.SUCCESS("Content seeded successfully."))
@@ -540,3 +542,38 @@ class Command(BaseCommand):
setattr(hero, field, value)
hero.save()
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",)
class ArticleInline(admin.StackedInline):
class SubProductArticleInline(admin.StackedInline):
model = Article
fk_name = "sub_product"
extra = 0
fields = ("badge", "title", "description", "order")
ordering = ("order",)
show_change_link = True
class SubProductVersionInline(admin.TabularInline):
model = SubProductVersion
class MainProductArticleInline(admin.StackedInline):
model = Article
fk_name = "main_product"
extra = 0
ordering = ("order", "version")
fields = (
fields = ("badge", "title", "description", "order")
ordering = ("order",)
show_change_link = True
RELEASE_VERSION_INLINE_FIELDS = (
"version",
"is_featured_stable",
"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):
model = SubProduct
extra = 0
@@ -59,9 +81,9 @@ class MainProductAdmin(admin.ModelAdmin):
search_fields = ("name", "description")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active")
inlines = [SubProductInline]
inlines = [MainProductArticleInline, MainProductVersionInline, SubProductInline]
fieldsets = (
(None, {"fields": ("name", "slug", "short_description")}),
(None, {"fields": ("name", "slug", "short_description", "distribution")}),
("Description", {"fields": ("description_format", "description")}),
("Media", {"fields": ("image",)}),
("Settings", {"fields": ("order", "is_active")}),
@@ -85,7 +107,7 @@ class SubProductAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
raw_id_fields = ("main_product",)
inlines = [ArticleInline, SubProductVersionInline]
inlines = [SubProductArticleInline, SubProductVersionInline]
fieldsets = (
(None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}),
("Description", {"fields": ("description_format", "description")}),
@@ -97,19 +119,27 @@ class SubProductAdmin(admin.ModelAdmin):
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "sub_product", "order", "created_at")
list_filter = ("sub_product__main_product",)
search_fields = ("title", "description")
list_display = ("title", "parent", "order", "created_at")
list_filter = ("main_product", "sub_product__main_product")
search_fields = ("title", "description", "main_product__name", "sub_product__name")
list_editable = ("order",)
raw_id_fields = ("sub_product",)
raw_id_fields = ("main_product", "sub_product")
inlines = [ArticleSectionInline, ArticleCitationInline]
fieldsets = (
(None, {"fields": ("sub_product", "badge", "title")}),
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
("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."}),
("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)
class ArticleSectionAdmin(admin.ModelAdmin):
@@ -127,21 +157,22 @@ class ArticleSectionAdmin(admin.ModelAdmin):
@admin.register(SubProductVersion)
class SubProductVersionAdmin(admin.ModelAdmin):
list_display = (
"sub_product",
"parent",
"version",
"is_featured_stable",
"is_active",
"order",
)
list_filter = ("is_active", "is_featured_stable", "sub_product__distribution")
search_fields = ("sub_product__name", "version")
list_filter = ("is_active", "is_featured_stable", "main_product", "sub_product__main_product")
search_fields = ("main_product__name", "sub_product__name", "version")
list_editable = ("is_active", "order")
raw_id_fields = ("sub_product",)
raw_id_fields = ("main_product", "sub_product")
fieldsets = (
(
None,
{
"fields": (
"main_product",
"sub_product",
"version",
"is_featured_stable",
@@ -163,3 +194,11 @@ class SubProductVersionAdmin(admin.ModelAdmin):
("Details", {"fields": ("release_notes",)}),
("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.urls import reverse
from django.utils.text import slugify
@@ -12,6 +13,13 @@ INSTALLABLE_PLATFORM_SPECS = (
("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):
name = models.CharField(max_length=200)
@@ -22,6 +30,12 @@ class MainProduct(models.Model):
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
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)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -47,14 +61,17 @@ class MainProduct(models.Model):
def get_absolute_url(self):
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):
DISTRIBUTION_INSTALLABLE = "installable"
DISTRIBUTION_PACKAGE = "package"
DISTRIBUTION_CHOICES = [
(DISTRIBUTION_INSTALLABLE, "Installable application"),
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
]
DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE
DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE
DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES
main_product = models.ForeignKey(
MainProduct,
@@ -121,10 +138,19 @@ class SubProduct(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(
SubProduct,
on_delete=models.CASCADE,
related_name="articles",
null=True,
blank=True,
)
badge = models.CharField(
max_length=100,
@@ -155,20 +181,47 @@ class Article(models.Model):
ordering = ["order", "title"]
verbose_name = "Article"
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
def rendered_description(self):
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):
return self.title
class SubProductVersion(models.Model):
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="versions",
null=True,
blank=True,
)
sub_product = models.ForeignKey(
SubProduct,
on_delete=models.CASCADE,
related_name="versions",
null=True,
blank=True,
)
version = models.CharField(max_length=80)
is_featured_stable = models.BooleanField(
@@ -189,12 +242,40 @@ class SubProductVersion(models.Model):
class Meta:
ordering = ["order", "version", "pk"]
unique_together = [["sub_product", "version"]]
verbose_name = "Release version"
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):
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):
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.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):
@@ -112,6 +120,72 @@ class ArticleModelTest(TestCase):
self.sub_product.delete()
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):
def setUp(self):
+49 -1
View File
@@ -83,6 +83,18 @@ class MainProductDetailViewTest(ProductViewsSetup):
response = self.client.get(url)
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):
def test_sub_detail_returns_200(self):
@@ -161,4 +173,40 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
)
response = self.client.get(url)
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(),
name="main_product_detail",
),
path(
"<slug:main_slug>/versions/",
views.MainProductOlderVersionsView.as_view(),
name="main_product_versions",
),
path(
"<slug:main_slug>/<slug:sub_slug>/versions/",
views.SubProductOlderVersionsView.as_view(),
+46 -95
View File
@@ -1,22 +1,8 @@
from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView, TemplateView
from .models import INSTALLABLE_PLATFORM_SPECS, MainProduct, SubProduct
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)
from .models import MainProduct, SubProduct
from .release_context import build_archive_context, build_release_context
class ProductOverviewView(ListView):
@@ -37,6 +23,39 @@ class MainProductDetailView(DetailView):
"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):
template_name = "products/sub_detail.html"
@@ -56,63 +75,25 @@ class SubProductDetailView(TemplateView):
)
context["main_product"] = main_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"] = (
SubProduct.objects.filter(main_product=main_product, is_active=True)
.exclude(pk=sub_product.pk)
.order_by("order", "name")
)
active_versions = sub_product.versions.filter(is_active=True)
context["distribution_installable"] = (
sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE
release_context = build_release_context(
sub_product.distribution, active_versions
)
context["distribution_package"] = (
sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE
)
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
release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
context.update(release_context)
return context
class SubProductOlderVersionsView(TemplateView):
template_name = "products/sub_versions.html"
template_name = "products/versions_archive.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -128,40 +109,10 @@ class SubProductOlderVersionsView(TemplateView):
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["sub_product"] = sub_product
context["featured_version"] = featured
context["archive_versions"] = archive
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}
context["product_detail_url"] = sub_product.get_absolute_url()
context.update(
build_archive_context(sub_product.distribution, active_versions)
)
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
+2
View File
@@ -54,6 +54,8 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"apps.core.context_processors.site_branding",
"apps.core.context_processors.site_contact",
"apps.core.context_processors.navigation",
],
},
+149 -49
View File
@@ -344,8 +344,7 @@ h1, h2, h3, h4, h5, h6 {
.brand-icon {
width: 26px;
height: 26px;
border-radius: 8px;
border: 1px solid #c4b5fd;
border-radius: 0;
object-fit: cover;
display: block;
flex-shrink: 0;
@@ -390,7 +389,7 @@ h1, h2, h3, h4, h5, h6 {
}
.nav-arrow {
transition: transform 0.22s ease;
transition: transform 0.4s ease;
}
.nav-item.has-megamenu:hover .nav-arrow,
@@ -428,7 +427,7 @@ h1, h2, h3, h4, h5, h6 {
opacity: 0;
visibility: hidden;
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;
z-index: 999;
}
@@ -444,37 +443,91 @@ h1, h2, h3, h4, h5, h6 {
.megamenu-inner {
max-width: var(--container-max);
margin: 0 auto;
padding: 2rem var(--container-padding);
padding: 1.5rem var(--container-padding);
}
.megamenu-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
.megamenu-products-scroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: rgba(79, 142, 247, 0.45) transparent;
padding-bottom: 0.35rem;
}
.megamenu-column {
padding: 1rem;
.megamenu-products-scroll::-webkit-scrollbar {
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: 1px solid var(--glass-border);
background: var(--glass-bg);
transition: var(--transition-fast);
}
.megamenu-column:hover {
background: var(--glass-bg);
.megamenu-product-item:hover,
.megamenu-product-item:focus-within {
border-color: rgba(79, 142, 247, 0.35);
background: rgba(79, 142, 247, 0.06);
}
.megamenu-product-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.4rem;
.megamenu-product-link {
display: block;
padding: 1rem;
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);
}
@@ -482,44 +535,47 @@ h1, h2, h3, h4, h5, h6 {
font-size: 0.78rem;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 0.85rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.megamenu-sublist {
display: flex;
flex-direction: column;
gap: 0.2rem;
list-style: none;
margin: 0;
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 {
display: flex;
align-items: center;
gap: 0.5rem;
display: block;
padding: 0.45rem 1rem;
font-size: 0.82rem;
color: var(--text-secondary);
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
text-decoration: none;
transition: var(--transition-fast);
}
.megamenu-sublink:hover {
.megamenu-sublink:hover,
.megamenu-sublink:focus-visible {
color: var(--accent-blue-light);
background: rgba(79, 142, 247, 0.08);
}
.sublink-dot {
width: 5px; height: 5px;
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);
.megamenu-sublink:last-child {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
/* ============================================================
@@ -831,7 +887,7 @@ h1, h2, h3, h4, h5, h6 {
width: 32px;
height: 32px;
object-fit: contain;
border-radius: var(--radius-sm);
border-radius: 0;
opacity: 0.85;
}
@@ -914,7 +970,7 @@ h1, h2, h3, h4, h5, h6 {
flex-shrink: 0;
width: 52px;
height: 52px;
border-radius: var(--radius-sm);
border-radius: 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
display: flex;
@@ -1054,6 +1110,21 @@ h1, h2, h3, h4, h5, h6 {
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
============================================================ */
@@ -1136,12 +1207,17 @@ h1, h2, h3, h4, h5, h6 {
align-items: start;
}
.sub-product-main {
.sub-product-main,
.main-product-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.sub-product-main .sub-downloads {
margin-top: 0;
}
.sub-product-image {
overflow: hidden;
padding: 0;
@@ -1563,6 +1639,10 @@ a.citation-count-badge:hover {
padding: 2.5rem;
}
.about-custom-card {
padding: 2.5rem;
}
.about-intro h2 {
font-size: 1.4rem;
margin-bottom: 1.25rem;
@@ -2319,6 +2399,10 @@ a.citation-count-badge:hover {
align-items: start;
}
.contact-layout--full {
grid-template-columns: 1fr;
}
.contact-form-wrap {
padding: 2.5rem;
}
@@ -2805,11 +2889,27 @@ a.citation-count-badge:hover {
padding: 1rem;
}
.megamenu-grid {
grid-template-columns: 1fr;
.megamenu-products-scroll {
overflow-x: visible;
padding-bottom: 0;
}
.megamenu-products {
flex-direction: column;
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 {
transform: none;
}
+1 -1
View File
@@ -77,7 +77,7 @@ function initMegaMenu() {
hideTimer = setTimeout(() => {
productsItem.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
}, 180);
}, 420);
}
productsItem.addEventListener('mouseenter', openMenu);
+2 -2
View File
@@ -105,8 +105,8 @@
</div>
{% endif %}
{% if section.content %}
<div class="glass-card fade-in">
<div class="rich-content" style="padding: 2rem;" data-readmore="200">{{ section.rendered_content }}</div>
<div class="glass-card fade-in about-custom-card">
<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
</div>
{% endif %}
{% with items=section.items.all %}
+21 -14
View File
@@ -22,7 +22,7 @@
<section class="section" aria-labelledby="contact-form-heading">
<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">
<h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2>
@@ -75,7 +75,9 @@
</form>
</div>
{% if site_contact.has_contact_sidebar %}
<aside class="contact-sidebar">
{% if site_contact.has_support_email %}
<div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true">
<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"/>
</svg>
</div>
<h3 class="contact-card-title">Email Support</h3>
<p class="contact-card-desc">For direct software support:</p>
<a href="mailto:support@radiuma.com" class="contact-email-link">support@radiuma.com</a>
<h3 class="contact-card-title">{{ site_contact.email_card_title_display }}</h3>
{% if site_contact.email_card_description %}
<p class="contact-card-desc">{{ site_contact.email_card_description }}</p>
{% endif %}
{% include "partials/_contact_email.html" with link_class="contact-email-link" %}
</div>
{% endif %}
{% if site_contact.has_discord %}
<div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true">
<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"/>
</svg>
</div>
<h3 class="contact-card-title">Discord Community</h3>
<p class="contact-card-desc">Join for community support and announcements.</p>
<a href="https://discord.gg/9XxA6pV9hb" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">Join Discord</a>
<h3 class="contact-card-title">{{ site_contact.discord_card_title_display }}</h3>
{% if site_contact.discord_card_description %}
<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>
{% endif %}
{% if site_contact.has_office_address %}
<div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true">
<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"/>
</svg>
</div>
<h3 class="contact-card-title">Office</h3>
<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>
<h3 class="contact-card-title">{{ site_contact.office_card_title_display }}</h3>
{% include "partials/_contact_office.html" with address_class="contact-address" %}
</div>
{% endif %}
</aside>
{% endif %}
</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">
<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>
</a>
<p class="footer-tagline">
Advancing medical imaging and radiomics research through innovative, standardized software solutions.
</p>
<a href="https://discord.gg/9XxA6pV9hb" class="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>
Join Discord
</a>
{% include "partials/_contact_discord.html" with button_class="btn-ghost btn-sm" %}
</div>
<div class="footer-links-col">
@@ -34,29 +29,27 @@
</ul>
</div>
{% if footer_sub_products %}
{% if footer_main_products %}
<div class="footer-links-col">
<h3 class="footer-heading">Products</h3>
<ul class="footer-links" role="list">
{% for sub in footer_sub_products %}
<li><a href="{{ sub.get_absolute_url }}">{{ sub.name }}</a></li>
{% for product in footer_main_products %}
<li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li>
{% endfor %}
{% if footer_sub_products_has_more %}
<li><a href="/products/radiuma/">more</a></li>
{% if footer_main_products_has_more %}
<li><a href="{% url 'products:main_product_detail' main_slug='radiuma' %}">more</a></li>
{% endif %}
</ul>
</div>
{% endif %}
{% if site_contact.has_footer_contact %}
<div class="footer-contact-col">
<h3 class="footer-heading">Contact</h3>
<address class="footer-address">
<p>BC Cancer Research Center</p>
<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>
{% include "partials/_contact_office.html" with address_class="footer-address" %}
{% include "partials/_contact_email.html" with link_class="footer-email" %}
</div>
{% endif %}
</div>
+20 -21
View File
@@ -3,7 +3,7 @@
<div class="navbar-container">
<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>
</a>
@@ -32,34 +32,33 @@
</a>
<div class="megamenu" role="menu" aria-labelledby="productsTrigger">
<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 %}
<div class="megamenu-column">
<a href="{{ product.get_absolute_url }}" class="megamenu-product-title" role="menuitem">
<li class="megamenu-product-item{% if product.sub_products.all %} has-subproducts{% endif %}">
<a href="{{ product.get_absolute_url }}" class="megamenu-product-link" role="menuitem">
<span class="megamenu-product-name">
{{ product.name }}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M3 7H11M11 7L8 4M11 7L8 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
{% if product.sub_products.all %}
<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>
</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 %}
</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 %}
</ul>
{% endif %}
{% endwith %}
</div>
</li>
{% endfor %}
</ul>
</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>
</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="main-product-main">
<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">
{% if main_product.image %}
<img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" />
@@ -43,12 +43,24 @@
{% endif %}
</div>
<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>
</div>
{% include "products/_releases_section.html" %}
</div>
</div>
</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 %}
{% if subs %}
<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>
{% 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>
{% include "products/_releases_section.html" %}
<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="{{ 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 %}
{% include "products/_article_list.html" %}
</main>
<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 %}