Ports the Slint/Tauri award pair: the landing (gold laurel hero, magazine line, follow heart, "Acclaimed Releases" with its count and See All, the preview grid and the "Other awards" rail) and the paginated listing with its client-side search. Routes "award" and "awardalbums", the label pair's shape — one controller for both, since they share the hero, the follow state and the identity. The three rules from the Tauri review are in the module header because they are the ones a re-implementation gets wrong: the grid is ALWAYS /award/getAlbums (/award/page is user-scoped and may omit releases, so it feeds the hero and nothing else); the favorite split is plural-read, singular-write, because the backend derives `award_ids` from the write kind; and getAlbums has no has_more, its `total` is a hint, so the only sound predicate is `total > loaded`. The album sidebar's AWARDS cards navigate now — with an id directly, and by NAME through the resolver when Qobuz omits it, which it does. They were also reading `album.awards` when the field lives on `album.header`, so the block had rendered EMPTY for every album since it was written: an empty Repeater draws nothing and reports nothing, and the sidebar's own gate counted the same empty list. The LABEL card beside it had a "no label view yet" note and no handler, years after the label view landed. Beyond the reference: an all-awards dropdown pinned to the page's top-right, so any award is reachable without first finding an album that won it. It costs nothing new — the /award/explore crawl already existed to resolve names to ids. The award icon was lucide's MICROPHONE under the wrong filename. Search had routed around it with `crown` and left a note; kiosk was missed and drew a mic for "Most popular". The asset is the laurel the other two frontends use, and kiosk now asks for the crown it wanted. cc
447 lines
17 KiB
QML
447 lines
17 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
|
|
}
|
|
}
|
|
|
|
// "See all" — only when there is more than the preview holds.
|
|
Rectangle {
|
|
id: seeAll
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.doc.hasMore === true
|
|
width: seeAllText.implicitWidth + 16
|
|
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
|
|
}
|
|
|
|
// ---- 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 || "")
|
|
}
|
|
}
|
|
}
|