Files
qbz/crates/qbz-qt/qml/views/AwardView.qml
T

548 lines
22 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
// Reserves exactly the pinned bar's height. Without it the hero
// would jump up by that height the moment the bar was lifted out
// of the flow (the ArtistView jumpSlot rule).
Item {
id: navSlot
width: 1
height: navBar.height
}
// 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 is loaded. The
// search and the shuffle used to sit beside it; they live in
// the pinned nav bar now, next to the award picker.
Rectangle {
id: seeAll
anchors.right: parent.right
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 {
id: collection
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
flick: page
contentOffset: collection.y
}
// ---- 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
}
}
}
}
}
}
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: page; scope: "award" }
QbzScrollBar {
target: page
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
// ---- The award nav bar (this port only) ---------------------------------
// Pinned at the top of the page, OUTSIDE the Flickable, given the
// ArtistView jump-nav treatment: 44px, its own fill, a 1px bottom
// hairline, and the page's 32px horizontal rhythm.
//
// THE ROUNDED-CORNER RULE, and it is a real defect this codebase has hit
// twice (ArtistView.qml:2795, the Discover full-bleed toolbar): pinned at
// the pane's top edge the bar's SQUARE corners poke out past the shell's
// rounded bezel. Round them — but only WHILE it is actually at the top.
// Mid-page the bar is surrounded by content and a notch there is worse
// than the square corner it fixes, so the radius is driven off the live y.
//
// The fill is not transparent: pinned, the page scrolls underneath, so it
// needs enough body to stay readable. Under the dynamic background that is
// surface-main at the thin-bar alpha tier, the same one ArtistView uses.
Rectangle {
id: navBar
x: 0
width: root.width
height: 44
y: Math.max(page.y + navSlot.y - page.contentY, 0)
z: 5
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
topLeftRadius: y <= 0 ? theme.radiusMd : 0
topRightRadius: y <= 0 ? theme.radiusMd : 0
Rectangle {
x: 0
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
// All three controls in one cluster, right-aligned, with the
// navigation dropdown LAST — the search and the shuffle act on the
// page you are on, the dropdown takes you off it, so it reads
// left-to-right from "narrow this" to "leave here".
Row {
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
spacing: 10
// Filters the loaded set. Shares the LISTING's query — 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). 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.
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)
}
}
// Jump to ANY award. Hidden until the background crawl has both a
// list and THIS award's place in it: `currentIndex` tracks the
// open page, so navigating away and arriving somewhere else moves
// the selection with you instead of stranding it on the award you
// came from.
QbzSelect {
id: awardPicker
anchors.verticalCenter: parent.verticalCenter
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 || "")
}
}
}
}
}