diff --git a/kitsune/products/jinja2/products/includes/topic_macros.html b/kitsune/products/jinja2/products/includes/topic_macros.html index 64ea50f6c0f..e898a10961e 100644 --- a/kitsune/products/jinja2/products/includes/topic_macros.html +++ b/kitsune/products/jinja2/products/includes/topic_macros.html @@ -1,32 +1,45 @@ {% macro help_topics(topics, product_slug=None, new_tab=False) -%} -
-
- {% for topic in topics %} - {% set topic_url = url('products.documents', product_slug=product_slug or product.slug, topic_slug=topic.slug) %} -
+{# topics: List of topic_data dicts containing: + - topic: Topic model instance + - topic_url: URL to topic page + - title: Topic title + - total_articles: Number of articles + - image_url: URL to topic icon + - documents: three documents for the topic +#} +
+
+ {% for topic_data in topics %} + + {% endfor %}
+
{%- endmacro %} + + {% macro topic_metadata(topics, product=None) %} {% if product and has_aaq_config and not settings.READ_ONLY %}
diff --git a/kitsune/products/jinja2/products/product.html b/kitsune/products/jinja2/products/product.html index a533733d663..0bf23f3b096 100644 --- a/kitsune/products/jinja2/products/product.html +++ b/kitsune/products/jinja2/products/product.html @@ -123,7 +123,7 @@

-

{{ _('Frequent Topics') }}

+

{{ _('Topics') }}

{{ _('Explore the knowledge base.') }}

diff --git a/kitsune/products/views.py b/kitsune/products/views.py index f87fda7139a..eb5df66a67b 100644 --- a/kitsune/products/views.py +++ b/kitsune/products/views.py @@ -2,7 +2,7 @@ from django.conf import settings from django.db.models import Exists, OuterRef, Q -from django.http import Http404, HttpResponse +from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from product_details import product_details @@ -11,7 +11,7 @@ from kitsune.wiki.decorators import check_simple_wiki_locale from kitsune.wiki.facets import documents_for, topics_for from kitsune.wiki.models import Document, Revision -from kitsune.wiki.utils import get_featured_articles +from kitsune.wiki.utils import build_topics_data, get_featured_articles @check_simple_wiki_locale @@ -23,8 +23,19 @@ def product_list(request): @check_simple_wiki_locale -def product_landing(request, slug): - """The product landing page.""" +def product_landing(request: HttpRequest, slug: str) -> HttpResponse: + """The product landing page. + + Args: + request: The HTTP request + slug: Product slug identifier + + Returns: + Rendered product landing page + + Raises: + Http404: If product not found + """ if slug == "firefox-accounts": return redirect(product_landing, slug="mozilla-account", permanent=True) @@ -45,6 +56,7 @@ def product_landing(request, slug): latest_version = versions[0].min_version else: latest_version = 0 + topics = topics_for(request.user, product=product, parent=None) return render( request, @@ -52,7 +64,7 @@ def product_landing(request, slug): { "product": product, "products": Product.active.filter(visible=True), - "topics": topics_for(request.user, product=product, parent=None), + "topics": build_topics_data(request, product, topics), "search_params": {"product": slug}, "latest_version": latest_version, "featured": get_featured_articles(product, locale=request.LANGUAGE_CODE), diff --git a/kitsune/questions/views.py b/kitsune/questions/views.py index 40d37092e8e..f729ed58fde 100644 --- a/kitsune/questions/views.py +++ b/kitsune/questions/views.py @@ -70,6 +70,7 @@ from kitsune.upload.models import ImageAttachment from kitsune.users.models import Setting from kitsune.wiki.facets import topics_for +from kitsune.wiki.utils import build_topics_data log = logging.getLogger("k.questions") @@ -568,8 +569,10 @@ def aaq(request, product_slug=None, step=1, is_loginless=False): context["ga_products"] = f"/{product_slug}/" if step == 2: + topics = topics_for(request.user, product, parent=None) + context["featured"] = get_featured_articles(product, locale=request.LANGUAGE_CODE) - context["topics"] = topics_for(request.user, product, parent=None) + context["topics"] = build_topics_data(request, product, topics) elif step == 3: context["cancel_url"] = get_next_url(request) or ( diff --git a/kitsune/sumo/static/sumo/scss/components/_card.scss b/kitsune/sumo/static/sumo/scss/components/_card.scss index 62fefb26d4a..8436caa5266 100644 --- a/kitsune/sumo/static/sumo/scss/components/_card.scss +++ b/kitsune/sumo/static/sumo/scss/components/_card.scss @@ -181,21 +181,79 @@ } &--topic { - @include c.elevation-01; display: flex; - align-items: center; + flex-direction: column; + justify-content: space-between; + height: 100%; + background-color: p.$color-white; + border: 1px solid #ddd; + border-radius: p.$border-radius-md; + padding: p.$spacing-md; + box-shadow: p.$box-shadow-sm; + box-sizing: border-box; + + .topic-header { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: p.$spacing-md; + } - .card-title { + .card--icon { + flex-shrink: 0; + width: 18px; + height: 18px; + } + + .card--title { + font-family: Inter; + font-size: 16px; + font-weight: 700; margin: 0; + line-height: 1.2; + flex-grow: 1; } - .card--icon { - width: p.$spacing-lg; - height: p.$spacing-lg; - object-fit: contain; - font-size: 9px; - line-height: 1; - flex: 0 0 auto; + .article-list { + flex-grow: 1; + margin: 0 0 p.$spacing-md; + padding: 0; + list-style: none; + + li { + margin-bottom: 8px; + line-height: 1.5; + + a { + color: black; + font-size: 14px; + text-decoration: underline; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + text-decoration: underline; + } + } + } + } + + .view-all-link { + border-top: 1px solid #ddd; + padding-top: 10px; + margin-top: p.$spacing-md; + display: inline-block; + font-size: 14px; + color: #000000; + text-decoration: underline; + font-weight: normal; + + &:hover { + text-decoration: underline; + } } } diff --git a/kitsune/sumo/static/sumo/scss/layout/_products.scss b/kitsune/sumo/static/sumo/scss/layout/_products.scss index 068045483ca..59069708862 100644 --- a/kitsune/sumo/static/sumo/scss/layout/_products.scss +++ b/kitsune/sumo/static/sumo/scss/layout/_products.scss @@ -1,7 +1,6 @@ @use '../config' as c; @use 'protocol/css/includes/lib' as p; - .sumo-page-subheader { display: flex; flex-direction: column-reverse; @@ -32,3 +31,51 @@ margin: 0 p.$spacing-xs; } } + +.topics-section { + padding: 40px 20px; + h2 { + font-size: 24px; + margin-bottom: 0px; + color: #333; + } + + @media #{p.$mq-sm} { + padding: p.$spacing-lg p.$spacing-md; + } +} + +.topics-grid { + @media #{p.$mq-lg} { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + } + + @media (max-width: #{p.$screen-lg}) { + display: flex; + overflow-x: auto; + gap: p.$spacing-md; + padding: p.$spacing-md 0; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > .card--topic { + flex: 0 0 280px; + height: 280px; + scroll-snap-align: start; + display: flex; + flex-direction: column; + overflow: hidden; + + > * { + flex-shrink: 0; + } + } + } +} diff --git a/kitsune/wiki/utils.py b/kitsune/wiki/utils.py index 1e572d387e0..3353b1a4504 100644 --- a/kitsune/wiki/utils.py +++ b/kitsune/wiki/utils.py @@ -5,12 +5,19 @@ from django.contrib.auth.models import User from django.db.models import Prefetch, Q from django.db.models.functions import Now -from django.http import Http404 +from django.http import Http404, HttpRequest from django.shortcuts import get_object_or_404 +from django.contrib.postgres.aggregates import StringAgg +from django.db.models import TextField +from django.db.models.functions import Cast +from itertools import chain, islice from kitsune.dashboards import LAST_7_DAYS from kitsune.dashboards.models import WikiDocumentVisits +from kitsune.sumo.urlresolvers import reverse from kitsune.wiki.models import Document, Revision +from kitsune.wiki.facets import documents_for +from kitsune.products.models import Product, Topic def active_contributors(from_date, to_date=None, locale=None, product=None): @@ -103,52 +110,62 @@ def _active_contributors_id(from_date, to_date, locale, product): return set(list(editors) + list(reviewers)) -def get_featured_articles(product=None, locale=settings.WIKI_DEFAULT_LANGUAGE): - """Returns 4 random articles from the most visited. +def get_featured_articles(product=None, topics=None, locale=settings.WIKI_DEFAULT_LANGUAGE): + """Returns up to 4 random articles per topic from the most visited. - If a product is passed, it returns 4 random highly visited articles. + Args: + product: Optional product to filter by + topics: Optional iterable of topics to filter by + locale: Locale to get articles for, defaults to WIKI_DEFAULT_LANGUAGE """ + # Get base queryset with all needed relations in one hit visits = ( WikiDocumentVisits.objects.filter(period=LAST_7_DAYS) + .select_related("document") + .prefetch_related( + "document__products", + "document__topics", + Prefetch( + "document__translations", + queryset=( + Document.objects.visible( + locale=locale, + current_revision__is_approved=True, + is_archived=False, + is_template=False, + ) + if locale != settings.WIKI_DEFAULT_LANGUAGE + else None + ), + ), + ) .filter( document__restrict_to_groups__isnull=True, document__locale=settings.WIKI_DEFAULT_LANGUAGE, + document__is_archived=False, + document__is_template=False, ) .exclude(document__products__slug__in=settings.EXCLUDE_PRODUCT_SLUGS_FEATURED_ARTICLES) - .exclude(document__is_archived=True) - .exclude(document__is_template=True) - .order_by("-visits") - .select_related("document") ) + # Add product and topics filters to the database query if product: - visits = visits.filter(document__products__in=[product.id]) + visits = visits.filter(document__products=product) + if topics: + visits = visits.filter(document__topics__in=topics) - visits = visits[:10] - documents = [] + # Order by visits but don't limit - we need enough documents for all topics + visits = visits.order_by("-visits") + # Get documents based on locale + documents = [] if locale == settings.WIKI_DEFAULT_LANGUAGE: - for visit in visits: - documents.append(visit.document) + documents = [visit.document for visit in visits] else: - # prefretch localised documents to avoid n+1 problem - visits = visits.prefetch_related( - Prefetch( - "document__translations", - queryset=Document.objects.visible( - locale=locale, - current_revision__is_approved=True, - is_archived=False, - is_template=False, - ), - ) - ) - for visit in visits: - translation = visit.document.translations.first() - if not translation: - continue - documents.append(translation) + translation = next(iter(visit.document.translations.all()), None) + if translation: + documents.append(translation) if len(documents) <= 4: return documents @@ -194,3 +211,59 @@ def get_visible_document_or_404( def get_visible_revision_or_404(user, **kwargs): return get_object_or_404(Revision.objects.visible(user, **kwargs)) + + +def build_topics_data(request: HttpRequest, product: Product, topics: list[Topic]) -> list[dict]: + """Build topics_data for use in topic cards + Inputs: + request: HttpRequest + product: Product + topics: list[Topic] + Output: + topics_data: list[dict] + """ + topics_data = [] + + all_docs, _ = documents_for( + request.user, request.LANGUAGE_CODE, topics=topics, products=[product] + ) + + # Convert all docs to Document objects + doc_ids = [doc["id"] for doc in all_docs] + documents = ( + Document.objects.filter(id__in=doc_ids) + .prefetch_related("topics") + .annotate(topic_ids=StringAgg(Cast("topics__id", TextField()), delimiter=",", default="")) + ) + + featured_articles = get_featured_articles(product, locale=request.LANGUAGE_CODE, topics=topics) + + for topic in topics: + # Filter documents for this specific topic + topic_docs = [ + doc for doc in documents if doc.topic_ids and str(topic.id) in doc.topic_ids.split(",") + ] + + # Filter featured articles for this topic + topic_featured = [ + doc for doc in featured_articles if any(t.id == topic.id for t in doc.topics.all()) + ] + + remaining_docs = (doc for doc in topic_docs if doc not in topic_featured) + + topic_data = { + "topic": topic, + "topic_url": reverse("products.documents", args=[product.slug, topic.slug]), + "title": topic.title, + "total_articles": len(topic_docs), + "image_url": topic.image_url, + # We want to show three articles in total, and we prefer featured_articles + # but if we don't have enough we will then use remainind_docs to fill the + # remaining slots. + # We use islice to limit the number of articles to 3, and chain to combine + # featured_articles and remaining_docs so featured come first. + "documents": list(islice(chain(topic_featured, remaining_docs), 3)), + } + topics_data.append(topic_data) + + return topics_data