From cacf1ba77bec19c8aedab15d2e27278d37f351f7 Mon Sep 17 00:00:00 2001 From: mohamad Date: Mon, 25 May 2026 17:17:27 +0330 Subject: [PATCH] feat: new dynamic pages in admin panel --- apps/core/context_processors.py | 6 + apps/pages/admin.py | 70 +++++ apps/pages/migrations/0008_custom_pages.py | 79 +++++ apps/pages/models.py | 134 +++++++++ apps/pages/tests/test_custom_pages.py | 74 +++++ apps/pages/tests/test_views.py | 15 + apps/pages/urls.py | 1 + apps/pages/views.py | 35 ++- static/css/main.css | 26 +- static/js/main.js | 47 ++- templates/pages/about.html | 134 +-------- templates/pages/custom_page.html | 8 + templates/partials/_navbar.html | 8 + templates/partials/_page_sections.html | 330 +++++++++++++++++++++ 14 files changed, 818 insertions(+), 149 deletions(-) create mode 100644 apps/pages/migrations/0008_custom_pages.py create mode 100644 apps/pages/tests/test_custom_pages.py create mode 100644 templates/pages/custom_page.html create mode 100644 templates/partials/_page_sections.html diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index daabfb8..7420418 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -1,6 +1,7 @@ from django.db.models import Prefetch from apps.core.models import SiteBranding, SiteContact +from apps.pages.models import CustomPage from apps.products.models import MainProduct, SubProduct @@ -28,8 +29,13 @@ def navigation(request): all_footer_products = list(main_products[:5]) footer_main_products = all_footer_products[:4] footer_main_products_has_more = len(all_footer_products) == 5 + nav_custom_pages = CustomPage.objects.filter( + is_published=True, + show_in_nav=True, + ).order_by("menu_order", "title") return { "nav_main_products": main_products, + "nav_custom_pages": nav_custom_pages, "footer_main_products": footer_main_products, "footer_main_products_has_more": footer_main_products_has_more, } diff --git a/apps/pages/admin.py b/apps/pages/admin.py index e30ab78..9af651f 100644 --- a/apps/pages/admin.py +++ b/apps/pages/admin.py @@ -6,6 +6,9 @@ from .models import ( AboutSection, AboutSectionItem, ContactSubmission, + CustomPage, + CustomPageSection, + CustomPageSectionItem, DownloadItem, FAQEntry, HeroSection, @@ -106,6 +109,73 @@ class FAQEntryAdmin(admin.ModelAdmin): ) +class CustomPageSectionItemInline(admin.TabularInline): + model = CustomPageSectionItem + extra = 1 + fields = ( + "icon", + "badge", + "title", + "content_format", + "content", + "url", + "image", + "image_alt", + "is_featured", + "order", + ) + ordering = ("order",) + + +class CustomPageSectionInline(admin.StackedInline): + model = CustomPageSection + extra = 1 + fields = ( + "section_type", + "badge", + "title", + "subtitle", + "description", + "content_format", + "content", + "link_text", + "link_url", + "order", + "is_active", + ) + ordering = ("order",) + show_change_link = True + + +@admin.register(CustomPage) +class CustomPageAdmin(admin.ModelAdmin): + list_display = ("title", "slug", "show_in_nav", "menu_order", "is_published", "updated_at") + list_filter = ("show_in_nav", "is_published") + search_fields = ("title", "slug", "menu_label") + list_editable = ("show_in_nav", "menu_order", "is_published") + prepopulated_fields = {"slug": ("title",)} + inlines = [CustomPageSectionInline] + fieldsets = ( + (None, {"fields": ("title", "slug", "menu_label", "meta_description")}), + ("Navigation", {"fields": ("show_in_nav", "menu_order")}), + ("Publishing", {"fields": ("is_published",)}), + ) + + +@admin.register(CustomPageSection) +class CustomPageSectionAdmin(admin.ModelAdmin): + list_display = ("page", "section_type", "title", "order", "is_active") + list_filter = ("section_type", "is_active", "page") + list_editable = ("order", "is_active") + inlines = [CustomPageSectionItemInline] + fieldsets = ( + (None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}), + ("Text", {"fields": ("description", "content_format", "content")}), + ("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}), + ("Settings", {"fields": ("order", "is_active")}), + ) + + @admin.register(DownloadItem) class DownloadItemAdmin(admin.ModelAdmin): list_display = ("name", "platform", "version", "is_active", "order") diff --git a/apps/pages/migrations/0008_custom_pages.py b/apps/pages/migrations/0008_custom_pages.py new file mode 100644 index 0000000..0e7cf2b --- /dev/null +++ b/apps/pages/migrations/0008_custom_pages.py @@ -0,0 +1,79 @@ +# Generated by Django 5.0.2 on 2026-05-25 12:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0007_hero_section'), + ] + + operations = [ + migrations.CreateModel( + name='CustomPage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('slug', models.SlugField(max_length=200, unique=True)), + ('menu_label', models.CharField(blank=True, help_text='Nav label when shown in menu. Defaults to title.', max_length=100)), + ('meta_description', models.CharField(blank=True, max_length=300)), + ('show_in_nav', models.BooleanField(default=True)), + ('menu_order', models.PositiveIntegerField(default=0)), + ('is_published', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Custom Page', + 'verbose_name_plural': 'Custom Pages', + 'ordering': ['menu_order', 'title'], + }, + ), + migrations.CreateModel( + name='CustomPageSection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('section_type', models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('subtitle', models.CharField(blank=True, max_length=500)), + ('description', models.TextField(blank=True, help_text='Short intro text (homepage-style sections).')), + ('content', models.TextField(blank=True)), + ('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)), + ('link_text', models.CharField(blank=True, max_length=100)), + ('link_url', models.CharField(blank=True, max_length=300)), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='pages.custompage')), + ], + options={ + 'verbose_name': 'Custom Page Section', + 'verbose_name_plural': 'Custom Page Sections', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='CustomPageSectionItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('icon', models.CharField(blank=True, max_length=20)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('content', models.TextField(blank=True)), + ('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20)), + ('url', models.URLField(blank=True)), + ('image', models.ImageField(blank=True, null=True, upload_to='pages/custom/')), + ('image_alt', models.CharField(blank=True, max_length=200)), + ('is_featured', models.BooleanField(default=False)), + ('order', models.PositiveIntegerField(default=0)), + ('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.custompagesection')), + ], + options={ + 'verbose_name': 'Custom Page Section Item', + 'verbose_name_plural': 'Custom Page Section Items', + 'ordering': ['order'], + }, + ), + ] diff --git a/apps/pages/models.py b/apps/pages/models.py index f87d85c..8091c69 100644 --- a/apps/pages/models.py +++ b/apps/pages/models.py @@ -1,7 +1,20 @@ +from django.core.exceptions import ValidationError from django.db import models +from django.utils.text import slugify from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content +RESERVED_PAGE_SLUGS = frozenset({ + "admin", + "about", + "contact", + "faq", + "home", + "products", + "static", + "media", +}) + class HeroSection(models.Model): badge = models.CharField(max_length=200, blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').") @@ -204,3 +217,124 @@ class DownloadItem(models.Model): def __str__(self): return f"{self.name} ({self.get_platform_display()})" + + +class CustomPage(models.Model): + title = models.CharField(max_length=200) + slug = models.SlugField(max_length=200, unique=True) + menu_label = models.CharField( + max_length=100, + blank=True, + help_text="Nav label when shown in menu. Defaults to title.", + ) + meta_description = models.CharField(max_length=300, blank=True) + show_in_nav = models.BooleanField(default=True) + menu_order = models.PositiveIntegerField(default=0) + is_published = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["menu_order", "title"] + verbose_name = "Custom Page" + verbose_name_plural = "Custom Pages" + + def __str__(self): + return self.title + + @property + def nav_label(self): + return self.menu_label or self.title + + def clean(self): + super().clean() + if self.slug in RESERVED_PAGE_SLUGS: + raise ValidationError({"slug": f'"{self.slug}" is reserved and cannot be used.'}) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + self.full_clean() + super().save(*args, **kwargs) + + +class CustomPageSection(models.Model): + TYPE_HERO = "hero" + TYPE_INTRO = "intro" + TYPE_GRID = "grid" + TYPE_HISTORY = "history" + TYPE_CUSTOM = "custom" + TYPE_FEATURES = "features" + TYPE_SCREENSHOTS = "screenshots" + TYPE_PRODUCTS = "products" + TYPE_PROBLEMS = "problems" + TYPE_SUPPORTERS = "supporters" + TYPE_ABOUT_STRIP = "about_strip" + TYPE_FAQ = "faq" + + TYPE_CHOICES = [ + (TYPE_HERO, "Hero"), + (TYPE_INTRO, "Intro Card"), + (TYPE_GRID, "Grid Cards"), + (TYPE_HISTORY, "History Block"), + (TYPE_CUSTOM, "Custom Content"), + (TYPE_FEATURES, "Features Grid"), + (TYPE_SCREENSHOTS, "Screenshots Gallery"), + (TYPE_PRODUCTS, "Products Grid"), + (TYPE_PROBLEMS, "Problems / Value Proposition"), + (TYPE_SUPPORTERS, "Supporters"), + (TYPE_ABOUT_STRIP, "About Strip"), + (TYPE_FAQ, "FAQ Accordion"), + ] + + page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections") + section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + subtitle = models.CharField(max_length=500, blank=True) + description = models.TextField(blank=True, help_text="Short intro text (homepage-style sections).") + content = models.TextField(blank=True) + content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN) + link_text = models.CharField(max_length=100, blank=True) + link_url = models.CharField(max_length=300, blank=True) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["order"] + verbose_name = "Custom Page Section" + verbose_name_plural = "Custom Page Sections" + + @property + def rendered_content(self): + return render_content(self.content, self.content_format) + + def __str__(self): + label = self.title or self.badge or self.get_section_type_display() + return f"{self.page} › [{self.get_section_type_display()}] {label}" + + +class CustomPageSectionItem(models.Model): + section = models.ForeignKey(CustomPageSection, on_delete=models.CASCADE, related_name="items") + icon = models.CharField(max_length=20, blank=True) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + content = models.TextField(blank=True) + content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN) + url = models.URLField(blank=True) + image = models.ImageField(upload_to="pages/custom/", blank=True, null=True) + image_alt = models.CharField(max_length=200, blank=True) + is_featured = models.BooleanField(default=False) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + verbose_name = "Custom Page Section Item" + verbose_name_plural = "Custom Page Section Items" + + @property + def rendered_content(self): + return render_content(self.content, self.content_format) + + def __str__(self): + return f"{self.section} › {self.title or self.icon or '(item)'}" diff --git a/apps/pages/tests/test_custom_pages.py b/apps/pages/tests/test_custom_pages.py new file mode 100644 index 0000000..f4bdd54 --- /dev/null +++ b/apps/pages/tests/test_custom_pages.py @@ -0,0 +1,74 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.pages.models import CustomPage, CustomPageSection + + +class CustomPageViewTest(TestCase): + def setUp(self): + self.page = CustomPage.objects.create( + title="Resources", + slug="resources", + show_in_nav=True, + menu_order=5, + is_published=True, + ) + CustomPageSection.objects.create( + page=self.page, + section_type=CustomPageSection.TYPE_HERO, + title="Resources", + badge="Docs", + is_active=True, + ) + CustomPage.objects.create( + title="Draft Page", + slug="draft", + is_published=False, + ) + + def test_published_page_returns_200(self): + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"})) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "pages/custom_page.html") + self.assertEqual(response.context["custom_page"], self.page) + + def test_unpublished_page_returns_404(self): + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "draft"})) + self.assertEqual(response.status_code, 404) + + def test_unknown_slug_returns_404(self): + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "missing"})) + self.assertEqual(response.status_code, 404) + + +class CustomPageNavTest(TestCase): + def test_nav_custom_pages_in_context(self): + CustomPage.objects.create( + title="Visible", + slug="visible", + show_in_nav=True, + menu_order=1, + is_published=True, + ) + CustomPage.objects.create( + title="Hidden Nav", + slug="hidden-nav", + show_in_nav=False, + is_published=True, + ) + response = self.client.get(reverse("pages:home")) + pages = list(response.context["nav_custom_pages"]) + self.assertEqual(len(pages), 1) + self.assertEqual(pages[0].slug, "visible") + + def test_nav_link_rendered(self): + CustomPage.objects.create( + title="Team", + slug="team", + menu_label="Our Team", + show_in_nav=True, + is_published=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertContains(response, "Our Team") + self.assertContains(response, reverse("pages:custom_page", kwargs={"slug": "team"})) diff --git a/apps/pages/tests/test_views.py b/apps/pages/tests/test_views.py index 547c7bf..096118f 100644 --- a/apps/pages/tests/test_views.py +++ b/apps/pages/tests/test_views.py @@ -80,3 +80,18 @@ class NavigationContextTest(TestCase): response.context, f"Missing nav_main_products at {url}", ) + + def test_nav_custom_pages_in_context_on_all_pages(self): + urls = [ + reverse("pages:home"), + reverse("pages:about"), + reverse("pages:faq"), + reverse("pages:contact"), + ] + for url in urls: + response = self.client.get(url) + self.assertIn( + "nav_custom_pages", + response.context, + f"Missing nav_custom_pages at {url}", + ) diff --git a/apps/pages/urls.py b/apps/pages/urls.py index a836d4e..cf13072 100644 --- a/apps/pages/urls.py +++ b/apps/pages/urls.py @@ -9,4 +9,5 @@ urlpatterns = [ path("about/", views.AboutView.as_view(), name="about"), path("faq/", views.FAQView.as_view(), name="faq"), path("contact/", views.ContactView.as_view(), name="contact"), + path("/", views.CustomPageView.as_view(), name="custom_page"), ] diff --git a/apps/pages/views.py b/apps/pages/views.py index 62c43f9..b8fff2c 100644 --- a/apps/pages/views.py +++ b/apps/pages/views.py @@ -3,12 +3,19 @@ import random from django.http import JsonResponse from django.shortcuts import render from django.views import View -from django.views.generic import ListView, TemplateView +from django.views.generic import DetailView, ListView, TemplateView from apps.products.models import SubProduct from .forms import ContactForm -from .models import AboutSection, ContactSubmission, FAQEntry, HeroSection, HomepageSection +from .models import ( + AboutSection, + ContactSubmission, + CustomPage, + FAQEntry, + HeroSection, + HomepageSection, +) class HomeView(TemplateView): @@ -90,3 +97,27 @@ class ContactView(View): {"success": False, "errors": errors, "captcha_question": captcha_question}, status=400, ) + + +class CustomPageView(DetailView): + model = CustomPage + template_name = "pages/custom_page.html" + context_object_name = "custom_page" + slug_url_kwarg = "slug" + + def get_queryset(self): + return CustomPage.objects.filter(is_published=True) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["page_sections"] = ( + self.object.sections.filter(is_active=True) + .prefetch_related("items") + .order_by("order") + ) + ctx["sub_products"] = ( + SubProduct.objects.filter(is_active=True, show_on_homepage=True) + .select_related("main_product") + .order_by("homepage_order", "order") + ) + return ctx diff --git a/static/css/main.css b/static/css/main.css index 2c34b21..1dff5c3 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -45,6 +45,9 @@ --spacing-2xl: 7rem; --navbar-height: 68px; + --navbar-expand-duration: 0.85s; + --navbar-link-duration: 0.28s; + --navbar-expand-ease: cubic-bezier(0.4, 0, 0.1, .9); --container-max: 1200px; --container-padding: 1.5rem; @@ -378,7 +381,8 @@ h1, h2, h3, h4, h5, h6 { font-weight: 500; color: var(--text-secondary); text-decoration: none; - transition: var(--transition-fast); + transition: color var(--navbar-link-duration) var(--navbar-expand-ease), + background var(--navbar-link-duration) var(--navbar-expand-ease); white-space: nowrap; } @@ -389,7 +393,7 @@ h1, h2, h3, h4, h5, h6 { } .nav-arrow { - transition: transform 0.4s ease; + transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease); } .nav-item.has-megamenu:hover .nav-arrow, @@ -427,7 +431,9 @@ h1, h2, h3, h4, h5, h6 { opacity: 0; visibility: hidden; transform: translateY(-10px); - transition: opacity 0.6s ease, visibility 0.6s ease, transform 0.6s ease; + transition: opacity var(--navbar-expand-duration) var(--navbar-expand-ease), + visibility var(--navbar-expand-duration) var(--navbar-expand-ease), + transform var(--navbar-expand-duration) var(--navbar-expand-ease); pointer-events: none; z-index: 999; } @@ -440,6 +446,10 @@ h1, h2, h3, h4, h5, h6 { pointer-events: auto; } +.megamenu.is-closing { + pointer-events: auto; +} + .megamenu-inner { max-width: var(--container-max); margin: 0 auto; @@ -522,7 +532,8 @@ h1, h2, h3, h4, h5, h6 { .megamenu-sub-chevron { flex-shrink: 0; color: var(--text-muted); - transition: transform 0.4s ease, color 0.4s ease; + transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease), + color var(--navbar-expand-duration) var(--navbar-expand-ease); } .megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron, @@ -549,7 +560,9 @@ h1, h2, h3, h4, h5, h6 { max-height: 0; overflow: hidden; opacity: 0; - transition: max-height 0.4s ease, opacity 0.35s ease, border-color 0.35s ease; + transition: max-height var(--navbar-expand-duration) var(--navbar-expand-ease), + opacity var(--navbar-expand-duration) var(--navbar-expand-ease), + border-color var(--navbar-expand-duration) var(--navbar-expand-ease); } .megamenu-product-item.has-subproducts:hover .megamenu-sublist, @@ -2853,7 +2866,8 @@ a.citation-count-badge:hover { padding: 1rem var(--container-padding) 1.5rem; max-height: 0; overflow: hidden; - transition: max-height 0.35s ease, padding 0.35s ease; + transition: max-height var(--navbar-expand-duration) var(--navbar-expand-ease), + padding var(--navbar-expand-duration) var(--navbar-expand-ease); } .navbar-menu.open { diff --git a/static/js/main.js b/static/js/main.js index 9ed5629..38bac99 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -65,21 +65,46 @@ function initMegaMenu() { const megamenu = productsItem.querySelector('.megamenu'); if (!trigger || !megamenu) return; + const CLOSE_DELAY_MS = 150; let hideTimer = null; - function openMenu() { + function isPointerInMenu(target) { + if (!target || !(target instanceof Node)) return false; + return productsItem.contains(target) || megamenu.contains(target); + } + + function cancelClose() { clearTimeout(hideTimer); + hideTimer = null; + megamenu.classList.remove('is-closing'); + } + + function openMenu() { + cancelClose(); productsItem.classList.add('open'); trigger.setAttribute('aria-expanded', 'true'); } - function scheduleClose() { - hideTimer = setTimeout(() => { - productsItem.classList.remove('open'); - trigger.setAttribute('aria-expanded', 'false'); - }, 420); + function beginClose() { + productsItem.classList.remove('open'); + trigger.setAttribute('aria-expanded', 'false'); + megamenu.classList.add('is-closing'); } + function scheduleClose(event) { + if (event && isPointerInMenu(event.relatedTarget)) return; + + clearTimeout(hideTimer); + hideTimer = setTimeout(beginClose, CLOSE_DELAY_MS); + } + + megamenu.addEventListener('transitionend', (event) => { + if (event.target !== megamenu) return; + if (event.propertyName !== 'opacity' && event.propertyName !== 'visibility') return; + if (productsItem.classList.contains('open')) return; + megamenu.classList.remove('is-closing'); + }); + productsItem.addEventListener('mouseenter', openMenu); productsItem.addEventListener('mouseleave', scheduleClose); @@ -90,11 +115,17 @@ function initMegaMenu() { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const isOpen = productsItem.classList.toggle('open'); + if (isOpen) { + openMenu(); + } else { + cancelClose(); + beginClose(); + } trigger.setAttribute('aria-expanded', String(isOpen)); } if (e.key === 'Escape') { - productsItem.classList.remove('open'); - trigger.setAttribute('aria-expanded', 'false'); + cancelClose(); + beginClose(); } }); } diff --git a/templates/pages/about.html b/templates/pages/about.html index 3a12268..f9dfc53 100644 --- a/templates/pages/about.html +++ b/templates/pages/about.html @@ -1,140 +1,8 @@ {% extends "base.html" %} -{% load static %} {% block title %}About{% endblock %} {% block meta_description %}Learn about Radiuma — Visualized & Standardized Environment for Radiomics Analysis, developed at UBC and BC Cancer Research Institute.{% endblock %} {% block content %} - -{% for section in about_sections %} - -{% if section.section_type == "hero" %} -
- -
-
- {% if section.badge %}
{{ section.badge }}
{% endif %} - {% if section.title %}

{{ section.title }}

{% endif %} - {% if section.subtitle %}

{{ section.subtitle }}

{% endif %} - {% if section.content %}
{{ section.rendered_content }}
{% endif %} -
-
-
- -{% elif section.section_type == "intro" %} -
-
-
-
- {% if section.title %}

{{ section.title }}

{% endif %} - {% if section.content %} -
{{ section.rendered_content }}
- {% endif %} -
-
-
-
- -{% elif section.section_type == "grid" %} -
-
- {% if section.badge or section.title %} -
- {% if section.badge %}
{{ section.badge }}
{% endif %} - {% if section.title %}

{{ section.title }}

{% endif %} -
- {% endif %} - {% if section.content %}
{{ section.rendered_content }}
{% endif %} - {% with items=section.items.all %} - {% if items %} -
- {% for item in items %} -
- {% if item.badge %}
{{ item.badge }}
{% endif %} - {% if item.title %}

{{ item.title }}

{% endif %} - {% if item.content %}

{{ item.content }}

{% endif %} -
- {% endfor %} -
- {% endif %} - {% endwith %} -
-
- -{% elif section.section_type == "history" %} -
-
-
-
- {% if section.badge %}
{{ section.badge }}
{% endif %} - {% if section.title %}

{{ section.title }}

{% endif %} - {% if section.content %}
{{ section.rendered_content }}
{% endif %} - {% with links=section.items.all %} - {% if links %} - - {% endif %} - {% endwith %} -
- -
-
-
- -{% elif section.section_type == "custom" %} -
-
- {% if section.badge or section.title %} -
- {% if section.badge %}
{{ section.badge }}
{% endif %} - {% if section.title %}

{{ section.title }}

{% endif %} -
- {% endif %} - {% if section.content %} -
-
{{ section.rendered_content }}
-
- {% endif %} - {% with items=section.items.all %} - {% if items %} -
- {% for item in items %} -
- {% if item.badge %}
{{ item.badge }}
{% endif %} - {% if item.title %}

{{ item.title }}

{% endif %} - {% if item.content %}

{{ item.content }}

{% endif %} - {% if item.url %}{{ item.title }}{% endif %} -
- {% endfor %} -
- {% endif %} - {% endwith %} -
-
- -{% endif %} -{% empty %} -
-
-

- No content configured yet. Add sections in the admin panel. -

-
-
-{% endfor %} - +{% include "partials/_page_sections.html" with sections=about_sections %} {% endblock %} diff --git a/templates/pages/custom_page.html b/templates/pages/custom_page.html new file mode 100644 index 0000000..5495f18 --- /dev/null +++ b/templates/pages/custom_page.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block title %}{{ custom_page.title }}{% endblock %} +{% block meta_description %}{{ custom_page.meta_description|default:custom_page.title }}{% endblock %} + +{% block content %} +{% include "partials/_page_sections.html" with sections=page_sections %} +{% endblock %} diff --git a/templates/partials/_navbar.html b/templates/partials/_navbar.html index f889af2..afba6c0 100644 --- a/templates/partials/_navbar.html +++ b/templates/partials/_navbar.html @@ -71,6 +71,14 @@ + {% for page in nav_custom_pages %} + + {% endfor %} +