feat(myqbz): detail edit — overflow menu, modals, play-mode/kind/cover
Phase-2 Slice 7. The hero overflow (⋯) menu and the edit operations: - Hero action row corrected to spec §3.6: exactly 4 controls (Play primary, filled white disc + black glyph; Shuffle; DJ-mix [turntable glyph]; ⋯ overflow) -- replaces the S3 flat-circles deviation. - ⋯ overflow menu (spec §10): Rename, Edit description, Upload/Clear custom cover, play-mode toggle (label = the OTHER mode), Convert kind (hidden for artist_collection), Delete (destructive). - ui/myqbz/MyQbzEditModal.slint: Rename / Description / Delete-confirm modals (mirrors EditPlaylistModal chrome). - src/myqbz_edit.rs: rename / set_description / toggle play_mode / convert kind / delete via with_db -> with_connection -> qbz_mixtape::repo::*; each reloads the detail, delete navigates back. "Converted"/"Cannot convert this kind" toasts. - src/myqbz_cover.rs: custom-cover upload (rfd picker, ext validate, resize 1000x1000 Lanczos3, save to artwork cache, persist-before-delete-prev) + remove. dj-mix + sync stay stubs (later slices). Authoritative copy (Upload cover / Clear custom cover / In order / double-quote delete). Backend crates untouched. cargo check -p qbz-slint green. cc
This commit is contained in:
10 files changed
+981
-43
No files matched your search
@@ -47,8 +47,10 @@ component ActionCircle inherits Rectangle {
|
||||
height: root.diameter;
|
||||
border-radius: root.diameter / 2;
|
||||
opacity: root.enabled ? 1.0 : 0.4;
|
||||
background: ac-ta.has-hover ? Theme.surface-hover : transparent;
|
||||
border-width: 1px;
|
||||
background: root.primary
|
||||
? (ac-ta.has-hover ? #ffffffd6 : #ffffff)
|
||||
: (ac-ta.has-hover ? Theme.surface-hover : transparent);
|
||||
border-width: root.primary ? 0px : 1px;
|
||||
border-color: ac-ta.has-hover ? Theme.text-disabled : Theme.border-subtle;
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
@@ -56,7 +58,7 @@ component ActionCircle inherits Rectangle {
|
||||
height: root.glyph;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: ac-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
tint: root.primary ? #000000 : (ac-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
|
||||
}
|
||||
ac-ta := TouchArea {
|
||||
mouse-cursor: root.enabled ? pointer : default;
|
||||
@@ -862,10 +864,15 @@ export component MixtapeDetailView inherits Rectangle {
|
||||
font-size: 13px;
|
||||
}
|
||||
Rectangle { height: 4px; }
|
||||
// Action row (play / shuffle / dj-mix / edit / delete / sync).
|
||||
// Hero action row (spec 12 §3.6): EXACTLY four controls from
|
||||
// the shared circular-icon-button family — Play (primary),
|
||||
// Shuffle, DJ-mix, and the Overflow (⋯) menu trigger. Edit /
|
||||
// delete / cover / convert / play-mode now live in the ⋯ menu
|
||||
// (spec §10), NOT as flat circles.
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
// (1) Play — primary.
|
||||
ActionCircle {
|
||||
icon: @image-url("../assets/icons/play-fill.svg");
|
||||
primary: true;
|
||||
@@ -874,6 +881,7 @@ export component MixtapeDetailView inherits Rectangle {
|
||||
enabled: MyQbzDetailState.item-count > 0;
|
||||
clicked => { MyQbzDetailActions.play-all(); }
|
||||
}
|
||||
// (2) Shuffle.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ActionCircle {
|
||||
@@ -884,43 +892,95 @@ export component MixtapeDetailView inherits Rectangle {
|
||||
clicked => { MyQbzDetailActions.shuffle(); }
|
||||
}
|
||||
}
|
||||
// (3) DJ-mix (DjIcon) — STUB click (modal is a later slice).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ActionCircle {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
icon: @image-url("../assets/icons/turntable.svg");
|
||||
diameter: 40px;
|
||||
glyph: 18px;
|
||||
enabled: MyQbzDetailState.item-count > 0;
|
||||
clicked => { MyQbzDetailActions.dj-mix(); }
|
||||
}
|
||||
}
|
||||
// (4) Overflow (⋯) — toggles the hero overflow menu.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ActionCircle {
|
||||
icon: @image-url("../assets/icons/pen-line.svg");
|
||||
diameter: 40px;
|
||||
glyph: 18px;
|
||||
clicked => { MyQbzDetailActions.edit(); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ActionCircle {
|
||||
icon: @image-url("../assets/icons/trash-2.svg");
|
||||
diameter: 40px;
|
||||
glyph: 18px;
|
||||
clicked => { MyQbzDetailActions.delete(); }
|
||||
}
|
||||
}
|
||||
// Sync — artist collections only (parity with Tauri's
|
||||
// discography sync surface; stub here).
|
||||
if MyQbzDetailState.kind == "artist_collection": VerticalLayout {
|
||||
alignment: center;
|
||||
ActionCircle {
|
||||
icon: @image-url("../assets/icons/refresh-cw.svg");
|
||||
diameter: 40px;
|
||||
glyph: 18px;
|
||||
clicked => { MyQbzDetailActions.sync(); }
|
||||
overflow-anchor := Rectangle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
ActionCircle {
|
||||
icon: @image-url("../assets/icons/ellipsis.svg");
|
||||
diameter: 40px;
|
||||
glyph: 18px;
|
||||
clicked => { overflow-menu.show(); }
|
||||
}
|
||||
overflow-menu := PopupWindow {
|
||||
x: 0;
|
||||
y: 44px;
|
||||
width: 220px;
|
||||
Rectangle {
|
||||
background: Theme.surface-main;
|
||||
border-width: 1px;
|
||||
border-color: Theme.surface-elevated;
|
||||
border-radius: 8px;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-color: #00000066;
|
||||
VerticalLayout {
|
||||
padding: 4px;
|
||||
spacing: 1px;
|
||||
// 1 — Rename.
|
||||
RowMenuItem {
|
||||
icon: @image-url("../assets/icons/pen-line.svg");
|
||||
label: @tr("Rename");
|
||||
chosen => { MyQbzDetailActions.open-rename(); }
|
||||
}
|
||||
// 2 — Edit description.
|
||||
RowMenuItem {
|
||||
icon: @image-url("../assets/icons/pen-line.svg");
|
||||
label: @tr("Edit description");
|
||||
chosen => { MyQbzDetailActions.open-description(); }
|
||||
}
|
||||
// 3 — Set custom cover.
|
||||
RowMenuItem {
|
||||
icon: @image-url("../assets/icons/image.svg");
|
||||
label: @tr("Upload cover");
|
||||
chosen => { MyQbzDetailActions.upload-cover(); }
|
||||
}
|
||||
// 4 — Remove custom cover (only when set).
|
||||
if MyQbzDetailState.has-custom-cover: RowMenuItem {
|
||||
icon: @image-url("../assets/icons/x.svg");
|
||||
label: @tr("Clear custom cover");
|
||||
chosen => { MyQbzDetailActions.remove-cover(); }
|
||||
}
|
||||
Rectangle { height: 1px; background: Theme.surface-elevated; }
|
||||
// 5 — Play-mode toggle (label = the OTHER mode).
|
||||
RowMenuItem {
|
||||
icon: @image-url("../assets/icons/shuffle.svg");
|
||||
label: MyQbzDetailState.play-mode == "in_order"
|
||||
? @tr("Album shuffle")
|
||||
: @tr("In order");
|
||||
chosen => { MyQbzDetailActions.toggle-play-mode(); }
|
||||
}
|
||||
// 6 — Convert kind (hidden for artist collections).
|
||||
if MyQbzDetailState.kind != "artist_collection": RowMenuItem {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
label: MyQbzDetailState.kind == "mixtape"
|
||||
? @tr("Convert to Collection")
|
||||
: @tr("Convert to Mixtape");
|
||||
chosen => { MyQbzDetailActions.convert-kind(); }
|
||||
}
|
||||
Rectangle { height: 1px; background: Theme.surface-elevated; }
|
||||
// 7 — Delete (destructive).
|
||||
RowMenuItem {
|
||||
icon: @image-url("../assets/icons/trash-2.svg");
|
||||
label: @tr("Delete");
|
||||
destructive: true;
|
||||
chosen => { MyQbzDetailActions.open-delete(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// My QBZ — Rename / Description / Delete-confirm modals (spec 12 §11).
|
||||
//
|
||||
// Driven from the hero overflow (⋯) menu. A single global (MyQbzEditState)
|
||||
// backs all three; `mode` ("rename" | "description" | "delete") selects which
|
||||
// body renders. Chrome mirrors EditPlaylistModal / CreateMyQbzModal: a 420px
|
||||
// panel, h2 title (18px bold), the m-input field family, and an m-footer with
|
||||
// secondary Cancel + a primary/destructive submit. Backdrop click + Cancel
|
||||
// close without committing; Enter submits the rename. Submit is disabled while
|
||||
// busy or (rename) the trimmed name is blank.
|
||||
//
|
||||
// Per spec §23 the Cancel / Save button labels are HARDCODED in the Tauri
|
||||
// source; we keep them @tr here (DQ2 — translate hardcoded UI strings on port).
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { MyQbzEditState, MyQbzEditActions } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LineEdit, TextEdit } from "std-widgets.slint";
|
||||
|
||||
export component MyQbzEditModal inherits Rectangle {
|
||||
visible: MyQbzEditState.open;
|
||||
|
||||
property <bool> is-rename: MyQbzEditState.mode == "rename";
|
||||
property <bool> is-description: MyQbzEditState.mode == "description";
|
||||
property <bool> is-delete: MyQbzEditState.mode == "delete";
|
||||
// Rename submit needs a non-blank trimmed name; description/delete always
|
||||
// allowed (description "" clears to null). Trim check approximated by
|
||||
// comparing against a stripped copy is not available in Slint, so we gate
|
||||
// on a non-empty raw string for rename (the Rust side trims again).
|
||||
property <bool> can-submit:
|
||||
MyQbzEditState.busy ? false
|
||||
: (root.is-rename ? (MyQbzEditState.draft-name != "") : true);
|
||||
|
||||
if MyQbzEditState.open: Rectangle {
|
||||
background: #000000bf;
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
MyQbzEditActions.close();
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 80px, 420px);
|
||||
height: panel.preferred-height;
|
||||
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 on the panel itself (so they don't hit the
|
||||
// backdrop's close TouchArea below).
|
||||
TouchArea { }
|
||||
|
||||
panel := VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 16px;
|
||||
|
||||
// --- Title (h2) ------------------------------------------
|
||||
Text {
|
||||
text: root.is-rename
|
||||
? @tr("Rename")
|
||||
: (root.is-description ? @tr("Edit description") : @tr("Delete"));
|
||||
color: Theme.text-primary;
|
||||
font-size: 18px;
|
||||
font-weight: Typography.bold;
|
||||
}
|
||||
|
||||
// --- Body: rename (name input) ---------------------------
|
||||
if root.is-rename: rename-input := LineEdit {
|
||||
text <=> MyQbzEditState.draft-name;
|
||||
accepted => {
|
||||
if (root.can-submit) {
|
||||
MyQbzEditActions.submit-rename();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Body: description (textarea) ------------------------
|
||||
if root.is-description: desc-input := TextEdit {
|
||||
height: 96px;
|
||||
wrap: word-wrap;
|
||||
text <=> MyQbzEditState.draft-description;
|
||||
}
|
||||
|
||||
// --- Body: delete-confirm (prompt) -----------------------
|
||||
if root.is-delete: Text {
|
||||
// Delete "{name}"? This cannot be undone. — interpolated
|
||||
// with the collection name (spec §11.3 deleteConfirm, double quotes).
|
||||
text: @tr("Delete \"{}\"? This cannot be undone.", MyQbzEditState.name);
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// --- Footer (m-footer) -----------------------------------
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
spacing: 8px;
|
||||
|
||||
// Cancel (secondary).
|
||||
Rectangle {
|
||||
width: cancel-label.preferred-width + 32px;
|
||||
height: 38px;
|
||||
border-radius: Radius.sm;
|
||||
background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
cancel-label := Text {
|
||||
text: @tr("Cancel");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
cancel-ta := TouchArea {
|
||||
mouse-cursor: MyQbzEditState.busy ? default : pointer;
|
||||
clicked => {
|
||||
if (!MyQbzEditState.busy) {
|
||||
MyQbzEditActions.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit — primary (rename/description) or destructive
|
||||
// (delete).
|
||||
Rectangle {
|
||||
width: submit-label.preferred-width + 40px;
|
||||
height: 38px;
|
||||
border-radius: Radius.sm;
|
||||
opacity: root.can-submit ? 1.0 : 0.5;
|
||||
background: root.is-delete
|
||||
? (submit-ta.has-hover ? #e57373d9 : #e57373)
|
||||
: (submit-ta.has-hover ? #ffffffd6 : Theme.accent);
|
||||
submit-label := Text {
|
||||
text: root.is-delete ? @tr("Delete") : @tr("Save");
|
||||
color: #ffffff;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
submit-ta := TouchArea {
|
||||
mouse-cursor: root.can-submit ? pointer : default;
|
||||
clicked => {
|
||||
if (root.can-submit) {
|
||||
if (root.is-rename) {
|
||||
MyQbzEditActions.submit-rename();
|
||||
} else if (root.is-description) {
|
||||
MyQbzEditActions.submit-description();
|
||||
} else {
|
||||
MyQbzEditActions.confirm-delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user