Files
qbz/crates/qbz-qt/qml/views/AwardView.qml
T
vicrodh 2d33a4fdfb feat(qt): search, load more and random on the award landing page
Three controls so the landing is usable without bouncing through "See all".

Search sits in the section header and shares the LISTING's query — one
award, one filter — so what you type here is still in force behind See all.
Client-side over the loaded set, like the listing and like the reference.

Load more is a BUTTON, not infinite scroll. The listing sub-view owns the
scroll-driven paging; this page has the "Other awards" rail under the grid,
and auto-loading would push that rail out of reach forever. Suppressed while
a query is active, for the listing's reason: paging in rows the filter then
hides is work with no visible result.

That button needed the preview cap gone. The landing document published
`take(PREVIEW_PAGE_SIZE)`, so "load more" would have fetched rows the view
threw away. The first fetch still asks for one preview page, so the first
paint costs what it did before; the cap is now only on the REQUEST, not on
what the document carries.

Random plays one album out of the LOADED set (the LibraryToolbar precedent:
pick a visible row, `QbzPlayer.playAlbum`). Not the whole award — the rest
is not here to pick from, and reaching for it would put a blocking fetch
behind a button that should feel instant.

Every mutation republishes BOTH documents now: they share one accumulating
album list, so a page loaded or a query typed on either side is visible on
the other.

No new msgids — "Search albums & tracks…", "Load more" and "Loading…" are
all in the eight catalogs already.

cc
2026-08-16 19:54:15 -06:00

505 lines
20 KiB
QML

