feat: new dynamic pages in admin panel

This commit is contained in:
mohamad
2026-05-25 17:17:27 +03:30
parent f08d0aacc0
commit cacf1ba77b
14 changed files with 818 additions and 149 deletions
+6
View File
@@ -1,6 +1,7 @@
from django.db.models import Prefetch from django.db.models import Prefetch
from apps.core.models import SiteBranding, SiteContact from apps.core.models import SiteBranding, SiteContact
from apps.pages.models import CustomPage
from apps.products.models import MainProduct, SubProduct from apps.products.models import MainProduct, SubProduct
@@ -28,8 +29,13 @@ def navigation(request):
all_footer_products = list(main_products[:5]) all_footer_products = list(main_products[:5])
footer_main_products = all_footer_products[:4] footer_main_products = all_footer_products[:4]
footer_main_products_has_more = len(all_footer_products) == 5 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 { return {
"nav_main_products": main_products, "nav_main_products": main_products,
"nav_custom_pages": nav_custom_pages,
"footer_main_products": footer_main_products, "footer_main_products": footer_main_products,
"footer_main_products_has_more": footer_main_products_has_more, "footer_main_products_has_more": footer_main_products_has_more,
} }
+70
View File
@@ -6,6 +6,9 @@ from .models import (
AboutSection, AboutSection,
AboutSectionItem, AboutSectionItem,
ContactSubmission, ContactSubmission,
CustomPage,
CustomPageSection,
CustomPageSectionItem,
DownloadItem, DownloadItem,
FAQEntry, FAQEntry,
HeroSection, 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) @admin.register(DownloadItem)
class DownloadItemAdmin(admin.ModelAdmin): class DownloadItemAdmin(admin.ModelAdmin):
list_display = ("name", "platform", "version", "is_active", "order") list_display = ("name", "platform", "version", "is_active", "order")
@@ -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'],
},
),
]
+134
View File
@@ -1,7 +1,20 @@
from django.core.exceptions import ValidationError
from django.db import models 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 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): class HeroSection(models.Model):
badge = models.CharField(max_length=200, blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').") 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): def __str__(self):
return f"{self.name} ({self.get_platform_display()})" 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)'}"
+74
View File
@@ -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"}))
+15
View File
@@ -80,3 +80,18 @@ class NavigationContextTest(TestCase):
response.context, response.context,
f"Missing nav_main_products at {url}", 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}",
)
+1
View File
@@ -9,4 +9,5 @@ urlpatterns = [
path("about/", views.AboutView.as_view(), name="about"), path("about/", views.AboutView.as_view(), name="about"),
path("faq/", views.FAQView.as_view(), name="faq"), path("faq/", views.FAQView.as_view(), name="faq"),
path("contact/", views.ContactView.as_view(), name="contact"), path("contact/", views.ContactView.as_view(), name="contact"),
path("<slug>/", views.CustomPageView.as_view(), name="custom_page"),
] ]
+33 -2
View File
@@ -3,12 +3,19 @@ import random
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.views import View 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 apps.products.models import SubProduct
from .forms import ContactForm from .forms import ContactForm
from .models import AboutSection, ContactSubmission, FAQEntry, HeroSection, HomepageSection from .models import (
AboutSection,
ContactSubmission,
CustomPage,
FAQEntry,
HeroSection,
HomepageSection,
)
class HomeView(TemplateView): class HomeView(TemplateView):
@@ -90,3 +97,27 @@ class ContactView(View):
{"success": False, "errors": errors, "captcha_question": captcha_question}, {"success": False, "errors": errors, "captcha_question": captcha_question},
status=400, 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
+20 -6
View File
@@ -45,6 +45,9 @@
--spacing-2xl: 7rem; --spacing-2xl: 7rem;
--navbar-height: 68px; --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-max: 1200px;
--container-padding: 1.5rem; --container-padding: 1.5rem;
@@ -378,7 +381,8 @@ h1, h2, h3, h4, h5, h6 {
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; 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; white-space: nowrap;
} }
@@ -389,7 +393,7 @@ h1, h2, h3, h4, h5, h6 {
} }
.nav-arrow { .nav-arrow {
transition: transform 0.4s ease; transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease);
} }
.nav-item.has-megamenu:hover .nav-arrow, .nav-item.has-megamenu:hover .nav-arrow,
@@ -427,7 +431,9 @@ h1, h2, h3, h4, h5, h6 {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(-10px); 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; pointer-events: none;
z-index: 999; z-index: 999;
} }
@@ -440,6 +446,10 @@ h1, h2, h3, h4, h5, h6 {
pointer-events: auto; pointer-events: auto;
} }
.megamenu.is-closing {
pointer-events: auto;
}
.megamenu-inner { .megamenu-inner {
max-width: var(--container-max); max-width: var(--container-max);
margin: 0 auto; margin: 0 auto;
@@ -522,7 +532,8 @@ h1, h2, h3, h4, h5, h6 {
.megamenu-sub-chevron { .megamenu-sub-chevron {
flex-shrink: 0; flex-shrink: 0;
color: var(--text-muted); 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, .megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron,
@@ -549,7 +560,9 @@ h1, h2, h3, h4, h5, h6 {
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
opacity: 0; 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, .megamenu-product-item.has-subproducts:hover .megamenu-sublist,
@@ -2853,7 +2866,8 @@ a.citation-count-badge:hover {
padding: 1rem var(--container-padding) 1.5rem; padding: 1rem var(--container-padding) 1.5rem;
max-height: 0; max-height: 0;
overflow: hidden; 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 { .navbar-menu.open {
+37 -6
View File
@@ -65,21 +65,46 @@ function initMegaMenu() {
const megamenu = productsItem.querySelector('.megamenu'); const megamenu = productsItem.querySelector('.megamenu');
if (!trigger || !megamenu) return; if (!trigger || !megamenu) return;
const CLOSE_DELAY_MS = 150;
let hideTimer = null; 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); clearTimeout(hideTimer);
hideTimer = null;
megamenu.classList.remove('is-closing');
}
function openMenu() {
cancelClose();
productsItem.classList.add('open'); productsItem.classList.add('open');
trigger.setAttribute('aria-expanded', 'true'); trigger.setAttribute('aria-expanded', 'true');
} }
function scheduleClose() { function beginClose() {
hideTimer = setTimeout(() => {
productsItem.classList.remove('open'); productsItem.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-expanded', 'false');
}, 420); 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('mouseenter', openMenu);
productsItem.addEventListener('mouseleave', scheduleClose); productsItem.addEventListener('mouseleave', scheduleClose);
@@ -90,11 +115,17 @@ function initMegaMenu() {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
const isOpen = productsItem.classList.toggle('open'); const isOpen = productsItem.classList.toggle('open');
if (isOpen) {
openMenu();
} else {
cancelClose();
beginClose();
}
trigger.setAttribute('aria-expanded', String(isOpen)); trigger.setAttribute('aria-expanded', String(isOpen));
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
productsItem.classList.remove('open'); cancelClose();
trigger.setAttribute('aria-expanded', 'false'); beginClose();
} }
}); });
} }
+1 -133
View File
@@ -1,140 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %}About{% endblock %} {% 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 meta_description %}Learn about Radiuma — Visualized & Standardized Environment for Radiomics Analysis, developed at UBC and BC Cancer Research Institute.{% endblock %}
{% block content %} {% block content %}
{% include "partials/_page_sections.html" with sections=about_sections %}
{% for section in about_sections %}
{% if section.section_type == "hero" %}
<section class="page-hero" aria-labelledby="page-hero-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
<img src="{% static 'images/blob-blue-2.svg' %}" class="blob-img blob-img--page-hero" alt="" />
<img src="{% static 'images/abstract-shapes-2.svg' %}" class="blob-img blob-img--page-corner" alt="" />
</div>
<div class="container">
<div class="page-hero-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h1 class="page-hero-title" id="page-hero-heading">{{ section.title }}</h1>{% endif %}
{% if section.subtitle %}<p class="page-hero-subtitle">{{ section.subtitle }}</p>{% endif %}
{% if section.content %}<div class="rich-content page-hero-body" data-readmore="160">{{ section.rendered_content }}</div>{% endif %}
</div>
</div>
</section>
{% elif section.section_type == "intro" %}
<section class="section" aria-labelledby="about-intro-{{ section.pk }}-heading">
<div class="container">
<div class="about-intro glass-card fade-in">
<div class="about-intro-text">
{% if section.title %}<h2 id="about-intro-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
{% if section.content %}
<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
{% endif %}
</div>
</div>
</div>
</section>
{% elif section.section_type == "grid" %}
<section class="section" aria-labelledby="grid-{{ section.pk }}-heading">
<div class="container">
{% if section.badge or section.title %}
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="grid-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% if section.content %}<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>{% endif %}
{% with items=section.items.all %}
{% if items %}
<div class="standards-grid">
{% for item in items %}
<div class="standard-card glass-card fade-in">
{% if item.badge %}<div class="standard-badge">{{ item.badge }}</div>{% endif %}
{% if item.title %}<h3 class="standard-title">{{ item.title }}</h3>{% endif %}
{% if item.content %}<p class="standard-desc" data-readmore="88">{{ item.content }}</p>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "history" %}
<section class="section" aria-labelledby="history-{{ section.pk }}-heading">
<div class="container">
<div class="history-block glass-card fade-in">
<div class="history-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 id="history-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
{% if section.content %}<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>{% endif %}
{% with links=section.items.all %}
{% if links %}
<div class="history-links">
{% for link in links %}
{% if link.url %}
<a href="{{ link.url }}" class="btn-ghost" target="_blank" rel="noopener noreferrer">{{ link.title }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
<div class="history-visual" aria-hidden="true">
<div class="history-blob history-blob--1"></div>
<div class="history-blob history-blob--2"></div>
{% if section.subtitle %}<div class="history-year">{{ section.subtitle }}</div>{% endif %}
</div>
</div>
</div>
</section>
{% elif section.section_type == "custom" %}
<section class="section" aria-labelledby="custom-{{ section.pk }}-heading">
<div class="container">
{% if section.badge or section.title %}
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="custom-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% if section.content %}
<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 %}
{% if items %}
<div class="standards-grid" style="margin-top: 1.5rem;">
{% for item in items %}
<div class="standard-card glass-card fade-in">
{% if item.badge %}<div class="standard-badge">{{ item.badge }}</div>{% endif %}
{% if item.title %}<h3 class="standard-title">{{ item.title }}</h3>{% endif %}
{% if item.content %}<p class="standard-desc" data-readmore="88">{{ item.content }}</p>{% endif %}
{% if item.url %}<a href="{{ item.url }}" class="btn-ghost btn--sm" style="margin-top: 0.75rem;" target="_blank" rel="noopener noreferrer">{{ item.title }}</a>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% endif %}
{% empty %}
<section class="section">
<div class="container">
<p style="color: var(--text-secondary); text-align: center; padding: 4rem 0;">
No content configured yet. Add sections in the admin panel.
</p>
</div>
</section>
{% endfor %}
{% endblock %} {% endblock %}
+8
View File
@@ -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 %}
+8
View File
@@ -71,6 +71,14 @@
</a> </a>
</li> </li>
{% for page in nav_custom_pages %}
<li class="nav-item">
<a href="{% url 'pages:custom_page' slug=page.slug %}" class="nav-link {% if request.resolver_match.url_name == 'custom_page' and request.resolver_match.kwargs.slug == page.slug %}active{% endif %}">
{{ page.nav_label }}
</a>
</li>
{% endfor %}
<li class="nav-item"> <li class="nav-item">
<a href="{% url 'pages:faq' %}" class="nav-link {% if request.resolver_match.url_name == 'faq' %}active{% endif %}"> <a href="{% url 'pages:faq' %}" class="nav-link {% if request.resolver_match.url_name == 'faq' %}active{% endif %}">
FAQ FAQ
+330
View File
@@ -0,0 +1,330 @@
{% load static %}
{% for section in sections %}
{% if section.section_type == "hero" %}
<section class="page-hero" aria-labelledby="page-hero-heading-{{ section.pk }}">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
<img src="{% static 'images/blob-blue-2.svg' %}" class="blob-img blob-img--page-hero" alt="" />
<img src="{% static 'images/abstract-shapes-2.svg' %}" class="blob-img blob-img--page-corner" alt="" />
</div>
<div class="container">
<div class="page-hero-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h1 class="page-hero-title" id="page-hero-heading-{{ section.pk }}">{{ section.title }}</h1>{% endif %}
{% if section.subtitle %}<p class="page-hero-subtitle">{{ section.subtitle }}</p>{% endif %}
{% if section.content %}<div class="rich-content page-hero-body" data-readmore="160">{{ section.rendered_content }}</div>{% endif %}
</div>
</div>
</section>
{% elif section.section_type == "intro" %}
<section class="section" aria-labelledby="intro-{{ section.pk }}-heading">
<div class="container">
<div class="about-intro glass-card fade-in">
<div class="about-intro-text">
{% if section.title %}<h2 id="intro-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
{% if section.content %}
<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
{% endif %}
</div>
</div>
</div>
</section>
{% elif section.section_type == "grid" %}
<section class="section" aria-labelledby="grid-{{ section.pk }}-heading">
<div class="container">
{% if section.badge or section.title %}
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="grid-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% if section.content %}<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>{% endif %}
{% with items=section.items.all %}
{% if items %}
<div class="standards-grid">
{% for item in items %}
<div class="standard-card glass-card fade-in">
{% if item.badge %}<div class="standard-badge">{{ item.badge }}</div>{% endif %}
{% if item.title %}<h3 class="standard-title">{{ item.title }}</h3>{% endif %}
{% if item.content %}<p class="standard-desc" data-readmore="88">{{ item.content }}</p>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "history" %}
<section class="section" aria-labelledby="history-{{ section.pk }}-heading">
<div class="container">
<div class="history-block glass-card fade-in">
<div class="history-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 id="history-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
{% if section.content %}<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>{% endif %}
{% with links=section.items.all %}
{% if links %}
<div class="history-links">
{% for link in links %}
{% if link.url %}
<a href="{{ link.url }}" class="btn-ghost" target="_blank" rel="noopener noreferrer">{{ link.title }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
<div class="history-visual" aria-hidden="true">
<div class="history-blob history-blob--1"></div>
<div class="history-blob history-blob--2"></div>
{% if section.subtitle %}<div class="history-year">{{ section.subtitle }}</div>{% endif %}
</div>
</div>
</div>
</section>
{% elif section.section_type == "custom" %}
<section class="section" aria-labelledby="custom-{{ section.pk }}-heading">
<div class="container">
{% if section.badge or section.title %}
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="custom-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% if section.content %}
<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 %}
{% if items %}
<div class="standards-grid" style="margin-top: 1.5rem;">
{% for item in items %}
<div class="standard-card glass-card fade-in">
{% if item.badge %}<div class="standard-badge">{{ item.badge }}</div>{% endif %}
{% if item.title %}<h3 class="standard-title">{{ item.title }}</h3>{% endif %}
{% if item.content %}<p class="standard-desc" data-readmore="88">{{ item.content }}</p>{% endif %}
{% if item.url %}<a href="{{ item.url }}" class="btn-ghost btn--sm" style="margin-top: 0.75rem;" target="_blank" rel="noopener noreferrer">{{ item.title }}</a>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "features" %}
<section class="section features-section" aria-labelledby="features-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="features-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="features-grid">
{% for item in section.items.all %}
<div class="feature-card glass-card fade-in">
{% if item.icon %}<div class="feature-icon" aria-hidden="true">{{ item.icon }}</div>{% endif %}
<h3 class="feature-title">{{ item.title }}</h3>
<p class="feature-desc" data-readmore="88">{{ item.content }}</p>
</div>
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "screenshots" %}
<section class="section screenshots-section" aria-labelledby="screenshots-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="screenshots-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="screenshots-grid">
{% for item in section.items.all %}
{% if item.image %}
<div class="screenshot-item fade-in">
<img src="{{ item.image.url }}" alt="{{ item.image_alt|default:item.title }}" loading="lazy" />
</div>
{% endif %}
{% empty %}
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-2.jpg' %}" alt="Application screenshot" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-3.png' %}" alt="Application screenshot" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-5.png' %}" alt="Application screenshot" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-8.jpg' %}" alt="Application screenshot" loading="lazy" />
</div>
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "products" %}
{% if sub_products %}
<section class="section products-section" aria-labelledby="products-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="products-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="products-grid">
{% for product in sub_products %}
<a href="{{ product.get_absolute_url }}" class="product-card glass-card fade-in">
{% if product.image %}
<div class="product-card-image">
<img src="{{ product.image.url }}" alt="{{ product.name }}" loading="lazy" />
</div>
{% endif %}
<div class="product-card-body">
{% if product.logo %}
<img src="{{ product.logo.url }}" alt="" class="product-card-logo" aria-hidden="true" loading="lazy" />
{% endif %}
<h3 class="product-card-title">{{ product.name }}</h3>
<p class="product-card-desc" data-readmore="88">{{ product.short_description }}</p>
<span class="product-card-link">
Explore
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
</a>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% elif section.section_type == "problems" %}
<section class="section problems-section" aria-labelledby="problems-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="problems-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="problems-grid">
{% for item in section.items.all %}
<div class="problem-item glass-card fade-in">
{% if item.icon %}<div class="problem-number" aria-hidden="true">{{ item.icon }}</div>{% endif %}
<h3 class="problem-title">{{ item.title }}</h3>
<p class="problem-desc" data-readmore="88">{{ item.content }}</p>
</div>
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "supporters" %}
<section class="section supporters-section" aria-labelledby="supporters-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="supporters-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="supporters-grid">
{% for item in section.items.all %}
<div class="supporter-card glass-card fade-in">
{% if item.image %}
<div class="supporter-logo">
<img src="{{ item.image.url }}" alt="{{ item.image_alt|default:item.title }} logo" loading="lazy" />
</div>
{% endif %}
<div class="supporter-body">
<h3 class="supporter-name">{{ item.title }}</h3>
{% if item.content %}<p class="supporter-desc">{{ item.content }}</p>{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "about_strip" %}
<section class="section about-strip" aria-labelledby="about-strip-heading-{{ section.pk }}">
<div class="container">
<div class="about-strip-inner glass-card">
<div class="about-strip-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="about-strip-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="about-strip-text">{{ section.description }}</p>{% elif section.content %}<p class="about-strip-text">{{ section.rendered_content }}</p>{% endif %}
{% if section.link_text and section.link_url %}
<a href="{{ section.link_url }}" class="btn-primary">{{ section.link_text }}</a>
{% endif %}
</div>
<div class="about-strip-blobs" aria-hidden="true">
<div class="strip-blob strip-blob--1"></div>
<div class="strip-blob strip-blob--2"></div>
</div>
</div>
</div>
</section>
{% elif section.section_type == "faq" %}
<section class="section" aria-labelledby="faq-section-{{ section.pk }}-heading">
<div class="container">
{% if section.badge or section.title %}
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="faq-section-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% with items=section.items.all %}
{% if items %}
<div class="faq-list">
{% for item in items %}
<div class="faq-item glass-card fade-in">
<button
class="faq-question"
aria-expanded="false"
aria-controls="faq-answer-{{ section.pk }}-{{ item.pk }}"
id="faq-question-{{ section.pk }}-{{ item.pk }}"
>
{{ item.title }}
<svg class="faq-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 8L10 13L15 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div
class="faq-answer"
id="faq-answer-{{ section.pk }}-{{ item.pk }}"
role="region"
aria-labelledby="faq-question-{{ section.pk }}-{{ item.pk }}"
hidden
>
<div class="rich-content">{{ item.rendered_content }}</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% endif %}
{% empty %}
<section class="section">
<div class="container">
<p style="color: var(--text-secondary); text-align: center; padding: 4rem 0;">
No content configured yet. Add sections in the admin panel.
</p>
</div>
</section>
{% endfor %}