diff --git a/.gitignore b/.gitignore index 96dbc5b..05ea864 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ venv.bak/ media/ staticfiles/ +*.DS_Store \ No newline at end of file diff --git a/apps/core/admin.py b/apps/core/admin.py new file mode 100644 index 0000000..8c25e6d --- /dev/null +++ b/apps/core/admin.py @@ -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) diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index 8139b35..daabfb8 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -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, } diff --git a/apps/core/management/commands/seed_content.py b/apps/core/management/commands/seed_content.py index 6281454..e9e36b3 100644 --- a/apps/core/management/commands/seed_content.py +++ b/apps/core/management/commands/seed_content.py @@ -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") diff --git a/apps/core/migrations/0001_site_branding.py b/apps/core/migrations/0001_site_branding.py new file mode 100644 index 0000000..04b97d1 --- /dev/null +++ b/apps/core/migrations/0001_site_branding.py @@ -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', + }, + ), + ] diff --git a/apps/core/migrations/0002_site_contact.py b/apps/core/migrations/0002_site_contact.py new file mode 100644 index 0000000..2ce9405 --- /dev/null +++ b/apps/core/migrations/0002_site_contact.py @@ -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), + ] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..4f3fb1a --- /dev/null +++ b/apps/core/models.py @@ -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" diff --git a/apps/core/tests/__init__.py b/apps/core/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/tests/test_site_branding.py b/apps/core/tests/test_site_branding.py new file mode 100644 index 0000000..b2eed72 --- /dev/null +++ b/apps/core/tests/test_site_branding.py @@ -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) diff --git a/apps/core/tests/test_site_contact.py b/apps/core/tests/test_site_contact.py new file mode 100644 index 0000000..80e0323 --- /dev/null +++ b/apps/core/tests/test_site_contact.py @@ -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") diff --git a/apps/products/admin.py b/apps/products/admin.py index 62486a7..5a887fe 100644 --- a/apps/products/admin.py +++ b/apps/products/admin.py @@ -17,30 +17,52 @@ 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 MainProductArticleInline(admin.StackedInline): + model = Article + fk_name = "main_product" + extra = 0 + fields = ("badge", "title", "description", "order") + ordering = ("order",) + show_change_link = True + + +RELEASE_VERSION_INLINE_FIELDS = ( + "version", + "is_featured_stable", + "windows_download_url", + "macos_download_url", + "linux_download_url", + "source_code_url", + "package_resource_url", + "release_notes", + "is_active", + "order", +) + + class SubProductVersionInline(admin.TabularInline): model = SubProductVersion + fk_name = "sub_product" extra = 0 ordering = ("order", "version") - fields = ( - "version", - "is_featured_stable", - "windows_download_url", - "macos_download_url", - "linux_download_url", - "source_code_url", - "package_resource_url", - "release_notes", - "is_active", - "order", - ) + 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): @@ -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 "—" diff --git a/apps/products/migrations/0009_article_main_product.py b/apps/products/migrations/0009_article_main_product.py new file mode 100644 index 0000000..a265535 --- /dev/null +++ b/apps/products/migrations/0009_article_main_product.py @@ -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'), + ), + ] diff --git a/apps/products/migrations/0010_release_version_main_product.py b/apps/products/migrations/0010_release_version_main_product.py new file mode 100644 index 0000000..45d37b4 --- /dev/null +++ b/apps/products/migrations/0010_release_version_main_product.py @@ -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'), + ), + ] diff --git a/apps/products/models.py b/apps/products/models.py index 080a494..810007e 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -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 = [] diff --git a/apps/products/release_context.py b/apps/products/release_context.py new file mode 100644 index 0000000..2a5438a --- /dev/null +++ b/apps/products/release_context.py @@ -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 diff --git a/apps/products/tests/test_models.py b/apps/products/tests/test_models.py index 7899489..e54da66 100644 --- a/apps/products/tests/test_models.py +++ b/apps/products/tests/test_models.py @@ -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): diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py index 8749440..f6490a3 100644 --- a/apps/products/tests/test_views.py +++ b/apps/products/tests/test_views.py @@ -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"]) diff --git a/apps/products/urls.py b/apps/products/urls.py index 65e13a4..ad6be2a 100644 --- a/apps/products/urls.py +++ b/apps/products/urls.py @@ -11,6 +11,11 @@ urlpatterns = [ views.MainProductDetailView.as_view(), name="main_product_detail", ), + path( + "/versions/", + views.MainProductOlderVersionsView.as_view(), + name="main_product_versions", + ), path( "//versions/", views.SubProductOlderVersionsView.as_view(), diff --git a/apps/products/views.py b/apps/products/views.py index f18801b..0cea905 100644 --- a/apps/products/views.py +++ b/apps/products/views.py @@ -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} - ) - 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 + context["product_detail_url"] = sub_product.get_absolute_url() + context.update( + build_archive_context(sub_product.distribution, active_versions) + ) return context diff --git a/config/settings/base.py b/config/settings/base.py index 8e69e76..b1a8dc0 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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", ], }, diff --git a/static/css/main.css b/static/css/main.css index a92cfb9..2c34b21 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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; } diff --git a/static/js/main.js b/static/js/main.js index 84427c7..9ed5629 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -77,7 +77,7 @@ function initMegaMenu() { hideTimer = setTimeout(() => { productsItem.classList.remove('open'); trigger.setAttribute('aria-expanded', 'false'); - }, 180); + }, 420); } productsItem.addEventListener('mouseenter', openMenu); diff --git a/templates/pages/about.html b/templates/pages/about.html index 1225f0d..3a12268 100644 --- a/templates/pages/about.html +++ b/templates/pages/about.html @@ -105,8 +105,8 @@ {% endif %} {% if section.content %} -
-
{{ section.rendered_content }}
+
+
{{ section.rendered_content }}
{% endif %} {% with items=section.items.all %} diff --git a/templates/pages/contact.html b/templates/pages/contact.html index d74e0d6..6b192b9 100644 --- a/templates/pages/contact.html +++ b/templates/pages/contact.html @@ -22,7 +22,7 @@
-
+

