diff --git a/core/views.py b/core/views.py index 488b42869..03c841091 100644 --- a/core/views.py +++ b/core/views.py @@ -1252,6 +1252,7 @@ def get_context_data(self, **kwargs): build_library_intro_context, get_commit_data_by_release_for_library, commit_data_to_stats_bars, + patch_commit_authors, ) CODE_DEMO_BEAST = """int main() @@ -1989,4 +1990,82 @@ def get_context_data(self, **kwargs): else: context["example_library_not_found"] = library_slug context["example_library_slug"] = library_slug + + demo_library_items = [] + demo_libs_qs = ( + Library.objects.filter( + slug__in=[ + "accumulators", + "algorithm", + "align", + "any", + "array", + "asio", + "assign", + "atomic", + "beast", + "bimap", + "bind", + ] + ) + .prefetch_related("categories", "authors") + .order_by("name") + ) + # Collect one author per demo library and patch CommitAuthor data + demo_authors = {} + for lib in demo_libs_qs: + author = ( + lib.authors.exclude(email__startswith="deleted-") + .exclude(github_username="") + .first() + ) or lib.authors.exclude(email__startswith="deleted-").first() + if author: + demo_authors[lib.pk] = author + patch_commit_authors(list(demo_authors.values())) + + for lib in demo_libs_qs: + lv = ( + LibraryVersion.objects.filter(version=latest, library=lib).first() + if latest + else None + ) + cats = [ + {"label": cat.name, "url": "#", "variant": "neutral"} + for cat in lib.categories.all()[:3] + ] + author = demo_authors.get(lib.pk) + demo_library_items.append( + { + "library_name": lib.display_name_short, + "library_url": reverse( + "library-detail", + kwargs={ + "version_slug": "latest", + "library_slug": lib.slug, + }, + ), + "description": lib.description or "", + "categories": cats, + "cpp_version": ( + lv.get_cpp_standard_minimum_display() + if lv and lv.cpp_standard_minimum + else "C++03" + ), + "author": { + "name": author.display_name if author else "Unknown", + "role": "Contributor", + "avatar_url": author.get_avatar_url() if author else "", + "badge_url": f"{badge_img}/badge-first-place.png", + }, + "doc_url": reverse( + "library-detail", + kwargs={ + "version_slug": "latest", + "library_slug": lib.slug, + }, + ), + } + ) + context["demo_library_items"] = demo_library_items + return context diff --git a/static/css/v3/components.css b/static/css/v3/components.css index 244ab17b5..45e5661dd 100644 --- a/static/css/v3/components.css +++ b/static/css/v3/components.css @@ -46,3 +46,4 @@ @import "./post-card.css"; @import "./event-card.css"; @import "./badge-button.css"; +@import "./library-item.css"; diff --git a/static/css/v3/library-item.css b/static/css/v3/library-item.css new file mode 100644 index 000000000..08b3dcdcd --- /dev/null +++ b/static/css/v3/library-item.css @@ -0,0 +1,301 @@ +/* ========================================================================== + Library Item — list row and card variants + ========================================================================== */ + +/* Demo only: provides a contrasting background for the list variant in + Lookbook/examples so the item surface colours are visible. Remove when + the component is used on a real page with its own background. */ +.v3-examples-section__example-box:has(.library-item-list) { + background: var(--color-surface-mid); +} + +/* ---------- Containers ---------- */ + +.library-item-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 160px 2fr 1fr 1fr 1fr auto; + column-gap: var(--space-xl); + max-width: 1408px; + width: 100%; +} + +.library-item-card-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-large); +} + +@media (max-width: 1024px) { + .library-item-card-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +/* ---------- Shared element styles ---------- */ + +.library-item__name { + font-family: var(--font-display); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-tight); + letter-spacing: var(--letter-spacing-display-regular); + color: var(--color-text-primary); + text-decoration: none; +} + +a.library-item__name:hover { + text-decoration: underline; +} + +.library-item p { + margin: 0; + padding: 0; +} + +.library-item__description { + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-default); + letter-spacing: var(--letter-spacing-tight); + color: var(--color-text-secondary); +} + +.library-item .btn-icon-library { + padding: 0; + border: none; + background: none; + width: var(--space-large); +} + +.library-item .btn-icon-library .btn-icon svg { + width: 100%; + height: 100%; +} + +/* Version tag visibility — desktop shows the tag inside .library-item__actions, + mobile/card shows the tag inline with category tags. */ +.library-item__cpp-version { + display: none; +} + +.library-item__cpp-version--desktop { + display: inline-flex; + width: fit-content; + justify-self: center; +} + +/* ========================================================================== + List variant + ========================================================================== */ + +/* ---------- Desktop ---------- */ + +.library-item--list { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: start; + row-gap: var(--space-large); + column-gap: var(--space-large); + padding: var(--space-large); + padding-top: var(--space-default); + background: var(--color-surface-weak); + border-bottom: 1px solid var(--color-stroke-weak); +} + +.library-item--list .library-item__header { + display: contents; +} + +.library-item--list .library-item__tags { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-self: start; + gap: var(--space-s); +} + +.library-item--list .library-item__contributor { + display: flex; + align-items: start; + gap: var(--space-default); +} + +.library-item--list .library-item__actions { + display: contents; +} + +/* Right-align the doc button with extra spacing from version tag */ +.library-item--list .library-item__actions > .btn-icon-library { + justify-self: end; + margin-left: var(--space-xl); +} + +/* ---------- Tablet (≤ 1024px) ---------- */ + +@media (max-width: 1024px) { + .library-item-list { + grid-template-columns: auto 2fr 1fr 1fr min-content auto; + /* Figma specifies --space-large here, but long descriptions cause overflow + at tablet widths. Using --space-default until content constraints are + defined. */ + column-gap: var(--space-default); + } + + .library-item--list .user-profile { + align-items: normal; + } + + .library-item--list .library-item__actions > .btn-icon-library { + margin-left: var(--space-default); + } +} + +/* ---------- Mobile (≤ 767px) — switches to card-like layout ---------- */ + +@media (max-width: 767px) { + .library-item-list { + display: flex; + flex-direction: column; + } + + /* Container — stacked cards with shared borders */ + .library-item--list { + display: grid; + grid-template-columns: 1fr auto; + row-gap: var(--space-medium); + border-bottom: 1px solid var(--color-stroke-weak); + border-radius: 0; + background: var(--color-surface-weak); + padding: var(--space-large); + } + + .library-item--list:first-child { + border-radius: var(--border-radius-xl) var(--border-radius-xl) 0 0; + } + + .library-item--list:last-child { + border-radius: 0 0 var(--border-radius-xl) var(--border-radius-xl); + border-bottom: none; + } + + .library-item--list:only-child { + border-radius: var(--border-radius-xl); + } + + /* Header — name + description, left column */ + .library-item--list .library-item__header { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + gap: var(--space-card); + } + + /* Actions — doc icon only, top-right */ + .library-item--list .library-item__actions { + grid-column: 2; + grid-row: 1; + min-width: unset; + } + + /* Version tag swap — hide desktop, show mobile (inline with tags) */ + .library-item--list .library-item__cpp-version--desktop { + display: none; + } + + .library-item--list .library-item__cpp-version { + display: inline-flex; + } + + /* Tags — category tags + C++ version, full width */ + .library-item--list .library-item__tags { + grid-column: 1 / -1; + grid-row: 2; + } + + /* Contributor — full width, bottom */ + .library-item--list .library-item__contributor { + grid-column: 1 / -1; + grid-row: 3; + margin-top: auto; + } +} + +/* ========================================================================== + Card variant + ========================================================================== */ + +/* ---------- Desktop ---------- */ + +.library-item--card { + display: grid; + grid-template-columns: 1fr auto; + column-gap: var(--space-large); + width: 100%; + border-radius: var(--border-radius-xl); + border: 1px solid var(--color-stroke-mid); + background: var(--color-surface-weak); + padding: var(--space-large); +} + +.library-item--card .library-item__header { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + gap: var(--space-large); +} + +.library-item--card .library-item__actions { + grid-column: 2; + grid-row: 1; + display: flex; + align-items: start; + justify-content: end; +} + +.library-item--card .library-item__cpp-version--desktop { + display: none; +} + +.library-item--card .library-item__cpp-version { + display: inline-flex; +} + +.library-item--card .library-item__tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-s); +} + +.library-item--card .library-item__contributor { + grid-column: 1 / -1; + grid-row: 2; + display: flex; + align-items: center; + gap: var(--space-default); + margin-top: auto; + padding-top: var(--space-large); +} + +/* ---------- Mobile (≤ 767px) ---------- */ + +@media (max-width: 767px) { + .library-item-card-grid { + grid-template-columns: 1fr; + gap: var(--space-medium); + } + + .library-item--card .library-item__header { + gap: var(--space-card); + } + + .library-item--card .library-item__contributor { + padding-top: var(--space-card); + } +} diff --git a/templates/v3/examples/_v3_example_section.html b/templates/v3/examples/_v3_example_section.html index f41cc455f..b5acf9daf 100644 --- a/templates/v3/examples/_v3_example_section.html +++ b/templates/v3/examples/_v3_example_section.html @@ -223,6 +223,30 @@

{{ section_title }}

{% endwith %} {% endif %} + {% with section_title="Library Item (List)" %} +
+

{{ section_title }}

+
+ +
+
+ {% endwith %} + + {% with section_title="Library Item (Card)" %} +
+

{{ section_title }}

+
+ {% for item in demo_library_items %} + {% include "v3/includes/_library_item.html" with variant="card" library_name=item.library_name library_url=item.library_url description=item.description categories=item.categories cpp_version=item.cpp_version author=item.author doc_url=item.doc_url %} + {% endfor %} +
+
+ {% endwith %} + {% if example_library_choices %} {% with section_title="Commits per release" %}
diff --git a/templates/v3/includes/_library_item.html b/templates/v3/includes/_library_item.html new file mode 100644 index 000000000..a5a9371cc --- /dev/null +++ b/templates/v3/includes/_library_item.html @@ -0,0 +1,47 @@ +{% comment %} + Library Item — displays a Boost library in list or card layout. + + Variables: + variant (string, required) - "list" | "card" + library_name (string, required) - Library display name, e.g. "Boost.Geometry" + library_url (string, optional) - Link for the library name + description (string, required) - Short library description + categories (list, required) - List of {label, url, variant} dicts for category tags + cpp_version (string, required) - e.g. "C++ 14" — rendered as neutral tag + author (dict, required) - Author object for _user_profile.html (name, role, avatar_url, badge_url) + doc_url (string, required) - Documentation link URL + extra_attrs (string, optional) - Extra HTML attributes for analytics + + Usage (list): + {% include "v3/includes/_library_item.html" with variant="list" library_name="Boost.Geometry" library_url="#" description="A library for solving geometry problems." categories=cats cpp_version="C++ 14" author=author doc_url="/libs/geometry/doc/" %} + + Usage (card): + {% include "v3/includes/_library_item.html" with variant="card" library_name="Boost.Asio" description="Portable networking and low-level I/O." categories=cats cpp_version="C++ 11" author=author doc_url="/libs/asio/doc/" %} +{% endcomment %} +{% if variant == "list" %}
  • {% else %}
    {% endif %} + +
    + {% if library_url %} + {{ library_name }} + {% else %} + {{ library_name }} + {% endif %} +

    {{ description }}

    +
    + {% for cat in categories %} + {% include "v3/includes/_category_tag.html" with tag_label=cat.label url=cat.url variant=cat.variant %} + {% endfor %} + C++ {{ cpp_version|cut:"C++" }} +
    +
    + +
    + {% include "v3/includes/_user_profile.html" with author=author only %} +
    + +
    + C++ {{ cpp_version|cut:"C++" }} + {% include "v3/includes/_button.html" with url=doc_url label="" icon_name="documentation" icon_size=32 icon_viewbox="0 0 16 16" style="icon-library" aria_label="View documentation for "|add:library_name %} +
    + +{% if variant == "list" %}
  • {% else %}
    {% endif %} diff --git a/users/models.py b/users/models.py index c7491044d..44cc66eaf 100644 --- a/users/models.py +++ b/users/models.py @@ -289,6 +289,22 @@ def get_thumbnail_url(self): with suppress(AttributeError, MissingSource): return getattr(self.image_thumbnail, "url", None) + def get_avatar_url(self): + """Return the best available avatar URL. + + Tries the profile image thumbnail first, then falls back to + the linked CommitAuthor avatar. Returns empty string when no + image is available so the avatar template falls back to a + colored initials circle. + """ + if url := self.get_thumbnail_url(): + return url + if (ca := getattr(self, "commitauthor", None)) and getattr( + ca, "avatar_url", None + ): + return ca.avatar_url + return "" + def get_hq_image_url(self): # convenience method for templates if self.hq_image and self.hq_image_render: