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);
|
||||
}
|
||||
Reference in new issue
Block a user