feat: pagination for videos and icon separating
This commit is contained in:
+20
-3
@@ -8,19 +8,36 @@ from .models import SiteBranding, SiteContact
|
||||
@admin.register(SiteBranding)
|
||||
class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
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",
|
||||
{
|
||||
"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",
|
||||
{
|
||||
"fields": (
|
||||
"object_fit",
|
||||
"show_border",
|
||||
"border_width",
|
||||
"border_color",
|
||||
|
||||
+44
@@ -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
@@ -1,3 +1,4 @@
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
@@ -13,20 +14,50 @@ class SiteBranding(models.Model):
|
||||
upload_to="branding/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.",
|
||||
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(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
default=False,
|
||||
@@ -61,9 +92,9 @@ class SiteBranding(models.Model):
|
||||
|
||||
def icon_style(self, size_px):
|
||||
parts = [
|
||||
f"width:{size_px}px",
|
||||
"width:auto",
|
||||
f"height:{size_px}px",
|
||||
f"object-fit:{self.object_fit}",
|
||||
"object-fit:contain",
|
||||
]
|
||||
if self.show_border:
|
||||
parts.append(f"border:{self.border_width}px solid {self.border_color}")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.core.context_processors import site_branding
|
||||
from apps.core.models import SiteBranding
|
||||
@@ -17,7 +18,9 @@ class SiteBrandingModelTests(TestCase):
|
||||
branding.border_width = 2
|
||||
branding.navbar_icon_size = 30
|
||||
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)
|
||||
|
||||
def test_icon_style_omits_border_when_disabled(self):
|
||||
@@ -33,3 +36,26 @@ class SiteBrandingContextProcessorTests(TestCase):
|
||||
ctx = site_branding(request)
|
||||
self.assertIn("site_branding", ctx)
|
||||
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")
|
||||
|
||||
@@ -80,6 +80,17 @@ class CustomPageVideoSectionTest(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):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
@@ -103,6 +114,44 @@ class PageVideoTest(TestCase):
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
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):
|
||||
def setUp(self):
|
||||
@@ -129,6 +178,30 @@ class ProductVideoTest(TestCase):
|
||||
self.assertContains(response, "Product Demo")
|
||||
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):
|
||||
def test_styled_background_renders_panel(self):
|
||||
|
||||
@@ -9,5 +9,10 @@ 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(
|
||||
"videos/<slug:library>/",
|
||||
views.PageVideoArchiveView.as_view(),
|
||||
name="video_archive",
|
||||
),
|
||||
path("<slug>/", views.CustomPageView.as_view(), name="custom_page"),
|
||||
]
|
||||
|
||||
+89
-7
@@ -1,8 +1,9 @@
|
||||
import random
|
||||
|
||||
from django.db.models import Prefetch
|
||||
from django.http import JsonResponse
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
@@ -20,6 +21,18 @@ from .models import (
|
||||
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():
|
||||
return (
|
||||
@@ -74,10 +87,20 @@ class FAQView(ListView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["faq_videos"] = PageVideo.objects.filter(
|
||||
videos = PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
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
|
||||
|
||||
|
||||
@@ -90,16 +113,26 @@ class ContactView(View):
|
||||
return f"{a} + {b}"
|
||||
|
||||
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(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": ContactForm(),
|
||||
"captcha_question": self._new_captcha(request),
|
||||
"contact_videos": PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
is_active=True,
|
||||
).order_by("order"),
|
||||
"contact_videos": preview["videos"],
|
||||
"videos_total_count": preview["videos_total_count"],
|
||||
"videos_archive_url": preview["videos_archive_url"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -163,3 +196,52 @@ class CustomPageView(DetailView):
|
||||
)
|
||||
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
|
||||
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
|
||||
|
||||
@@ -11,6 +11,16 @@ urlpatterns = [
|
||||
views.ReleaseAssetDownloadView.as_view(),
|
||||
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(
|
||||
"<slug:main_slug>/",
|
||||
views.MainProductDetailView.as_view(),
|
||||
|
||||
+80
-2
@@ -2,6 +2,7 @@ import mimetypes
|
||||
|
||||
from django.http import FileResponse, Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
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_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):
|
||||
def get(self, request, version_id, asset):
|
||||
@@ -58,7 +71,16 @@ class MainProductDetailView(DetailView):
|
||||
context["articles"] = self.object.articles.prefetch_related(
|
||||
"sections", "citations"
|
||||
).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(
|
||||
self.object.distribution,
|
||||
self.object.versions.filter(is_active=True),
|
||||
@@ -112,7 +134,19 @@ class SubProductDetailView(TemplateView):
|
||||
context["articles"] = sub_product.articles.prefetch_related(
|
||||
"sections", "citations"
|
||||
).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"] = (
|
||||
SubProduct.objects.filter(main_product=main_product, is_active=True)
|
||||
.exclude(pk=sub_product.pk)
|
||||
@@ -127,6 +161,50 @@ class SubProductDetailView(TemplateView):
|
||||
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):
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
|
||||
@@ -3702,6 +3702,53 @@ a.citation-count-badge:hover {
|
||||
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) {
|
||||
.video-panel--styled {
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
<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 %}" />
|
||||
<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-32.png' %}" type="image/png" sizes="32x32" />
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
@@ -35,10 +35,12 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</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-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" />
|
||||
{% elif not hero %}
|
||||
<img src="{% static 'images/screenshot-1.jpg' %}" alt="Radiuma application — main workflow view" loading="eager" />
|
||||
|
||||
@@ -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_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
|
||||
class="brand-icon"
|
||||
width="{{ site_branding.footer_icon_size }}"
|
||||
height="{{ site_branding.footer_icon_size }}"
|
||||
style="{{ site_branding.footer_icon_style }}"
|
||||
/>
|
||||
{% else %}
|
||||
@@ -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_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
|
||||
class="brand-icon"
|
||||
width="{{ site_branding.navbar_icon_size }}"
|
||||
height="{{ site_branding.navbar_icon_size }}"
|
||||
style="{{ site_branding.navbar_icon_style }}"
|
||||
/>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{% 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">
|
||||
{% 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">
|
||||
{% for video in videos %}
|
||||
{% if video.has_video %}
|
||||
@@ -10,6 +16,11 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</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>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user