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
@@ -36,7 +36,9 @@ mod location_view;
|
||||
mod mix;
|
||||
mod musician;
|
||||
mod myqbz;
|
||||
mod myqbz_cover;
|
||||
mod myqbz_detail;
|
||||
mod myqbz_edit;
|
||||
mod myqbz_play;
|
||||
mod nav;
|
||||
mod play_history;
|
||||
@@ -2264,7 +2266,10 @@ fn wire_myqbz_detail(
|
||||
});
|
||||
}
|
||||
|
||||
// --- STILL-STUBBED hero/CTA actions (later slices) ------------------
|
||||
// --- STILL-STUBBED hero CTAs (DJ-mix modal + discography sync) -------
|
||||
// DJ-mix: the TrackMixModal is a later slice — keep the existing stub.
|
||||
// Sync: artist_discography has NO sync impl (spec §8) — no-op stub (the
|
||||
// hero button is shown only for artist_collection for Tauri parity).
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let log_stub = move |what: &str| {
|
||||
@@ -2273,19 +2278,169 @@ fn wire_myqbz_detail(
|
||||
.map(|w| w.global::<MyQbzDetailState>().get_id().to_string())
|
||||
.unwrap_or_default();
|
||||
log::info!(
|
||||
"[qbz-slint] myqbz_detail {what}({id}) — not yet wired (read-only slice)"
|
||||
"[qbz-slint] myqbz_detail {what}({id}) — not yet wired (later slice / no impl)"
|
||||
);
|
||||
};
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_dj_mix(move || s("dj-mix"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_edit(move || s("edit"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_delete(move || s("delete"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_sync(move || s("sync"));
|
||||
}
|
||||
|
||||
// --- Hero overflow (⋯) menu — open the edit modals (spec 12 §10/§11) ---
|
||||
// Rename / Edit description / Delete-confirm open the shared MyQbzEditModal
|
||||
// with the right mode + prefill; the mutations + reload run on submit.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<Act>().on_open_rename(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ds = w.global::<MyQbzDetailState>();
|
||||
let es = w.global::<MyQbzEditState>();
|
||||
es.set_mode("rename".into());
|
||||
es.set_name(ds.get_name());
|
||||
es.set_draft_name(ds.get_name());
|
||||
es.set_busy(false);
|
||||
es.set_open(true);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<Act>().on_open_description(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ds = w.global::<MyQbzDetailState>();
|
||||
let es = w.global::<MyQbzEditState>();
|
||||
es.set_mode("description".into());
|
||||
es.set_name(ds.get_name());
|
||||
es.set_draft_description(ds.get_description());
|
||||
es.set_busy(false);
|
||||
es.set_open(true);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<Act>().on_open_delete(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ds = w.global::<MyQbzDetailState>();
|
||||
let es = w.global::<MyQbzEditState>();
|
||||
es.set_mode("delete".into());
|
||||
es.set_name(ds.get_name());
|
||||
es.set_busy(false);
|
||||
es.set_open(true);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hero overflow — custom cover (set / remove) --------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_upload_cover(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
myqbz_cover::upload(weak.clone(), handle.clone(), image_cache.clone(), id);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_remove_cover(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
myqbz_cover::remove(weak.clone(), handle.clone(), image_cache.clone(), id);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hero overflow — play-mode toggle / convert kind ---------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_toggle_play_mode(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ds = w.global::<MyQbzDetailState>();
|
||||
let id = ds.get_id().to_string();
|
||||
let mode = ds.get_play_mode().to_string();
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
myqbz_edit::toggle_play_mode(weak.clone(), handle.clone(), image_cache.clone(), id, mode);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_convert_kind(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ds = w.global::<MyQbzDetailState>();
|
||||
let id = ds.get_id().to_string();
|
||||
let kind = ds.get_kind().to_string();
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
myqbz_edit::convert_kind(weak.clone(), handle.clone(), image_cache.clone(), id, kind);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Edit modals — submit (rename / description / delete) ----------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<MyQbzEditActions>().on_submit_rename(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
let name = w.global::<MyQbzEditState>().get_draft_name().to_string();
|
||||
myqbz_edit::rename(weak.clone(), handle.clone(), image_cache.clone(), id, name);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window
|
||||
.global::<MyQbzEditActions>()
|
||||
.on_submit_description(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
let desc = w.global::<MyQbzEditState>().get_draft_description().to_string();
|
||||
myqbz_edit::set_description(
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
id,
|
||||
desc,
|
||||
);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.global::<MyQbzEditActions>().on_confirm_delete(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
myqbz_edit::delete(weak.clone(), handle.clone(), id);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<MyQbzEditActions>().on_close(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let es = w.global::<MyQbzEditState>();
|
||||
es.set_open(false);
|
||||
es.set_mode("".into());
|
||||
es.set_busy(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Per-row PLAY (default) -----------------------------------------
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! My QBZ — custom-cover upload / remove (Phase-2 Slice 7).
|
||||
//!
|
||||
//! Mirrors Tauri's `v2_mixtape_upload_custom_cover` /
|
||||
//! `v2_mixtape_remove_custom_cover` (spec 40 §7) 1:1, and the album
|
||||
//! custom-cover convention (same artwork cache dir, same 1000×1000 Lanczos3
|
||||
//! resize, same failure-safe "persist before deleting the previous file"
|
||||
//! ordering):
|
||||
//!
|
||||
//! - **Upload:** validate the picked file's extension (png/jpg/jpeg/webp),
|
||||
//! read the previous `custom_artwork_path` (to delete after persist), decode
|
||||
//! + `resize(1000, 1000, Lanczos3)`, save as `mixtape_custom_{safe_id}_{epoch_secs}.jpg`
|
||||
//! in `qbz_library::get_artwork_cache_dir()`, persist via
|
||||
//! `repo::set_custom_artwork(Some(dest))`, then delete the previous file only
|
||||
//! if it differs.
|
||||
//! - **Remove:** read previous, `repo::set_custom_artwork(None)`, delete prev.
|
||||
//!
|
||||
//! Frontend-agnostic (ADR-005/006): the persistence is `qbz_mixtape::repo`
|
||||
//! reached directly through `crate::library_db::with_db`; no Tauri command
|
||||
//! wrappers. All blocking work (file decode/resize/IO + DB) runs on a
|
||||
//! `spawn_blocking` worker; the reload + toast hop back to the event loop.
|
||||
//!
|
||||
//! NOTE on webp: the workspace `image` crate is built with only the `jpeg` +
|
||||
//! `png` features, so a `.webp` source decodes to an error at runtime (the
|
||||
//! extension is accepted to match the Tauri picker filter, but the decode
|
||||
//! surfaces the "upload failed" toast). png/jpg/jpeg are the working formats.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use image::imageops::FilterType;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::artwork::ImageCache;
|
||||
use crate::AppWindow;
|
||||
|
||||
/// The four extensions the Tauri picker accepts (spec §7.1 step 1).
|
||||
const ALLOWED_EXTENSIONS: [&str; 4] = ["png", "jpg", "jpeg", "webp"];
|
||||
|
||||
/// Epoch SECONDS (NOT ms) — the custom-cover filename timestamp is in seconds
|
||||
/// (spec §1.6 / §7.1 step 4).
|
||||
fn epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Keep ascii-alphanumeric / `-` / `_`; everything else becomes `_` (spec
|
||||
/// §7.1 step 5). The collection id is a UUID, so this is normally a no-op.
|
||||
fn safe_id(id: &str) -> String {
|
||||
id.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the stored `custom_artwork_path` for a collection (used to delete the
|
||||
/// previous file after a new one persists). Runs synchronously via `with_db`.
|
||||
fn get_prev_path(id: &str) -> Option<String> {
|
||||
crate::library_db::with_db(|db| {
|
||||
Ok(db.with_connection(|conn| {
|
||||
qbz_mixtape::repo::get_custom_artwork(conn, id).unwrap_or(None)
|
||||
}))
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Persist `path` (or clear with None) into `custom_artwork_path`. Returns true
|
||||
/// on success. Runs synchronously via `with_db`.
|
||||
fn set_custom_artwork(id: &str, path: Option<&str>) -> bool {
|
||||
let id = id.to_string();
|
||||
let path = path.map(|p| p.to_string());
|
||||
crate::library_db::with_db(move |db| {
|
||||
db.with_connection(|conn| {
|
||||
qbz_mixtape::repo::set_custom_artwork(conn, &id, path.as_deref())
|
||||
})
|
||||
.map_err(|e| {
|
||||
qbz_library::LibraryError::Database(format!("set_custom_artwork failed: {e}"))
|
||||
})
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Decode `source`, resize to 1000×1000 (Lanczos3), and save as a JPEG at
|
||||
/// `dest`. Returns an error string on any failure (decode / resize / save).
|
||||
fn resize_and_save(source: &Path, dest: &Path) -> Result<(), String> {
|
||||
let img = image::open(source).map_err(|e| format!("decode failed: {e}"))?;
|
||||
let resized = img.resize(1000, 1000, FilterType::Lanczos3);
|
||||
resized
|
||||
.to_rgb8()
|
||||
.save_with_format(dest, image::ImageFormat::Jpeg)
|
||||
.map_err(|e| format!("save failed: {e}"))
|
||||
}
|
||||
|
||||
/// The blocking upload body (extension validation → resize/save → persist →
|
||||
/// delete-prev). Returns Ok(dest) or Err(reason). Mirrors the Tauri command
|
||||
/// step order exactly (persist BEFORE deleting the previous file, and only
|
||||
/// delete when it differs).
|
||||
fn do_upload(id: &str, source_path: &str) -> Result<String, String> {
|
||||
let source = PathBuf::from(source_path);
|
||||
if !source.exists() {
|
||||
return Err("source file not found".to_string());
|
||||
}
|
||||
// 1 — extension validation.
|
||||
let ext_ok = source
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| ALLOWED_EXTENSIONS.contains(&e.to_lowercase().as_str()))
|
||||
.unwrap_or(false);
|
||||
if !ext_ok {
|
||||
return Err("unsupported image type".to_string());
|
||||
}
|
||||
|
||||
// 2 — read previous path (to delete after persist).
|
||||
let prev = get_prev_path(id);
|
||||
|
||||
// 3-6 — build the destination filename in the shared artwork cache dir.
|
||||
let artwork_dir = qbz_library::get_artwork_cache_dir();
|
||||
let filename = format!("mixtape_custom_{}_{}.jpg", safe_id(id), epoch_secs());
|
||||
let dest = artwork_dir.join(&filename);
|
||||
|
||||
// 7 — decode + resize + save.
|
||||
resize_and_save(&source, &dest)?;
|
||||
|
||||
// 8 — persist the new path.
|
||||
let dest_str = dest.to_string_lossy().to_string();
|
||||
if !set_custom_artwork(id, Some(&dest_str)) {
|
||||
// Persist failed — clean up the orphan we just wrote, surface the error.
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
return Err("failed to save cover".to_string());
|
||||
}
|
||||
|
||||
// 9 — delete the previous file AFTER persist, only if it differs.
|
||||
if let Some(prev) = prev {
|
||||
if prev != dest_str {
|
||||
let _ = std::fs::remove_file(&prev);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(dest_str)
|
||||
}
|
||||
|
||||
/// The blocking remove body: read previous → clear → delete prev file.
|
||||
fn do_remove(id: &str) -> Result<(), String> {
|
||||
let prev = get_prev_path(id);
|
||||
if !set_custom_artwork(id, None) {
|
||||
return Err("failed to clear cover".to_string());
|
||||
}
|
||||
if let Some(prev) = prev {
|
||||
let _ = std::fs::remove_file(&prev);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hero overflow "Set custom cover": open the native image picker, then upload
|
||||
/// + resize + persist + reload. Toasts success/failure (spec §10 item 3).
|
||||
pub fn upload(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
handle.clone().spawn(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_title("Choose a cover image")
|
||||
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return; // user cancelled — no toast.
|
||||
};
|
||||
let source = file.path().to_string_lossy().to_string();
|
||||
|
||||
let upload_id = id.clone();
|
||||
let result = tokio::task::spawn_blocking(move || do_upload(&upload_id, &source))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("upload task panicked: {e}")));
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
crate::toast::success_weak(&weak, "Cover updated");
|
||||
reload(weak.clone(), handle.clone(), image_cache.clone(), id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] myqbz_cover upload failed: {e}");
|
||||
crate::toast::error_weak(&weak, "Failed to upload cover");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Hero overflow "Remove custom cover": clear + delete the file + reload.
|
||||
pub fn remove(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
handle.clone().spawn(async move {
|
||||
let remove_id = id.clone();
|
||||
let result = tokio::task::spawn_blocking(move || do_remove(&remove_id))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("remove task panicked: {e}")));
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
crate::toast::success_weak(&weak, "Cover removed");
|
||||
reload(weak.clone(), handle.clone(), image_cache.clone(), id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] myqbz_cover remove failed: {e}");
|
||||
crate::toast::error_weak(&weak, "Failed to remove cover");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reload the open detail view so the hero reflects the new cover. Re-runs the
|
||||
/// detail navigator's load/apply/artwork path for the same id (Tauri's
|
||||
/// "-> reload"). The `set_view` inside `navigate` is harmless (we're already on
|
||||
/// the detail view).
|
||||
fn reload(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let _ = &w; // keep the closure's capture explicit.
|
||||
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! My QBZ — collection detail EDIT operations (Phase-2 Slice 7).
|
||||
//!
|
||||
//! Wires the hero overflow (⋯) menu + the Rename / Description / Delete-confirm
|
||||
//! modals to the shared `qbz_mixtape::repo` setters, reached directly through
|
||||
//! `crate::library_db::with_db` (ADR-005/006 — no Tauri command wrappers). Each
|
||||
//! mutation mirrors its Tauri command (spec 40 §3.5/§3.6) and then RELOADS the
|
||||
//! open detail view so the hero + state reflect the change (Tauri's
|
||||
//! "-> reload"):
|
||||
//!
|
||||
//! - **Rename** (`v2_rename_mixtape_collection`): trim; empty -> no-op.
|
||||
//! - **Description** (`v2_set_mixtape_description`): empty -> NULL (clear).
|
||||
//! - **Play-mode toggle** (`v2_set_mixtape_play_mode`): in_order <-> album_shuffle.
|
||||
//! - **Convert kind** (`v2_set_mixtape_kind`): mixtape <-> collection; the repo
|
||||
//! REJECTS any artist_collection conversion -> the "Cannot convert this kind"
|
||||
//! toast. Success -> "Converted".
|
||||
//! - **Delete** (`v2_delete_mixtape_collection`, CASCADE): navigate BACK (which
|
||||
//! re-applies the previous grid entry, dropping the deleted row) on success;
|
||||
//! "Failed to delete" toast on error.
|
||||
//!
|
||||
//! All DB work runs synchronously inside `with_db` on a `spawn_blocking` worker
|
||||
//! (no `&Connection` crosses an `.await`); the reload + toast hop back to the
|
||||
//! event loop.
|
||||
|
||||
use qbz_models::mixtape::{CollectionKind, CollectionPlayMode};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::artwork::ImageCache;
|
||||
use crate::{AppWindow, MyQbzEditState, NavState};
|
||||
|
||||
// ──────────────────────────── DB write helpers ────────────────────────
|
||||
|
||||
/// Run a repo mutation that returns `rusqlite::Result<()>` against the per-user
|
||||
/// library.db. Returns `Ok(())` on success, `Err(message)` on any failure
|
||||
/// (DB unavailable or the repo error). Synchronous (`with_db`).
|
||||
fn with_repo<F>(f: F) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(&rusqlite::Connection) -> rusqlite::Result<()> + Send,
|
||||
{
|
||||
match crate::library_db::with_db(|db| Ok(db.with_connection(f))) {
|
||||
Some(Ok(())) => Ok(()),
|
||||
Some(Err(e)) => Err(e.to_string()),
|
||||
None => Err("library database unavailable".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── reload after mutation ───────────────────
|
||||
|
||||
/// Reload the open detail view for `id` (Tauri's "-> reload") so the hero +
|
||||
/// toolbar reflect the mutation. Re-runs the detail navigator's
|
||||
/// load/apply/artwork path; the inner `set_view` is harmless (already there).
|
||||
fn reload(
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
image_cache: &ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────── public entry points ─────────────────────
|
||||
|
||||
/// Rename modal submit: trim the draft; empty -> close without writing. Else
|
||||
/// `repo::rename_collection` -> reload -> close. Caps at 80 chars (Tauri
|
||||
/// `<input maxlength=80>`).
|
||||
pub fn rename(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
raw_name: String,
|
||||
) {
|
||||
let name: String = raw_name.trim().chars().take(80).collect();
|
||||
if id.is_empty() || name.is_empty() {
|
||||
close_modal(&weak);
|
||||
return;
|
||||
}
|
||||
set_busy(&weak, true);
|
||||
handle.clone().spawn(async move {
|
||||
let write_id = id.clone();
|
||||
let write_name = name.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
with_repo(|conn| qbz_mixtape::repo::rename_collection(conn, &write_id, &write_name))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("rename task panicked: {e}")));
|
||||
|
||||
finish(&weak, &handle, &image_cache, id, result, None, "Failed to rename");
|
||||
});
|
||||
}
|
||||
|
||||
/// Description modal submit: trimmed empty -> NULL (clear). Else set it.
|
||||
/// `repo::set_description` -> reload -> close.
|
||||
pub fn set_description(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
raw_description: String,
|
||||
) {
|
||||
if id.is_empty() {
|
||||
close_modal(&weak);
|
||||
return;
|
||||
}
|
||||
let trimmed = raw_description.trim().to_string();
|
||||
let desc: Option<String> = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||
set_busy(&weak, true);
|
||||
handle.clone().spawn(async move {
|
||||
let write_id = id.clone();
|
||||
let write_desc = desc.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
with_repo(|conn| {
|
||||
qbz_mixtape::repo::set_description(conn, &write_id, write_desc.as_deref())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("description task panicked: {e}")));
|
||||
|
||||
finish(&weak, &handle, &image_cache, id, result, None, "Failed to save description");
|
||||
});
|
||||
}
|
||||
|
||||
/// Hero overflow play-mode toggle: flip in_order <-> album_shuffle. Reads the
|
||||
/// current mode from `MyQbzDetailState.play_mode`, persists the OTHER, reloads.
|
||||
pub fn toggle_play_mode(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
current_mode: String,
|
||||
) {
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let next = if current_mode == "in_order" {
|
||||
CollectionPlayMode::AlbumShuffle
|
||||
} else {
|
||||
CollectionPlayMode::InOrder
|
||||
};
|
||||
handle.clone().spawn(async move {
|
||||
let write_id = id.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
with_repo(|conn| qbz_mixtape::repo::set_play_mode(conn, &write_id, next))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("play-mode task panicked: {e}")));
|
||||
|
||||
finish(&weak, &handle, &image_cache, id, result, None, "Failed to change play mode");
|
||||
});
|
||||
}
|
||||
|
||||
/// Hero overflow convert-kind: flip mixtape <-> collection. The repo rejects
|
||||
/// any artist_collection conversion -> "Cannot convert this kind"; success ->
|
||||
/// "Converted". Reads the current kind from `MyQbzDetailState.kind`.
|
||||
pub fn convert_kind(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
current_kind: String,
|
||||
) {
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
// artist_collection is non-convertible (the menu item is hidden for it, but
|
||||
// guard here too).
|
||||
let next = match current_kind.as_str() {
|
||||
"mixtape" => CollectionKind::Collection,
|
||||
"collection" => CollectionKind::Mixtape,
|
||||
_ => {
|
||||
crate::toast::error_weak(&weak, "Cannot convert this kind");
|
||||
return;
|
||||
}
|
||||
};
|
||||
handle.clone().spawn(async move {
|
||||
let write_id = id.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
with_repo(|conn| qbz_mixtape::repo::set_kind(conn, &write_id, next))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("convert-kind task panicked: {e}")));
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
crate::toast::success_weak(&weak, "Converted");
|
||||
reload(&weak, &handle, &image_cache, id);
|
||||
}
|
||||
Err(_) => {
|
||||
// The repo's only rejection here is the artist_collection guard.
|
||||
crate::toast::error_weak(&weak, "Cannot convert this kind");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete-confirm: `repo::delete_collection` (CASCADE) -> navigate BACK (which
|
||||
/// re-applies the previous grid entry, so the deleted row is gone) -> close.
|
||||
/// "Failed to delete" toast on error.
|
||||
pub fn delete(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
id: String,
|
||||
) {
|
||||
if id.is_empty() {
|
||||
close_modal(&weak);
|
||||
return;
|
||||
}
|
||||
set_busy(&weak, true);
|
||||
handle.spawn(async move {
|
||||
let write_id = id.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
with_repo(|conn| qbz_mixtape::repo::delete_collection(conn, &write_id))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("delete task panicked: {e}")));
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let es = w.global::<MyQbzEditState>();
|
||||
es.set_busy(false);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
es.set_open(false);
|
||||
es.set_mode("".into());
|
||||
// Navigate back: re-applies the previous grid entry, which
|
||||
// re-lists collections from the DB (the deleted one is gone).
|
||||
w.global::<NavState>().invoke_request_back();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] myqbz_edit delete failed: {e}");
|
||||
crate::toast::error(&w, "Failed to delete");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────── modal-state helpers ─────────────────────
|
||||
|
||||
/// On a successful mutation: close the modal (if any), reload, and (when given)
|
||||
/// toast a success message. On failure: clear busy + toast `err_msg`.
|
||||
fn finish(
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
image_cache: &ImageCache,
|
||||
id: String,
|
||||
result: Result<(), String>,
|
||||
success_toast: Option<&'static str>,
|
||||
err_msg: &'static str,
|
||||
) {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if let Some(msg) = success_toast {
|
||||
crate::toast::success_weak(weak, msg);
|
||||
}
|
||||
close_modal(weak);
|
||||
reload(weak, handle, image_cache, id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] myqbz_edit mutation failed: {e}");
|
||||
set_busy(weak, false);
|
||||
crate::toast::error_weak(weak, err_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the edit modal (clears mode + busy). UI thread hop.
|
||||
fn close_modal(weak: &slint::Weak<AppWindow>) {
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
let es = w.global::<MyQbzEditState>();
|
||||
es.set_open(false);
|
||||
es.set_mode("".into());
|
||||
es.set_busy(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the modal busy flag (disables submit) from any thread.
|
||||
fn set_busy(weak: &slint::Weak<AppWindow>, busy: bool) {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
w.global::<MyQbzEditState>().set_busy(busy);
|
||||
});
|
||||
}
|
||||
@@ -108,3 +108,8 @@ pub fn error_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
|
||||
pub fn success_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
|
||||
show_weak(weak, message, ToastKind::Success);
|
||||
}
|
||||
|
||||
/// Info toast from any thread.
|
||||
pub fn info_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
|
||||
show_weak(weak, message, ToastKind::Info);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
||||
import { Toast } from "primitives/Toast.slint";
|
||||
|
||||
// Re-export the state globals so the Rust layer can populate them.
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
|
||||
|
||||
// Which top-level screen is shown. The app starts on `splash` while it
|
||||
// restores a saved session, then resolves to `shell` or `login`.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint";
|
||||
import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
|
||||
import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
|
||||
import { CreateMyQbzModal } from "../myqbz/CreateMyQbzModal.slint";
|
||||
import { MyQbzEditModal } from "../myqbz/MyQbzEditModal.slint";
|
||||
import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
|
||||
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
|
||||
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
|
||||
@@ -385,6 +386,9 @@ export component AppShell inherits Rectangle {
|
||||
// Global "Create Mixtape / Collection" modal (My QBZ "+ New").
|
||||
CreateMyQbzModal { }
|
||||
|
||||
// Global My QBZ detail edit modals (rename / description / delete-confirm).
|
||||
MyQbzEditModal { }
|
||||
|
||||
// Ephemeral "already playing" add-vs-replace choice.
|
||||
EphemeralPlayChoiceModal { }
|
||||
|
||||
|
||||
@@ -1603,14 +1603,20 @@ export global MyQbzDetailActions {
|
||||
callback open-item(string /* source */, string /* item-type */, string /* source-item-id */);
|
||||
// Open an item's artist page by name (qobuz items only).
|
||||
callback open-artist(string /* source */, string /* artist-name */);
|
||||
// --- READ-ONLY STUBS (logged; not yet wired) --------------------------
|
||||
// Hero CTAs.
|
||||
// --- Hero CTAs --------------------------------------------------------
|
||||
callback play-all();
|
||||
callback shuffle();
|
||||
callback dj-mix();
|
||||
callback edit(); // overflow: rename / description / cover / convert
|
||||
callback delete();
|
||||
callback sync();
|
||||
callback dj-mix(); // STUB (the DJ-mix modal is a later slice).
|
||||
callback sync(); // STUB / no-op (no discography sync impl, spec §8).
|
||||
// --- Hero overflow (⋯) menu (spec 12 §10) -----------------------------
|
||||
// Each opens the matching modal or fires the matching mutation + reload.
|
||||
callback open-rename(); // -> Rename modal (prefilled with name).
|
||||
callback open-description(); // -> Description modal (prefilled).
|
||||
callback upload-cover(); // native file picker -> resize -> set + reload.
|
||||
callback remove-cover(); // clear custom cover -> reload.
|
||||
callback toggle-play-mode(); // in_order <-> album_shuffle -> reload.
|
||||
callback convert-kind(); // mixtape <-> collection -> reload + toast.
|
||||
callback open-delete(); // -> Delete-confirm modal.
|
||||
// Per-row affordances.
|
||||
callback play-item(string /* source-item-id */);
|
||||
callback item-action(string /* source-item-id */, string /* action */);
|
||||
@@ -1618,6 +1624,31 @@ export global MyQbzDetailActions {
|
||||
callback toggle-item-select(int /* position */);
|
||||
}
|
||||
|
||||
// ──────────────────────────── MyQBZ edit modals ───────────────────────
|
||||
// The Rename / Description / Delete-confirm modals (spec 12 §11), driven
|
||||
// from the hero overflow (⋯) menu. One global backs all three (only one is
|
||||
// ever open at a time). `busy` disables submit while the mutation runs.
|
||||
export global MyQbzEditState {
|
||||
// "" = closed; "rename" | "description" | "delete".
|
||||
in-out property <string> mode: "";
|
||||
in property <bool> open: false;
|
||||
// The collection name (delete-confirm body interpolation + rename prefill).
|
||||
in property <string> name;
|
||||
// Rename: prefilled with the collection name; submit trims.
|
||||
in-out property <string> draft-name;
|
||||
// Description: prefilled with the current description ("" clears to null).
|
||||
in-out property <string> draft-description;
|
||||
// Disables submit while a mutation + reload is in flight.
|
||||
in-out property <bool> busy: false;
|
||||
}
|
||||
|
||||
export global MyQbzEditActions {
|
||||
callback close();
|
||||
callback submit-rename(); // trim; rename_collection -> reload -> close.
|
||||
callback submit-description(); // empty -> null; set_description -> reload.
|
||||
callback confirm-delete(); // delete_collection -> nav back + cleanup.
|
||||
}
|
||||
|
||||
// Folder editor modal — create + edit (icon preset, solid color, custom
|
||||
// image, hidden toggle, delete). Ports Tauri's FolderEditModal. Gradient
|
||||
// color presets are intentionally omitted (Slint brush limitation).
|
||||
|
||||
Reference in new issue
Block a user