feat(myqbz): create-new Mixtape/Collection modal + wiring
Phase-2 Slice 4. The grid "+ New" buttons open a create modal (name + kind radio: mixtape/collection, artist_collection excluded), create via the shared crate, and drop the user into the new collection's detail (1:1 with Tauri's submitCreateModal -> openMixtapeDetail). The new row prepends to its kind. - ui/myqbz/CreateMyQbzModal.slint (mirrors CreatePlaylistModal/CreateFolderModal idiom; spec 30 create-modal geometry). - MyQbzCreateState/MyQbzCreateActions in state.slint; mounted in AppShell. - src/myqbz.rs create_collection via with_db -> with_connection -> qbz_mixtape::repo::create_collection (Manual, 80-char cap). - Un-stubbed on_create_mixtape/on_create_collection; trim + busy guards; toast. Backend crates untouched. cargo check -p qbz-slint green. cc
This commit is contained in:
6 files changed
+368
-4
No files matched your search
@@ -1886,15 +1886,98 @@ fn wire_myqbz(
|
||||
});
|
||||
}
|
||||
|
||||
// --- Create CTAs: STUBS (read-only boundary) ------------------------
|
||||
// --- Create CTAs: open the create modal pre-set to the right kind ---
|
||||
// The kind is fixed by which grid opened it (Mixtapes -> mixtape;
|
||||
// Collections -> collection); the modal radio can flip it. Mirrors
|
||||
// Tauri's `openCreateModal(kind)`.
|
||||
fn open_create_modal(window: &AppWindow, kind: &str) {
|
||||
let st = window.global::<MyQbzCreateState>();
|
||||
st.set_kind(kind.into());
|
||||
st.set_name("".into());
|
||||
st.set_creating(false);
|
||||
st.set_open(true);
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<MyQbzActions>().on_create_mixtape(move || {
|
||||
log::info!("[qbz-slint] myqbz create-mixtape — not yet wired (read-only slice)");
|
||||
if let Some(w) = weak.upgrade() {
|
||||
open_create_modal(&w, "mixtape");
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<MyQbzActions>().on_create_collection(move || {
|
||||
log::info!("[qbz-slint] myqbz create-collection — not yet wired (read-only slice)");
|
||||
if let Some(w) = weak.upgrade() {
|
||||
open_create_modal(&w, "collection");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Create modal: cancel / submit ----------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<MyQbzCreateActions>().on_close(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<MyQbzCreateState>().set_open(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Submit: create the collection on a blocking worker, then close the
|
||||
// modal + drop the user straight into the new collection's detail
|
||||
// view (mirrors Tauri's `submitCreateModal` → `openMixtapeDetail`).
|
||||
// The grid is reloaded from the DB on back-nav, so the prepended row
|
||||
// shows up there.
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<MyQbzCreateActions>().on_submit(move || {
|
||||
let Some(w) = weak.upgrade() else { return; };
|
||||
let st = w.global::<MyQbzCreateState>();
|
||||
let name = st.get_name().to_string();
|
||||
if name.trim().is_empty() || st.get_creating() {
|
||||
return;
|
||||
}
|
||||
let kind = myqbz::kind_from_str(st.get_kind().as_str());
|
||||
st.set_creating(true);
|
||||
|
||||
let weak = weak.clone();
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
handle.clone().spawn(async move {
|
||||
let nm = name.trim().to_string();
|
||||
let created =
|
||||
tokio::task::spawn_blocking(move || myqbz::create_collection(kind, &nm))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let weak2 = weak.clone();
|
||||
let handle2 = handle.clone();
|
||||
let image_cache2 = image_cache.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let st = w.global::<MyQbzCreateState>();
|
||||
st.set_creating(false);
|
||||
match created {
|
||||
Some(c) => {
|
||||
st.set_open(false);
|
||||
st.set_name("".into());
|
||||
// Drop into the new collection's detail view.
|
||||
nav::record(nav::NavEntry::MixtapeDetail(c.id.clone()));
|
||||
myqbz_detail::navigate(
|
||||
weak2.clone(),
|
||||
handle2.clone(),
|
||||
image_cache2.clone(),
|
||||
c.id.clone(),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
crate::toast::error(&w, "Failed to create collection");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,45 @@ pub fn list_collections(kind: Option<CollectionKind>) -> Vec<MixtapeCollection>
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Create a new manual collection of `kind` named `name` in the per-user
|
||||
/// library.db and return the created row (mirrors the store's `createCollection`
|
||||
/// → `v2_create_mixtape_collection`). The repo shifts existing positions down
|
||||
/// so the new row lands at the TOP of its kind (position 0). `source_type` is
|
||||
/// always `manual` and `source_ref`/`description` are null — artist_collections
|
||||
/// are created via the Discography Builder, not this path. Runs on a blocking
|
||||
/// thread; returns None if the DB is unavailable or the insert fails (logged).
|
||||
pub fn create_collection(kind: CollectionKind, name: &str) -> Option<MixtapeCollection> {
|
||||
// Cap at 80 chars (Tauri's `<input maxlength="80">`), counting Unicode
|
||||
// scalar values to match HTML maxlength semantics.
|
||||
let name: String = name.chars().take(80).collect();
|
||||
crate::library_db::with_db(move |db| {
|
||||
db.with_connection(|conn| {
|
||||
qbz_mixtape::repo::create_collection(
|
||||
conn,
|
||||
kind,
|
||||
&name,
|
||||
None,
|
||||
qbz_models::mixtape::CollectionSourceType::Manual,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.map_err(|e| {
|
||||
qbz_library::LibraryError::Database(format!("myqbz create_collection failed: {e}"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the modal's `kind` string ("mixtape" | "collection") into a
|
||||
/// `CollectionKind`. `artist_collection` is intentionally NOT creatable here
|
||||
/// (Discography Builder only); unknown values default to `Mixtape`.
|
||||
pub fn kind_from_str(s: &str) -> CollectionKind {
|
||||
match s {
|
||||
"collection" => CollectionKind::Collection,
|
||||
"artist_collection" => CollectionKind::ArtistCollection,
|
||||
_ => CollectionKind::Mixtape,
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── string helpers ──────────────────────────
|
||||
|
||||
fn kind_str(kind: CollectionKind) -> &'static str {
|
||||
|
||||
@@ -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, 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, 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,221 @@
|
||||
// "Create Mixtape / Collection" modal — 1:1 with Tauri's inline create
|
||||
// modal in `+page.svelte` (spec 30 §3). A Name field, a Kind radio toggle
|
||||
// (Mixtape / Collection — artist_collection is built via the Discography
|
||||
// Builder, not here), and Cancel / Create buttons. The title and the Create
|
||||
// button label switch on `kind` ("Create Mixtape" + "+ New Mixtape" vs
|
||||
// "Create Collection" + "+ New Collection").
|
||||
//
|
||||
// Opened from either grid's "+ New" CTA / empty-state button
|
||||
// (MyQbzCreateState.open). Backdrop click and Cancel close without creating;
|
||||
// Enter / Create submit. Create is disabled while busy or the name is blank.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { MyQbzCreateState, MyQbzCreateActions } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LineEdit } from "std-widgets.slint";
|
||||
|
||||
// A single labelled radio option in the Kind toggle.
|
||||
component KindRadio inherits TouchArea {
|
||||
in property <string> label;
|
||||
in property <bool> selected;
|
||||
mouse-cursor: pointer;
|
||||
horizontal-stretch: 0;
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
// Radio dot.
|
||||
Rectangle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
border-radius: 9px;
|
||||
border-width: root.selected ? 0px : 1.5px;
|
||||
border-color: Theme.text-muted;
|
||||
background: root.selected ? Theme.accent : transparent;
|
||||
Rectangle {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
border-radius: 3.5px;
|
||||
background: #ffffff;
|
||||
visible: root.selected;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: root.label;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component CreateMyQbzModal inherits Rectangle {
|
||||
visible: MyQbzCreateState.open;
|
||||
|
||||
property <bool> is-mixtape: MyQbzCreateState.kind == "mixtape";
|
||||
property <bool> can-create: MyQbzCreateState.name != "" && !MyQbzCreateState.creating;
|
||||
|
||||
if MyQbzCreateState.open: Rectangle {
|
||||
background: #000000bf;
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
MyQbzCreateActions.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;
|
||||
TouchArea { }
|
||||
|
||||
panel := VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 16px;
|
||||
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
text: root.is-mixtape ? @tr("Create Mixtape") : @tr("Create Collection");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.bold;
|
||||
horizontal-stretch: 1;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
close-x := TouchArea {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
MyQbzCreateActions.close();
|
||||
}
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Name.
|
||||
VerticalLayout {
|
||||
spacing: 8px;
|
||||
Text {
|
||||
// Uppercase eyebrow (Tauri .field-label text-transform).
|
||||
text: @tr("NAME");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
name-input := LineEdit {
|
||||
text <=> MyQbzCreateState.name;
|
||||
accepted => {
|
||||
MyQbzCreateActions.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kind toggle (Mixtape / Collection).
|
||||
VerticalLayout {
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: @tr("KIND");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
alignment: start;
|
||||
KindRadio {
|
||||
label: @tr("Mixtapes");
|
||||
selected: root.is-mixtape;
|
||||
clicked => {
|
||||
if (!MyQbzCreateState.creating) {
|
||||
MyQbzCreateState.kind = "mixtape";
|
||||
}
|
||||
}
|
||||
}
|
||||
KindRadio {
|
||||
label: @tr("Collections");
|
||||
selected: !root.is-mixtape;
|
||||
clicked => {
|
||||
if (!MyQbzCreateState.creating) {
|
||||
MyQbzCreateState.kind = "collection";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel + Create.
|
||||
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: pointer;
|
||||
clicked => {
|
||||
MyQbzCreateActions.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Create (primary) — label reuses the empty-state CTA.
|
||||
Rectangle {
|
||||
width: create-label.preferred-width + 40px;
|
||||
height: 38px;
|
||||
border-radius: Radius.sm;
|
||||
opacity: root.can-create ? 1.0 : 0.5;
|
||||
background: create-ta.has-hover ? #ffffffd6 : Theme.accent;
|
||||
create-label := Text {
|
||||
text: root.is-mixtape ? @tr("+ New Mixtape") : @tr("+ New Collection");
|
||||
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;
|
||||
}
|
||||
create-ta := TouchArea {
|
||||
mouse-cursor: root.can-create ? pointer : default;
|
||||
clicked => {
|
||||
if (root.can-create) {
|
||||
MyQbzCreateActions.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint";
|
||||
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 { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
|
||||
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
|
||||
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
|
||||
@@ -381,6 +382,9 @@ export component AppShell inherits Rectangle {
|
||||
// Global "Create folder" modal.
|
||||
CreateFolderModal { }
|
||||
|
||||
// Global "Create Mixtape / Collection" modal (My QBZ "+ New").
|
||||
CreateMyQbzModal { }
|
||||
|
||||
// Ephemeral "already playing" add-vs-replace choice.
|
||||
EphemeralPlayChoiceModal { }
|
||||
|
||||
|
||||
@@ -1108,6 +1108,23 @@ export global CreateFolderActions {
|
||||
callback close();
|
||||
}
|
||||
|
||||
// "Create Mixtape / Collection" modal state (My QBZ). Opened from either
|
||||
// grid's "+ New" CTA / empty-state button. `kind` is preset by the opening
|
||||
// grid ("mixtape" | "collection") and can be flipped via the radio toggle
|
||||
// (artist_collection is created via the Discography Builder, not here).
|
||||
// `name` is capped at 80 chars (mirrors Tauri's maxlength="80").
|
||||
export global MyQbzCreateState {
|
||||
in-out property <bool> open: false;
|
||||
in-out property <string> kind: "mixtape";
|
||||
in-out property <string> name;
|
||||
in property <bool> creating: false;
|
||||
}
|
||||
|
||||
export global MyQbzCreateActions {
|
||||
callback submit();
|
||||
callback close();
|
||||
}
|
||||
|
||||
// Ephemeral "play with active queue" choice — shown when the user triggers an
|
||||
// ephemeral play action while a queue is already playing. Two outcomes:
|
||||
// add to the queue, or clear the queue and start playing.
|
||||
|
||||
Reference in new issue
Block a user