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
505 lines
20 KiB
QML
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 || "")
|
|
}
|
|
}
|
|
}
|