Send a Message

@@ -75,7 +75,9 @@
+ {% if site_contact.has_contact_sidebar %} + {% endif %}
diff --git a/templates/partials/_brand_icon.html b/templates/partials/_brand_icon.html new file mode 100644 index 0000000..53dd856 --- /dev/null +++ b/templates/partials/_brand_icon.html @@ -0,0 +1,20 @@ +{% load static %} +{% if placement == "footer" %} + +{% else %} + +{% endif %} diff --git a/templates/partials/_contact_discord.html b/templates/partials/_contact_discord.html new file mode 100644 index 0000000..5edce92 --- /dev/null +++ b/templates/partials/_contact_discord.html @@ -0,0 +1,8 @@ +{% if site_contact.has_discord %} + + + {{ site_contact.discord_label_display }} + +{% endif %} diff --git a/templates/partials/_contact_email.html b/templates/partials/_contact_email.html new file mode 100644 index 0000000..a1da6e4 --- /dev/null +++ b/templates/partials/_contact_email.html @@ -0,0 +1,3 @@ +{% if site_contact.has_support_email %} +{{ site_contact.support_email }} +{% endif %} diff --git a/templates/partials/_contact_office.html b/templates/partials/_contact_office.html new file mode 100644 index 0000000..3eec51c --- /dev/null +++ b/templates/partials/_contact_office.html @@ -0,0 +1,7 @@ +{% if site_contact.has_office_address %} +
+ {% for line in site_contact.office_address_lines %} +

{{ line }}

