Phase-2 Slice 12c (user directive: use the app's circular controls, no invented buttons; fix the glyph-stuck-left bug). - Hero §3.6: deleted the local ActionCircle; the 4 hero controls are now the shared CircleAction (Play primary = filled white disc + black glyph; the loading spinner is now available = more 1:1). Added `enabled` to CircleAction (additive). - All MyQBZ modal footers (Create / Edit / Mix / AddTo / Discography) now use the shared QbzPrimaryButton (+ a new `destructive` variant for Delete) and a new shared SecondaryButton (promoted from AppearanceSettings' inline copy). QbzPrimaryButton auto-sizes as a footer chip while staying full-width in login (verified: login stretches it in a VerticalLayout; footers take preferred width). - Kept legitimately-local icon+label controls (grid "+ New" CTA, AddToMixtape picker chips, toolbar sort/filter/view-mode pills) but verified every glyph is centered. - Fixed 3 glyph-stuck-left bugs (DiscographyBuilder checkboxes used non-snapped centering -> the Math.round(.../1px)*1px crisp idiom). Backend crates untouched. cargo check -p qbz-slint green. cc
485 lines
20 KiB
Plaintext
485 lines
20 KiB
Plaintext
// "Add to Mixtape/Collection" picker modal — the global singleton that adds
|
|
// one or more items (album / track / playlist, from qobuz or local) into an
|
|
// existing Mixtape/Collection, or creates a brand-new one inline and adds into
|
|
// it. 1:1 with Tauri's `AddToMixtapeModal.svelte` (spec 21 §A) chrome:
|
|
//
|
|
// - own backdrop (z9998) + 480px card (z9999), NOT the shared modal chrome.
|
|
// - header eyebrow ("ADD TO MIXTAPE/COLLECTION") + title/subtitle (single
|
|
// item -> title+subtitle; bulk -> "{count} items" + "{first} + N more").
|
|
// - TWO mutually-exclusive body panels: the PICKER (search row + collection
|
|
// list + create buttons) and the CREATE-NEW panel (name + kind + footer).
|
|
// - per-row kind icon, "N albums" meta, kind tag, and an "already added"
|
|
// hint when the collection already contains every pending item.
|
|
//
|
|
// Mounted ONCE at the window root (AppShell). The Rust controller
|
|
// (crate::myqbz_add) holds the pending items + drives every action; the
|
|
// "added N / skipped M duplicates" outcome is surfaced via toast.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
import { Radius } from "../foundation/radius.slint";
|
|
import { MyQbzAddState, MyQbzAddActions, MyQbzAddRow } from "../state.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
|
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
|
|
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
|
|
import { LineEdit } from "std-widgets.slint";
|
|
|
|
// A single labelled radio option in the create-panel Kind toggle.
|
|
component KindRadio inherits TouchArea {
|
|
in property <string> label;
|
|
in property <bool> selected;
|
|
mouse-cursor: pointer;
|
|
horizontal-stretch: 0;
|
|
HorizontalLayout {
|
|
spacing: 6px;
|
|
Rectangle {
|
|
width: 18px;
|
|
height: 18px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
border-radius: 9px;
|
|
border-width: root.selected ? 0px : 1.5px;
|
|
border-color: Theme.text-muted;
|
|
background: root.selected ? Theme.accent : transparent;
|
|
Rectangle {
|
|
width: 7px;
|
|
height: 7px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
border-radius: 3.5px;
|
|
background: #ffffff;
|
|
visible: root.selected;
|
|
}
|
|
}
|
|
Text {
|
|
text: root.label;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
// One collection row in the picker list.
|
|
component AddPickRow inherits Rectangle {
|
|
in property <MyQbzAddRow> data;
|
|
in property <int> index;
|
|
in property <bool> busy;
|
|
callback clicked(string);
|
|
height: 52px;
|
|
border-radius: 8px;
|
|
opacity: root.busy ? 0.5 : 1.0;
|
|
background: ta.has-hover && !root.busy
|
|
? #ffffff16
|
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
spacing: 12px;
|
|
// Kind icon.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 32px;
|
|
height: 32px;
|
|
QbzIcon {
|
|
source: root.data.icon == "user"
|
|
? @image-url("../assets/icons/user.svg")
|
|
: (root.data.icon == "library-big"
|
|
? @image-url("../assets/icons/library-big.svg")
|
|
: @image-url("../assets/icons/cassette-tape.svg"));
|
|
width: 18px;
|
|
height: 18px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
}
|
|
// Name + meta.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: root.data.name;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
overflow: elide;
|
|
}
|
|
HorizontalLayout {
|
|
spacing: 6px;
|
|
Text {
|
|
text: root.data.meta;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
vertical-alignment: center;
|
|
}
|
|
// "Already added" hint (item_exists for all pending items).
|
|
if root.data.already-has: HorizontalLayout {
|
|
spacing: 3px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/circle-check-big.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
tint: Theme.accent;
|
|
}
|
|
}
|
|
Text {
|
|
text: @tr("Already added");
|
|
color: Theme.accent;
|
|
font-size: Typography.legal;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Right-aligned kind tag.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.data.kind-label;
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 1.2px;
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: root.busy ? default : pointer;
|
|
clicked => {
|
|
if (!root.busy) {
|
|
root.clicked(root.data.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The icon+label "+ Mixtapes / + Collections" picker-footer chips. Kept as a
|
|
// local secondary pill (rule #3 of the button audit: legitimately icon+label,
|
|
// so it does NOT collapse to the icon-only CircleAction). The leading icon sits
|
|
// in a centered [icon · gap · label] group — never jammed at the left edge.
|
|
component FooterIconButton inherits Rectangle {
|
|
in property <string> label;
|
|
in property <image> icon;
|
|
callback clicked();
|
|
height: 38px;
|
|
border-radius: Radius.sm;
|
|
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
|
border-width: 1px;
|
|
border-color: Theme.border-subtle;
|
|
HorizontalLayout {
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
spacing: 6px;
|
|
alignment: center;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: 14px;
|
|
height: 14px;
|
|
tint: Theme.text-primary;
|
|
}
|
|
}
|
|
Text {
|
|
text: root.label;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
export component AddToMixtapeModal inherits Rectangle {
|
|
visible: MyQbzAddState.open;
|
|
|
|
property <bool> create-mixtape: MyQbzAddState.create-kind == "mixtape";
|
|
property <bool> can-create: MyQbzAddState.create-name != "" && !MyQbzAddState.create-busy;
|
|
|
|
if MyQbzAddState.open: Rectangle {
|
|
background: #00000099; // rgba(0,0,0,0.6)
|
|
// Scrim — click outside to dismiss.
|
|
TouchArea {
|
|
mouse-cursor: default;
|
|
clicked => {
|
|
MyQbzAddActions.close();
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: Math.min(root.width - 80px, 480px);
|
|
height: Math.min(root.height - 120px, 560px);
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
border-radius: Radius.md;
|
|
background: Theme.surface-card;
|
|
border-width: 1px;
|
|
border-color: Theme.border-subtle;
|
|
drop-shadow-blur: 32px;
|
|
drop-shadow-color: #00000080;
|
|
// Swallow clicks so they don't reach the scrim.
|
|
TouchArea { }
|
|
|
|
VerticalLayout {
|
|
// === Header (always rendered) ===
|
|
Rectangle {
|
|
height: header-row.preferred-height + 36px;
|
|
HorizontalLayout {
|
|
padding-left: 20px;
|
|
padding-right: 20px;
|
|
padding-top: 20px;
|
|
padding-bottom: 16px;
|
|
header-row := HorizontalLayout {
|
|
VerticalLayout {
|
|
horizontal-stretch: 1;
|
|
spacing: 2px;
|
|
Text {
|
|
text: @tr("ADD TO MIXTAPE/COLLECTION");
|
|
color: Theme.text-muted;
|
|
font-size: 10px;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 1.5px;
|
|
}
|
|
Text {
|
|
text: MyQbzAddState.header-title;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.section;
|
|
font-weight: Typography.bold;
|
|
overflow: elide;
|
|
}
|
|
if MyQbzAddState.header-subtitle != "": Text {
|
|
text: MyQbzAddState.header-subtitle;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
close-x := TouchArea {
|
|
width: 28px;
|
|
height: 28px;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
MyQbzAddActions.close();
|
|
}
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/x.svg");
|
|
width: 17px;
|
|
height: 17px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Header bottom border.
|
|
Rectangle {
|
|
height: 1px;
|
|
y: parent.height - 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
}
|
|
|
|
// === Body: PICKER panel (default) ===
|
|
if !MyQbzAddState.creating: VerticalLayout {
|
|
// Search row.
|
|
Rectangle {
|
|
height: 46px;
|
|
HorizontalLayout {
|
|
padding-left: 20px;
|
|
padding-right: 20px;
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/search.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
search-input := LineEdit {
|
|
placeholder-text: @tr("Search…");
|
|
text <=> MyQbzAddState.search;
|
|
edited(t) => {
|
|
MyQbzAddActions.search-changed(t);
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
height: 1px;
|
|
y: parent.height - 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
}
|
|
|
|
// Loading spinner.
|
|
if MyQbzAddState.loading: HorizontalLayout {
|
|
alignment: center;
|
|
padding: 24px;
|
|
LoadingSpinner { size: 30px; }
|
|
}
|
|
// Empty state.
|
|
if !MyQbzAddState.loading && MyQbzAddState.rows.length == 0: Rectangle {
|
|
height: 80px;
|
|
Text {
|
|
text: MyQbzAddState.search != ""
|
|
? @tr("No matches.")
|
|
: @tr("No mixtapes or collections yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
|
|
// Collection list.
|
|
Flickable {
|
|
vertical-stretch: 1;
|
|
viewport-width: self.width;
|
|
viewport-height: list.preferred-height;
|
|
list := VerticalLayout {
|
|
padding: 8px;
|
|
spacing: 2px;
|
|
for row[i] in MyQbzAddState.rows: AddPickRow {
|
|
data: row;
|
|
index: i;
|
|
busy: MyQbzAddState.busy-id != "";
|
|
clicked(id) => {
|
|
MyQbzAddActions.pick(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Footer: create buttons.
|
|
Rectangle {
|
|
height: 60px;
|
|
background: Theme.surface-elevated;
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
HorizontalLayout {
|
|
padding: 12px;
|
|
spacing: 8px;
|
|
FooterIconButton {
|
|
horizontal-stretch: 1;
|
|
label: @tr("Mixtapes");
|
|
icon: @image-url("../assets/icons/plus.svg");
|
|
clicked => {
|
|
MyQbzAddActions.show-create("mixtape");
|
|
}
|
|
}
|
|
// Hidden for track/playlist payloads (collections hold
|
|
// whole albums only).
|
|
if !MyQbzAddState.restrict-to-mixtape: FooterIconButton {
|
|
horizontal-stretch: 1;
|
|
label: @tr("Collections");
|
|
icon: @image-url("../assets/icons/plus.svg");
|
|
clicked => {
|
|
MyQbzAddActions.show-create("collection");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// === Body: CREATE-NEW panel ===
|
|
if MyQbzAddState.creating: VerticalLayout {
|
|
padding: 20px;
|
|
spacing: 16px;
|
|
// Name.
|
|
VerticalLayout {
|
|
spacing: 8px;
|
|
Text {
|
|
text: @tr("NAME");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 1.5px;
|
|
}
|
|
create-name-input := LineEdit {
|
|
placeholder-text: root.create-mixtape ? "90s Cassettes" : "Jazz Library";
|
|
text <=> MyQbzAddState.create-name;
|
|
enabled: !MyQbzAddState.create-busy;
|
|
accepted => {
|
|
if (root.can-create) {
|
|
MyQbzAddActions.create-and-add();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Kind toggle.
|
|
VerticalLayout {
|
|
spacing: 8px;
|
|
Text {
|
|
text: @tr("KIND");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 1.5px;
|
|
}
|
|
HorizontalLayout {
|
|
spacing: 16px;
|
|
alignment: start;
|
|
KindRadio {
|
|
label: @tr("Mixtapes");
|
|
selected: root.create-mixtape;
|
|
clicked => {
|
|
if (!MyQbzAddState.create-busy) {
|
|
MyQbzAddState.create-kind = "mixtape";
|
|
}
|
|
}
|
|
}
|
|
KindRadio {
|
|
label: @tr("Collections");
|
|
selected: !root.create-mixtape;
|
|
clicked => {
|
|
if (!MyQbzAddState.create-busy) {
|
|
MyQbzAddState.create-kind = "collection";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Spacer pushes the footer down.
|
|
Rectangle { vertical-stretch: 1; }
|
|
// Footer: Back (secondary) + Create & Add (primary) —
|
|
// the app's shared labeled primitives.
|
|
HorizontalLayout {
|
|
alignment: end;
|
|
spacing: 8px;
|
|
SecondaryButton {
|
|
label: @tr("Back");
|
|
height: 38px;
|
|
enabled: !MyQbzAddState.create-busy;
|
|
clicked => {
|
|
MyQbzAddActions.create-back();
|
|
}
|
|
}
|
|
QbzPrimaryButton {
|
|
label: @tr("Create & Add");
|
|
height: 38px;
|
|
enabled: root.can-create;
|
|
clicked => {
|
|
MyQbzAddActions.create-and-add();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|