feat: pagination for videos and icon separating

This commit is contained in:
mohamad
2026-08-01 17:45:55 +03:30
parent 7cddc03a57
commit 04b6e9049e
15 changed files with 508 additions and 25 deletions
+20 -3
View File
@@ -8,19 +8,36 @@ from .models import SiteBranding, SiteContact
@admin.register(SiteBranding) @admin.register(SiteBranding)
class SiteBrandingAdmin(admin.ModelAdmin): class SiteBrandingAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
("Icon", {"fields": ("icon", "icon_alt")}), (
"Brand assets",
{
"fields": (
"icon",
"icon_alt",
"website_icon",
"hero_logo",
"hero_logo_alt",
),
"description": (
"Manage each placement independently. Removing an upload restores "
"the existing Radiuma asset for that placement."
),
},
),
( (
"Sizes", "Sizes",
{ {
"fields": ("navbar_icon_size", "footer_icon_size"), "fields": ("navbar_icon_size", "footer_icon_size"),
"description": "Square dimensions in pixels for each placement.", "description": (
"Set the logo height for each placement. Its width is calculated "
"automatically so the uploaded image is never cropped or stretched."
),
}, },
), ),
( (
"Appearance", "Appearance",
{ {
"fields": ( "fields": (
"object_fit",
"show_border", "show_border",
"border_width", "border_width",
"border_color", "border_color",
@@ -0,0 +1,44 @@
# Generated by Django 5.2.13 on 2026-08-01 14:10
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_site_contact'),
]
operations = [
migrations.AddField(
model_name='sitebranding',
name='hero_logo',
field=models.ImageField(blank=True, help_text='Large brand artwork shown in the homepage hero. Leave empty to use the existing hero image.', null=True, upload_to='branding/hero/'),
),
migrations.AddField(
model_name='sitebranding',
name='hero_logo_alt',
field=models.CharField(blank=True, default='Radiuma', help_text='Accessible description for the homepage hero logo.', max_length=200),
),
migrations.AddField(
model_name='sitebranding',
name='website_icon',
field=models.FileField(blank=True, help_text='Icon shown in browser tabs and bookmarks. Use a square ICO, PNG, SVG, JPG, or WebP file. Leave empty to use the default Radiuma icons.', null=True, upload_to='branding/icons/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('ico', 'png', 'svg', 'jpg', 'jpeg', 'webp'))]),
),
migrations.AlterField(
model_name='sitebranding',
name='footer_icon_size',
field=models.PositiveSmallIntegerField(default=34, help_text='Maximum height in pixels for the footer logo. Width scales automatically.'),
),
migrations.AlterField(
model_name='sitebranding',
name='icon',
field=models.ImageField(blank=True, help_text='Brand logo shown in the site navigation and footer. Leave empty to use the default logo.', null=True, upload_to='branding/', verbose_name='Header and footer logo'),
),
migrations.AlterField(
model_name='sitebranding',
name='navbar_icon_size',
field=models.PositiveSmallIntegerField(default=26, help_text='Maximum height in pixels for the header logo. Width scales automatically.'),
),
]
+36 -5
View File
@@ -1,3 +1,4 @@
from django.core.validators import FileExtensionValidator
from django.db import models from django.db import models
@@ -13,20 +14,50 @@ class SiteBranding(models.Model):
upload_to="branding/", upload_to="branding/",
blank=True, blank=True,
null=True, null=True,
help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.", verbose_name="Header and footer logo",
help_text="Brand logo shown in the site navigation and footer. Leave empty to use the default logo.",
) )
icon_alt = models.CharField( icon_alt = models.CharField(
max_length=200, max_length=200,
blank=True, blank=True,
help_text="Alt text for the brand icon (decorative icons can stay empty).", help_text="Alt text for the brand icon (decorative icons can stay empty).",
) )
website_icon = models.FileField(
upload_to="branding/icons/",
blank=True,
null=True,
validators=[
FileExtensionValidator(
allowed_extensions=("ico", "png", "svg", "jpg", "jpeg", "webp")
)
],
help_text=(
"Icon shown in browser tabs and bookmarks. Use a square ICO, PNG, SVG, "
"JPG, or WebP file. Leave empty to use the default Radiuma icons."
),
)
hero_logo = models.ImageField(
upload_to="branding/hero/",
blank=True,
null=True,
help_text=(
"Large brand artwork shown in the homepage hero. Leave empty to use "
"the existing hero image."
),
)
hero_logo_alt = models.CharField(
max_length=200,
blank=True,
default="Radiuma",
help_text="Accessible description for the homepage hero logo.",
)
navbar_icon_size = models.PositiveSmallIntegerField( navbar_icon_size = models.PositiveSmallIntegerField(
default=26, default=26,
help_text="Width and height in pixels for the header icon.", help_text="Maximum height in pixels for the header logo. Width scales automatically.",
) )
footer_icon_size = models.PositiveSmallIntegerField( footer_icon_size = models.PositiveSmallIntegerField(
default=34, default=34,
help_text="Width and height in pixels for the footer icon.", help_text="Maximum height in pixels for the footer logo. Width scales automatically.",
) )
show_border = models.BooleanField( show_border = models.BooleanField(
default=False, default=False,
@@ -61,9 +92,9 @@ class SiteBranding(models.Model):
def icon_style(self, size_px): def icon_style(self, size_px):
parts = [ parts = [
f"width:{size_px}px", "width:auto",
f"height:{size_px}px", f"height:{size_px}px",
f"object-fit:{self.object_fit}", "object-fit:contain",
] ]
if self.show_border: if self.show_border:
parts.append(f"border:{self.border_width}px solid {self.border_color}") parts.append(f"border:{self.border_width}px solid {self.border_color}")
+27 -1
View File
@@ -1,4 +1,5 @@
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.urls import reverse
from apps.core.context_processors import site_branding from apps.core.context_processors import site_branding
from apps.core.models import SiteBranding from apps.core.models import SiteBranding
@@ -17,7 +18,9 @@ class SiteBrandingModelTests(TestCase):
branding.border_width = 2 branding.border_width = 2
branding.navbar_icon_size = 30 branding.navbar_icon_size = 30
style = branding.navbar_icon_style style = branding.navbar_icon_style
self.assertIn("width:30px", style) self.assertIn("width:auto", style)
self.assertIn("height:30px", style)
self.assertIn("object-fit:contain", style)
self.assertIn("border:2px solid #ffffff", style) self.assertIn("border:2px solid #ffffff", style)
def test_icon_style_omits_border_when_disabled(self): def test_icon_style_omits_border_when_disabled(self):
@@ -33,3 +36,26 @@ class SiteBrandingContextProcessorTests(TestCase):
ctx = site_branding(request) ctx = site_branding(request)
self.assertIn("site_branding", ctx) self.assertIn("site_branding", ctx)
self.assertIsInstance(ctx["site_branding"], SiteBranding) self.assertIsInstance(ctx["site_branding"], SiteBranding)
class SiteBrandingTemplateTests(TestCase):
def test_custom_brand_assets_are_rendered_independently(self):
branding = SiteBranding.load()
branding.icon = "branding/navigation-logo.png"
branding.website_icon = "branding/icons/site-icon.png"
branding.hero_logo = "branding/hero/hero-logo.png"
branding.hero_logo_alt = "Radiuma research platform"
branding.save()
response = self.client.get(reverse("pages:home"))
self.assertContains(response, 'src="/media/branding/navigation-logo.png"')
self.assertContains(response, 'href="/media/branding/icons/site-icon.png"')
self.assertContains(response, 'src="/media/branding/hero/hero-logo.png"')
self.assertContains(response, 'alt="Radiuma research platform"')
def test_default_assets_remain_when_custom_assets_are_empty(self):
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "images/favicon-16.png")
self.assertContains(response, "images/screenshot-1.jpg")
+73
View File
@@ -80,6 +80,17 @@ class CustomPageVideoSectionTest(TestCase):
class PageVideoTest(TestCase): class PageVideoTest(TestCase):
def _create_page_videos(self, page, count):
for index in range(count):
PageVideo.objects.create(
page=page,
title=f"Video {index + 1}",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
order=index,
is_active=True,
)
def test_contact_page_video(self): def test_contact_page_video(self):
PageVideo.objects.create( PageVideo.objects.create(
page=PageVideo.PAGE_CONTACT, page=PageVideo.PAGE_CONTACT,
@@ -103,6 +114,44 @@ class PageVideoTest(TestCase):
response = self.client.get(reverse("pages:faq")) response = self.client.get(reverse("pages:faq"))
self.assertContains(response, "Tutorial") self.assertContains(response, "Tutorial")
def test_page_shows_three_video_preview_and_archive_link(self):
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
response = self.client.get(reverse("pages:faq"))
self.assertEqual(response.content.count(b"youtube-nocookie.com/embed"), 3)
self.assertContains(
response,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
),
)
self.assertContains(response, "More videos")
def test_page_video_archive_is_paginated(self):
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
archive_url = reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
)
first_page = self.client.get(archive_url)
second_page = self.client.get(archive_url, {"page": 2})
self.assertEqual(first_page.status_code, 200)
self.assertEqual(first_page.content.count(b"youtube-nocookie.com/embed"), 6)
self.assertContains(first_page, "Page 1 of 2")
self.assertContains(first_page, "?page=2")
self.assertEqual(second_page.content.count(b"youtube-nocookie.com/embed"), 2)
self.assertContains(second_page, "Page 2 of 2")
def test_unknown_page_video_archive_returns_404(self):
response = self.client.get(
reverse("pages:video_archive", kwargs={"library": "unknown"})
)
self.assertEqual(response.status_code, 404)
class ProductVideoTest(TestCase): class ProductVideoTest(TestCase):
def setUp(self): def setUp(self):
@@ -129,6 +178,30 @@ class ProductVideoTest(TestCase):
self.assertContains(response, "Product Demo") self.assertContains(response, "Product Demo")
self.assertContains(response, "video-block--full") self.assertContains(response, "video-block--full")
def test_product_video_preview_links_to_paginated_archive(self):
for index in range(7):
ProductVideo.objects.create(
main_product=self.main_product,
title=f"Product video {index + 1}",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
order=index,
is_active=True,
)
archive_url = reverse(
"products:main_product_videos",
kwargs={"main_slug": self.main_product.slug},
)
detail_response = self.client.get(self.main_product.get_absolute_url())
archive_response = self.client.get(archive_url)
second_page = self.client.get(archive_url, {"page": 2})
self.assertEqual(detail_response.content.count(b"youtube-nocookie.com/embed"), 3)
self.assertContains(detail_response, archive_url)
self.assertEqual(archive_response.content.count(b"youtube-nocookie.com/embed"), 6)
self.assertEqual(second_page.content.count(b"youtube-nocookie.com/embed"), 1)
class VideoStyledBackgroundTest(TestCase): class VideoStyledBackgroundTest(TestCase):
def test_styled_background_renders_panel(self): def test_styled_background_renders_panel(self):
+5
View File
@@ -9,5 +9,10 @@ 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(
"videos/<slug:library>/",
views.PageVideoArchiveView.as_view(),
name="video_archive",
),
path("<slug>/", views.CustomPageView.as_view(), name="custom_page"), path("<slug>/", views.CustomPageView.as_view(), name="custom_page"),
] ]
+89 -7
View File
@@ -1,8 +1,9 @@
import random import random
from django.db.models import Prefetch from django.db.models import Prefetch
from django.http import JsonResponse from django.http import Http404, JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse
from django.views import View from django.views import View
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
@@ -20,6 +21,18 @@ from .models import (
PageVideo, PageVideo,
) )
VIDEO_PREVIEW_LIMIT = 3
VIDEO_ARCHIVE_PAGE_SIZE = 6
def _video_preview_context(queryset, archive_url):
total = queryset.count()
return {
"videos": queryset[:VIDEO_PREVIEW_LIMIT],
"videos_total_count": total,
"videos_archive_url": archive_url if total > VIDEO_PREVIEW_LIMIT else "",
}
def _homepage_products_catalog_queryset(): def _homepage_products_catalog_queryset():
return ( return (
@@ -74,10 +87,20 @@ class FAQView(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx["faq_videos"] = PageVideo.objects.filter( videos = PageVideo.objects.filter(
page=PageVideo.PAGE_FAQ, page=PageVideo.PAGE_FAQ,
is_active=True, is_active=True,
).order_by("order") ).order_by("order", "pk")
preview = _video_preview_context(
videos,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
),
)
ctx["faq_videos"] = preview["videos"]
ctx["videos_total_count"] = preview["videos_total_count"]
ctx["videos_archive_url"] = preview["videos_archive_url"]
return ctx return ctx
@@ -90,16 +113,26 @@ class ContactView(View):
return f"{a} + {b}" return f"{a} + {b}"
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
videos = PageVideo.objects.filter(
page=PageVideo.PAGE_CONTACT,
is_active=True,
).order_by("order", "pk")
preview = _video_preview_context(
videos,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_CONTACT},
),
)
return render( return render(
request, request,
self.template_name, self.template_name,
{ {
"form": ContactForm(), "form": ContactForm(),
"captcha_question": self._new_captcha(request), "captcha_question": self._new_captcha(request),
"contact_videos": PageVideo.objects.filter( "contact_videos": preview["videos"],
page=PageVideo.PAGE_CONTACT, "videos_total_count": preview["videos_total_count"],
is_active=True, "videos_archive_url": preview["videos_archive_url"],
).order_by("order"),
}, },
) )
@@ -163,3 +196,52 @@ class CustomPageView(DetailView):
) )
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset() ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
return ctx return ctx
class PageVideoArchiveView(ListView):
model = PageVideo
template_name = "videos/archive.html"
context_object_name = "videos"
paginate_by = VIDEO_ARCHIVE_PAGE_SIZE
PAGE_CONFIG = {
PageVideo.PAGE_CONTACT: (
"Contact videos",
"Guides and updates from the Radiuma team.",
"pages:contact",
),
PageVideo.PAGE_FAQ: (
"FAQ videos",
"Video answers to common questions.",
"pages:faq",
),
}
def get_page_config(self):
try:
return self.PAGE_CONFIG[self.kwargs["library"]]
except KeyError as exc:
raise Http404("Video library not found.") from exc
def get_queryset(self):
self.get_page_config()
return PageVideo.objects.filter(
page=self.kwargs["library"],
is_active=True,
).order_by("order", "pk")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
title, description, source_url_name = self.get_page_config()
context.update(
{
"library_title": title,
"library_description": description,
"library_back_url": reverse(source_url_name),
"library_back_label": "Back to page",
"pagination_range": context["paginator"].get_elided_page_range(
context["page_obj"].number
),
}
)
return context
+10
View File
@@ -11,6 +11,16 @@ urlpatterns = [
views.ReleaseAssetDownloadView.as_view(), views.ReleaseAssetDownloadView.as_view(),
name="release_asset_download", name="release_asset_download",
), ),
path(
"<slug:main_slug>/videos/",
views.ProductVideoArchiveView.as_view(),
name="main_product_videos",
),
path(
"<slug:main_slug>/<slug:sub_slug>/videos/",
views.ProductVideoArchiveView.as_view(),
name="sub_product_videos",
),
path( path(
"<slug:main_slug>/", "<slug:main_slug>/",
views.MainProductDetailView.as_view(), views.MainProductDetailView.as_view(),
+80 -2
View File
@@ -2,6 +2,7 @@ import mimetypes
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.views import View from django.views import View
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
@@ -9,6 +10,18 @@ from .models import Article, ArticleCitation, ArticleSection, MainProduct, Produ
from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY
from .release_context import build_archive_context, build_release_context from .release_context import build_archive_context, build_release_context
VIDEO_PREVIEW_LIMIT = 3
VIDEO_ARCHIVE_PAGE_SIZE = 6
def _product_video_preview(queryset, archive_url):
total = queryset.count()
return {
"product_videos": queryset[:VIDEO_PREVIEW_LIMIT],
"videos_total_count": total,
"videos_archive_url": archive_url if total > VIDEO_PREVIEW_LIMIT else "",
}
class ReleaseAssetDownloadView(View): class ReleaseAssetDownloadView(View):
def get(self, request, version_id, asset): def get(self, request, version_id, asset):
@@ -58,7 +71,16 @@ class MainProductDetailView(DetailView):
context["articles"] = self.object.articles.prefetch_related( context["articles"] = self.object.articles.prefetch_related(
"sections", "citations" "sections", "citations"
).all() ).all()
context["product_videos"] = self.object.videos.filter(is_active=True).order_by("order") videos = self.object.videos.filter(is_active=True).order_by("order", "pk")
context.update(
_product_video_preview(
videos,
reverse(
"products:main_product_videos",
kwargs={"main_slug": self.object.slug},
),
)
)
release_context = build_release_context( release_context = build_release_context(
self.object.distribution, self.object.distribution,
self.object.versions.filter(is_active=True), self.object.versions.filter(is_active=True),
@@ -112,7 +134,19 @@ class SubProductDetailView(TemplateView):
context["articles"] = sub_product.articles.prefetch_related( context["articles"] = sub_product.articles.prefetch_related(
"sections", "citations" "sections", "citations"
).all() ).all()
context["product_videos"] = sub_product.videos.filter(is_active=True).order_by("order") videos = sub_product.videos.filter(is_active=True).order_by("order", "pk")
context.update(
_product_video_preview(
videos,
reverse(
"products:sub_product_videos",
kwargs={
"main_slug": main_product.slug,
"sub_slug": sub_product.slug,
},
),
)
)
context["siblings"] = ( context["siblings"] = (
SubProduct.objects.filter(main_product=main_product, is_active=True) SubProduct.objects.filter(main_product=main_product, is_active=True)
.exclude(pk=sub_product.pk) .exclude(pk=sub_product.pk)
@@ -127,6 +161,50 @@ class SubProductDetailView(TemplateView):
return context return context
class ProductVideoArchiveView(ListView):
model = ProductVideo
template_name = "videos/archive.html"
context_object_name = "videos"
paginate_by = VIDEO_ARCHIVE_PAGE_SIZE
def get_parent(self):
main_product = get_object_or_404(
MainProduct,
slug=self.kwargs["main_slug"],
is_active=True,
)
sub_slug = self.kwargs.get("sub_slug")
if sub_slug:
return get_object_or_404(
SubProduct,
slug=sub_slug,
main_product=main_product,
is_active=True,
)
return main_product
def get_queryset(self):
self.parent_object = self.get_parent()
return self.parent_object.videos.filter(is_active=True).order_by("order", "pk")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
"library_title": f"{self.parent_object.name} videos",
"library_description": (
"Tutorials, demonstrations, and technical guides."
),
"library_back_url": self.parent_object.get_absolute_url(),
"library_back_label": f"Back to {self.parent_object.name}",
"pagination_range": context["paginator"].get_elided_page_range(
context["page_obj"].number
),
}
)
return context
class SubProductOlderVersionsView(TemplateView): class SubProductOlderVersionsView(TemplateView):
template_name = "products/versions_archive.html" template_name = "products/versions_archive.html"
+47
View File
@@ -3702,6 +3702,53 @@ a.citation-count-badge:hover {
margin-top: 0; margin-top: 0;
} }
.video-preview-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.video-preview-heading .section-badge { margin-bottom: 0.55rem; }
.video-preview-heading h2 { font-size: clamp(1.4rem, 3vw, 2rem); }
.video-preview-heading > span { color: var(--text-muted); font-size: 0.76rem; font-weight: 700; }
.video-preview-more { display: flex; justify-content: center; margin-top: 1.75rem; }
.video-library-hero .page-hero-content { max-width: none; }
.page-hero-content--row { display: flex; align-items: flex-end; justify-content: space-between; gap: 2rem; }
.video-library-section { background: var(--bg-secondary); }
.video-library-summary { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; color: var(--text-secondary); font-size: 0.8rem; }
.video-library-summary strong { color: var(--text-primary); font-size: 1rem; }
.video-library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; }
.video-library-card { min-width: 0; padding: 1.2rem; border: 1px solid var(--glass-border); border-radius: var(--radius-md); background: var(--glass-bg); box-shadow: var(--glass-shadow); }
.video-library-card .video-panel--styled { height: 100%; padding: 1.25rem; }
.video-library-card .video-panel-title,
.video-library-card .section-title { font-size: 1.05rem; }
.video-library-card .section-header { margin-bottom: 1rem; text-align: left; }
.video-library-card .section-desc { margin-bottom: 1rem; font-size: 0.78rem; }
.video-library-empty { grid-column: 1 / -1; }
.video-pagination { display: grid; grid-template-columns: minmax(110px, 1fr) auto minmax(110px, 1fr); align-items: center; gap: 1rem; margin-top: 2.25rem; padding-top: 1.5rem; border-top: 1px solid var(--glass-border); }
.video-pagination__pages { display: flex; align-items: center; gap: 0.35rem; }
.video-pagination__page,
.video-pagination__ellipsis { display: grid; place-items: center; min-width: 38px; height: 38px; padding: 0 0.4rem; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: var(--glass-bg); color: var(--text-secondary); font-size: 0.76rem; font-weight: 700; }
.video-pagination__page:hover { border-color: var(--accent-blue); color: var(--accent-blue-light); }
.video-pagination__page.is-current { border-color: var(--accent-blue); background: var(--accent-blue); color: var(--text-primary); }
.video-pagination__ellipsis { border-color: transparent; background: transparent; }
.video-pagination__direction { color: var(--accent-blue-light); font-size: 0.78rem; font-weight: 700; }
.video-pagination__direction:last-child { justify-self: end; }
.video-pagination__direction.is-disabled { color: var(--text-muted); pointer-events: none; }
@media (max-width: 768px) {
.video-library-grid { grid-template-columns: 1fr; }
.video-pagination { grid-template-columns: 1fr 1fr; }
.video-pagination__pages { grid-column: 1 / -1; grid-row: 1; justify-content: center; flex-wrap: wrap; }
.video-pagination__direction { grid-row: 2; }
.video-preview-heading,
.page-hero-content--row { align-items: flex-start; flex-direction: column; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.video-panel--styled { .video-panel--styled {
padding: 1.25rem 1rem 1rem; padding: 1.25rem 1rem 1rem;
+5
View File
@@ -6,9 +6,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{% block meta_description %}Radiuma — Advanced Medical Imaging & Radiomics Solutions{% endblock %}" /> <meta name="description" content="{% block meta_description %}Radiuma — Advanced Medical Imaging & Radiomics Solutions{% endblock %}" />
<title>{% block title %}Radiuma{% endblock %} | Radiuma</title> <title>{% block title %}Radiuma{% endblock %} | Radiuma</title>
{% if site_branding.website_icon %}
<link rel="icon" href="{{ site_branding.website_icon.url }}" />
<link rel="apple-touch-icon" href="{{ site_branding.website_icon.url }}" />
{% else %}
<link rel="icon" href="{% static 'images/favicon-16.png' %}" type="image/png" sizes="16x16" /> <link rel="icon" href="{% static 'images/favicon-16.png' %}" type="image/png" sizes="16x16" />
<link rel="icon" href="{% static 'images/favicon-32.png' %}" type="image/png" sizes="32x32" /> <link rel="icon" href="{% static 'images/favicon-32.png' %}" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="{% static 'images/favicon-180.png' %}" sizes="180x180" /> <link rel="apple-touch-icon" href="{% static 'images/favicon-180.png' %}" sizes="180x180" />
{% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
+4 -2
View File
@@ -35,10 +35,12 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% if hero and hero.image or not hero %} {% if site_branding.hero_logo or hero and hero.image or not hero %}
<div class="hero-app-preview fade-in"> <div class="hero-app-preview fade-in">
<div class="hero-app-preview-glow" aria-hidden="true"></div> <div class="hero-app-preview-glow" aria-hidden="true"></div>
{% if hero and hero.image %} {% if site_branding.hero_logo %}
<img src="{{ site_branding.hero_logo.url }}" alt="{{ site_branding.hero_logo_alt|default:'Radiuma' }}" loading="eager" />
{% elif hero and hero.image %}
<img src="{{ hero.image.url }}" alt="{{ hero.image_alt }}" loading="eager" /> <img src="{{ hero.image.url }}" alt="{{ hero.image_alt }}" loading="eager" />
{% elif not hero %} {% elif not hero %}
<img src="{% static 'images/screenshot-1.jpg' %}" alt="Radiuma application — main workflow view" loading="eager" /> <img src="{% static 'images/screenshot-1.jpg' %}" alt="Radiuma application — main workflow view" loading="eager" />
-4
View File
@@ -4,8 +4,6 @@
{% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %} {% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %}
{% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %} {% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
class="brand-icon" class="brand-icon"
width="{{ site_branding.footer_icon_size }}"
height="{{ site_branding.footer_icon_size }}"
style="{{ site_branding.footer_icon_style }}" style="{{ site_branding.footer_icon_style }}"
/> />
{% else %} {% else %}
@@ -13,8 +11,6 @@
{% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %} {% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %}
{% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %} {% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
class="brand-icon" class="brand-icon"
width="{{ site_branding.navbar_icon_size }}"
height="{{ site_branding.navbar_icon_size }}"
style="{{ site_branding.navbar_icon_style }}" style="{{ site_branding.navbar_icon_style }}"
/> />
{% endif %} {% endif %}
+12 -1
View File
@@ -1,6 +1,12 @@
{% if videos %} {% if videos %}
<section class="section product-videos-section" aria-label="Product videos"> <section class="section product-videos-section" aria-label="Featured videos">
<div class="container"> <div class="container">
{% if videos_archive_url %}
<div class="video-preview-heading">
<div><span class="section-badge">Video library</span><h2>Featured videos</h2></div>
<span>{{ videos_total_count }} videos</span>
</div>
{% endif %}
<div class="product-videos-list"> <div class="product-videos-list">
{% for video in videos %} {% for video in videos %}
{% if video.has_video %} {% if video.has_video %}
@@ -10,6 +16,11 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% if videos_archive_url %}
<div class="video-preview-more">
<a href="{{ videos_archive_url }}" class="btn-ghost">More videos <span aria-hidden="true"></span></a>
</div>
{% endif %}
</div> </div>
</section> </section>
{% endif %} {% endif %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}{{ library_title }}{% endblock %}
{% block meta_description %}Browse {{ library_title|lower }} from Radiuma.{% endblock %}
{% block content %}
<section class="page-hero video-library-hero" aria-labelledby="video-library-title">
<div class="container">
<div class="page-hero-content page-hero-content--row">
<div>
<div class="section-badge">Video library</div>
<h1 class="page-hero-title" id="video-library-title">{{ library_title }}</h1>
<p class="page-hero-subtitle">{{ library_description }}</p>
</div>
<a href="{{ library_back_url }}" class="btn-ghost">← {{ library_back_label }}</a>
</div>
</div>
</section>
<section class="section video-library-section" aria-label="All videos">
<div class="container">
<div class="video-library-summary">
<p><strong>{{ paginator.count }}</strong> video{{ paginator.count|pluralize }}</p>
{% if paginator.num_pages > 1 %}<span>Page {{ page_obj.number }} of {{ paginator.num_pages }}</span>{% endif %}
</div>
<div class="video-library-grid">
{% for video in videos %}
<article class="video-library-card">
{% include "partials/_video_block.html" with video=video compact_header=True %}
</article>
{% empty %}
<div class="glass-card video-library-empty"><h2>No videos available</h2><p>New videos will appear here when they are published.</p></div>
{% endfor %}
</div>
{% if page_obj.has_other_pages %}
<nav class="video-pagination" aria-label="Video library pages">
<a class="video-pagination__direction{% if not page_obj.has_previous %} is-disabled{% endif %}" {% if page_obj.has_previous %}href="?page={{ page_obj.previous_page_number }}"{% else %}aria-disabled="true" tabindex="-1"{% endif %}>← Previous</a>
<ol class="video-pagination__pages">
{% for page_number in pagination_range %}
{% if page_number == "…" %}
<li><span class="video-pagination__ellipsis"></span></li>
{% elif page_number == page_obj.number %}
<li><span class="video-pagination__page is-current" aria-current="page">{{ page_number }}</span></li>
{% else %}
<li><a class="video-pagination__page" href="?page={{ page_number }}" aria-label="Go to page {{ page_number }}">{{ page_number }}</a></li>
{% endif %}
{% endfor %}
</ol>
<a class="video-pagination__direction{% if not page_obj.has_next %} is-disabled{% endif %}" {% if page_obj.has_next %}href="?page={{ page_obj.next_page_number }}"{% else %}aria-disabled="true" tabindex="-1"{% endif %}>Next →</a>
</nav>
{% endif %}
</div>
</section>
{% endblock %}