// Award landing page — QML port of award/AwardView.slint (itself a 1:1 port
// of Tauri's AwardView.svelte). Route "award".
//
// A circular gold/laurel hero with the award name + magazine + a Follow
// heart, a section header ("Acclaimed Releases" + count + "See all" when more
// exist), a bounded PREVIEW grid, and an "Other awards" carousel.
//
// SCROLLING-PAGE CONVENTION (AlbumView / ArtistView / LabelView): one
// Flickable whose Column pads 32 / 32 / top 11 / bottom 100. The .slint's
// NavButtons row is dropped — back/forward live in the shell header — but its
// 22px spacer is KEPT so the hero sits where every other detail page's does.
//
// Composition: the grid is views/AlbumCollection.qml (the Discover/Label
// grid) and the heart is controls/QbzCircleAction.qml. The ONE local
// component is the "Other awards" card — see AwardRailCard below for why the
// shared ArtistCard could not be reused here.
//
// THE ONE ADDITION over the reference: the all-awards dropdown, pinned to the
// page's top-right. The Slint can only reach an award by finding an album that
// won it;
// this lists every award the `/award/explore` crawl found, so you can jump
// straight to any of them. The crawl already existed to resolve names to ids —
// the control is new, the data is not.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
readonly property bool ambientOn: theme.ambientOn
color: root.ambientOn ? "transparent" : theme.surfaceMain
radius: 12
QbzTheme { id: theme }
// One "Other awards" card. Local, and deliberately NOT cards/ArtistCard:
// that one calls `QbzArtist.openArtist(item.id)` INSIDE its own MouseArea
// and again from its menu (ArtistCard.qml:197, :294, :331) with no signal
// to override — handing it an award id would open an ARTIST page for a
// number that means something else entirely. The Slint could reuse its
// carousel because the Slint one emits `artist-clicked(id)` outward; this
// port's card swallows the click, so reuse here is not the same trade.
//
// It also drops the deviation the reference had to accept: an award with
// no image gets a LAUREL on gold, not the person glyph ArtistCard falls
// back to.
component AwardRailCard: Item {
id: railCard
property var entry: ({})
width: 160
height: 200
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 10
Rectangle {
width: 140
height: 140
radius: 70
clip: true
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "#b45309" }
GradientStop { position: 1.0; color: "#eab308" }
}
QbzIcon {
anchors.centerIn: parent
visible: (railCard.entry.imageUrl || "") === ""
name: "award"
width: 56
height: 56
tintName: theme.accentGlyphTint
}
RoundedImage {
anchors.fill: parent
visible: (railCard.entry.imageUrl || "") !== ""
radius: 70
source: railCard.entry.imageUrl || ""
}
}
Text {
width: 140
horizontalAlignment: Text.AlignHCenter
text: railCard.entry.title || ""
color: railArea.containsMouse ? theme.accent : theme.textPrimary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: 140
visible: (railCard.entry.artist || "") !== ""
horizontalAlignment: Text.AlignHCenter
text: railCard.entry.artist || ""
color: theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
MouseArea {
id: railArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.openAward(railCard.entry.id || "",
railCard.entry.title || "")
}
}
readonly property var doc: {
try {
return JSON.parse(QbzHome.awardJson)
} catch (e) {
return ({})
}
}
readonly property var albums: root.doc.albums || []
readonly property var otherAwards: root.doc.otherAwards || []
readonly property var catalog: root.doc.catalog || []
readonly property bool loading: QbzHome.awardLoading === true
readonly property bool loadError: root.doc.loadError === true
Flickable {
id: page
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: col
width: page.width - 64
x: 32
topPadding: 11
bottomPadding: 100
spacing: 0
// The .slint's NavButtons row is not ported; its spacer is, so the
// hero lands at the same y as every other detail page's header.
Item { width: 1; height: 22 }
// ---- Hero -------------------------------------------------------
Row {
width: parent.width
spacing: 32
Rectangle {
width: 180
height: 180
radius: 90
clip: true
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "#b45309" }
GradientStop { position: 1.0; color: "#eab308" }
}
QbzIcon {
anchors.centerIn: parent
visible: (root.doc.imageUrl || "") === ""
name: "award"
width: 84
height: 84
// The glyph sits on a saturated gold fill, which is the
// on-accent case the tint selector exists for.
tintName: theme.accentGlyphTint
}
RoundedImage {
anchors.fill: parent
visible: (root.doc.imageUrl || "") !== ""
radius: 90
source: root.doc.imageUrl || ""
}
}
Column {
width: parent.width - 180 - 32
spacing: 0
Item { width: 1; height: 8 }
Text {
text: QbzSession.tr("Award", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Item { width: 1; height: 6 }
Text {
width: parent.width
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item {
visible: (root.doc.magazineName || "") !== ""
width: 1
height: 10
}
Text {
visible: (root.doc.magazineName || "") !== ""
width: parent.width
// Server text — never translated.
text: root.doc.magazineName || ""
color: theme.textSecondary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
Item { width: 1; height: 18 }
QbzCircleAction {
name: root.doc.isFollowing === true ? "heart-filled" : "heart"
active: root.doc.isFollowing === true
btnEnabled: root.doc.followToggling !== true
onClicked: QbzHome.awardFollow()
}
}
}
Item { width: 1; height: 32 }
// ---- Section header ---------------------------------------------
// Anchored, not a Row: the "See all" chip pins right and the
// title group pins left, so neither has to know the other's width
// (a `parent.children[0].width` subtraction is how a header ends
// up mis-measuring itself the first time a child is hidden).
Item {
width: parent.width
height: 26
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Acclaimed Releases", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: (root.doc.total || 0) > 0
text: root.doc.total || 0
color: theme.textMuted
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
}
}
Row {
id: headerTools
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
// Filter the loaded set without leaving the page. Same
// query the listing uses — one award, one filter — so it
// is still in force behind "See all".
QbzLineEdit {
anchors.verticalCenter: parent.verticalCenter
width: 220
searchMode: true
sm: true
placeholder: QbzSession.tr("Search albums & tracks…", QbzSession.trRev)
text: root.doc.searchQuery || ""
onEdited: function (v) { QbzHome.awardAlbumsSearch(v) }
}
// Play a RANDOM album out of what is loaded — the
// LibraryToolbar precedent (`randomVisibleId` +
// QbzPlayer.playAlbum). It picks from the LOADED set, not
// the whole award: the rest is not here to pick from, and
// pretending otherwise would mean a blocking fetch behind
// a button that should feel instant.
QbzToolButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.albums.length > 0
name: "shuffle"
label: ""
onClicked: {
var pick = root.albums[
Math.floor(Math.random() * root.albums.length)]
if (pick && pick.id)
QbzPlayer.playAlbum(pick.id)
}
}
// "See all" — only when there is more than is loaded.
Rectangle {
id: seeAll
anchors.verticalCenter: parent.verticalCenter
visible: root.doc.hasMore === true
width: visible ? seeAllText.implicitWidth + 16 : 0
height: 26
radius: 4
color: seeAllArea.containsMouse ? theme.surfaceHover : "transparent"
Text {
id: seeAllText
anchors.centerIn: parent
// "See All" is the msgid; the arrow is decoration
// appended OUTSIDE it (the reference is explicit).
text: QbzSession.tr("See All", QbzSession.trRev) + " →"
color: seeAllArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 14
font.weight: theme.weightMedium
}
MouseArea {
id: seeAllArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.awardOpenAlbums()
}
}
}
}
Item { width: 1; height: 16 }
// ---- Body: loading / error+retry / empty / grid (exclusive) -----
Item {
visible: root.loading
width: parent.width
height: visible ? 280 : 0
Column {
anchors.centerIn: parent
spacing: 18
QbzSpinner {
anchors.horizontalCenter: parent.horizontalCenter
size: 36
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Loading…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
}
}
}
Item {
visible: !root.loading && root.loadError
width: parent.width
height: visible ? 240 : 0
Column {
anchors.centerIn: parent
spacing: 16
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Failed to load Library", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
SettingsButton {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Retry", QbzSession.trRev)
onClicked: QbzHome.awardRetry()
}
}
}
Item {
visible: !root.loading && !root.loadError && root.albums.length === 0
width: parent.width
height: visible ? 200 : 0
Text {
anchors.centerIn: parent
text: QbzSession.tr("No award-winning releases yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
AlbumCollection {
visible: !root.loading && !root.loadError && root.albums.length > 0
width: parent.width
// Identity of the catalog on screen — navigating to ANOTHER
// award must not inherit the previous one's tail fade
// (AlbumCollection.collectionKey, the LabelReleasesView note).
collectionKey: root.doc.id || ""
albums: root.albums
viewMode: "grid"
isGrouped: false
cardWidth: 200
cardHeight: 266
cardGap: 24
}
// ---- Load more ---------------------------------------------------
// A BUTTON, not infinite scroll: the listing sub-view owns the
// scroll-driven paging, and this page has a rail under the grid —
// auto-loading here would keep pushing that rail out of reach.
// Suppressed while a query is active, like the listing: paging in
// rows the filter then hides is work with no visible result.
Item {
visible: root.doc.hasMore === true && (root.doc.searchQuery || "") === ""
width: parent.width
height: visible ? 64 : 0
SettingsButton {
anchors.centerIn: parent
enabled: root.doc.loadingMore !== true
text: root.doc.loadingMore === true
? QbzSession.tr("Loading…", QbzSession.trRev)
: QbzSession.tr("Load more", QbzSession.trRev)
onClicked: QbzHome.awardAlbumsLoadMore()
}
}
// ---- Other awards -----------------------------------------------
Item {
visible: root.otherAwards.length > 0
width: 1
height: visible ? 32 : 0
}
Column {
visible: root.otherAwards.length > 0
width: parent.width
spacing: 12
Text {
text: QbzSession.tr("Other awards", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Flickable {
width: parent.width
height: 210
contentWidth: rail.width
contentHeight: height
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
clip: true
Row {
id: rail
height: parent.height
spacing: 16
Repeater {
model: root.otherAwards
delegate: AwardRailCard {
required property var modelData
entry: modelData
}
}
}
}
}
}
QbzScrollBar {
target: page
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
// ---- The all-awards dropdown (this port only) ---------------------------
// Top-right of the PAGE (owner, 2026-08-16), so it sits outside the
// Flickable and stays put while the page scrolls — a jump-to-anywhere
// control that scrolls away with the hero is only reachable from the top.
//
// Hidden until the background crawl has BOTH a list and this award's place
// in it: a select whose current row is somebody else's award reads as "you
// are here" and is not.
//
// `searchable` because the catalog runs to hundreds of entries — the
// control's own rule is a filter box past ~8 options.
QbzSelect {
id: awardPicker
anchors.right: parent.right
anchors.top: parent.top
// Clear of the scrollbar's 14px gutter, on the page's 32px rhythm.
anchors.rightMargin: 32 + 14
anchors.topMargin: 11
z: 5
visible: root.catalog.length > 1
&& (root.doc.catalogIndex !== undefined)
&& root.doc.catalogIndex >= 0
menuWidth: 320
popupWidth: 320
searchable: true
options: {
var out = []
for (var i = 0; i < root.catalog.length; i++)
out.push(root.catalog[i].name)
return out
}
currentIndex: root.doc.catalogIndex || 0
onSelected: function (i) {
var entry = root.catalog[i]
if (entry && entry.id !== (root.doc.id || ""))
QbzHome.openAward(entry.id, entry.name || "")
}
}
}