+ {% endfor %} +
+{% endif %} diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html index 816b7d0..9f15ed8 100644 --- a/templates/partials/_footer.html +++ b/templates/partials/_footer.html @@ -9,18 +9,13 @@ - {% if footer_sub_products %} + {% if footer_main_products %} {% endif %} + {% if site_contact.has_footer_contact %} + {% endif %}
diff --git a/templates/partials/_navbar.html b/templates/partials/_navbar.html index 20a6c7b..f889af2 100644 --- a/templates/partials/_navbar.html +++ b/templates/partials/_navbar.html @@ -3,7 +3,7 @@
-
+
-
-

About {{ main_product.name }}

-
- {% if main_product.image %} - {{ main_product.name }} - {% else %} - {{ main_product.name }} logo - {% endif %} -
-
-
{{ main_product.rendered_description }}
+
+
+
+ {% if main_product.image %} + {{ main_product.name }} + {% else %} + {{ main_product.name }} logo + {% endif %} +
+
+

About {{ main_product.name }}

+
{{ main_product.rendered_description }}
+
+ + {% include "products/_releases_section.html" %}
+{% if articles %} +
+
+ {% include "products/_article_list.html" %} +
+
+{% endif %} + {% with subs=main_product.sub_products.all %} {% if subs %}
diff --git a/templates/products/sub_detail.html b/templates/products/sub_detail.html index 3918afe..12318a7 100644 --- a/templates/products/sub_detail.html +++ b/templates/products/sub_detail.html @@ -47,177 +47,9 @@
{{ sub_product.rendered_description }}
- {% if show_releases_section %} - {% if distribution_installable %} -
-

- - Downloads -

+ {% include "products/_releases_section.html" %} -
- {% if featured_version.is_featured_stable %} - - - Stable - - {% endif %} - {% if featured_install_cells %} -
- {% for cell in featured_install_cells %} -
-
- {% if cell.icon %} - - {% else %} - - {% endif %} -
- {{ cell.label }} - v{{ featured_version.version }} - {% if cell.label == "Source code" %}Source{% else %}Download{% endif %} -
- {% endfor %} -
- {% endif %} -
- - {% if featured_version.package_resource_url %} -

- Package resource -

- {% endif %} - - {% if featured_version.release_notes %} -

{{ featured_version.release_notes }}

- {% endif %} - - {% if show_older_versions_link %} -

- Older releases -

- {% endif %} -
- - {% elif distribution_package %} -
-

- - Resources -

-
    - {% for ver in package_versions %} -
  • -
    - v{{ ver.version }} - {% if ver.is_featured_stable %} - - - Stable - - {% endif %} -
    -
    - {% if ver.package_resource_url %} - Open - {% endif %} - {% if ver.source_code_url %} - Source - {% endif %} -
    - {% if ver.release_notes %} -

    {{ ver.release_notes }}

    - {% endif %} -
  • - {% endfor %} -
-
- {% endif %} - - {% endif %} - - {% if articles %} -
-
- {% for article in articles %} -
- {% if article.badge %}
{{ article.badge }}
{% endif %} -

{{ article.title }}

-
{{ article.rendered_description }}
- {% if article.sections.all %} -
-
- {% for section in article.sections.all %} -
-
{{ section.title }}
-
{{ section.rendered_value }}
-
- {% endfor %} -
-
- {% endif %} - {% if article.citations.all %} -
-

Citations

-
    - {% for citation in article.citations.all %} -
  1. - {{ citation.text }} - {% if citation.url %} - - - - - - - {% endif %} -
  2. - {% endfor %} -
-
- {% endif %} - {% if article.citation_count_display is not None or article.citation_count_label %} - {% if article.citations.exists %} - - {% if article.citation_count_display is not None %}{{ article.citation_count_display }}{% endif %} - {% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %} - - {% else %} -
- {% if article.citation_count_display is not None %}{{ article.citation_count_display }}{% endif %} - {% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %} -
- {% endif %} - {% endif %} -
- {% endfor %} -
-
- {% endif %} + {% include "products/_article_list.html" %}