Local, Tauri-shared playlist folders (library.db) now organize the
sidebar:
- New folders.rs controller (create / rename / delete / move via
qbz-library); sidebar.rs reworked to a flattened entries model
(folder headers + grouped playlists + root playlists) with a
session expand cache, rebuilt without a refetch on expand/move.
- Sidebar renders folders (expandable, with playlist counts) and
playlists (indented inside folders); a hover "..." opens a context
popup: Move to folder (root + each folder) for playlists, Delete
folder for folders.
- Header gains a New-folder button + a CreateFolderModal; folders
reload after create/delete; moves are optimistic + persisted.
Adds folder-open / folder-plus icons.
cc
321 lines
11 KiB
Plaintext
321 lines
11 KiB
Plaintext
// Left navigation sidebar — the playlists list.
|
|
//
|
|
// The playlist list is empty until real playlist data is wired (the
|
|
// sidebar is a later milestone). The user element now lives in the
|
|
// header, not at the bottom of the sidebar.
|
|
//
|
|
// The sidebar is collapsible: its rendered width is driven by
|
|
// ShellState (0px when collapsed, otherwise the user-set width). A thin
|
|
// drag handle on the right edge resizes it, clamped to [200px, 420px];
|
|
// dragging below ~160px collapses it entirely.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Spacing } from "../foundation/spacing.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { ShellState, SidebarState, SidebarActions, SidebarEntry } from "../state.slint";
|
|
import { ScrollView } from "std-widgets.slint";
|
|
|
|
// A single context-menu item row.
|
|
component MenuItem inherits Rectangle {
|
|
in property <string> label;
|
|
in property <bool> danger: false;
|
|
callback activated();
|
|
height: 30px;
|
|
border-radius: 5px;
|
|
background: mi-ta.has-hover ? Theme.surface-hover : transparent;
|
|
HorizontalLayout {
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
Text {
|
|
text: root.label;
|
|
color: root.danger ? #ef4444 : Theme.text-secondary;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
mi-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.activated();
|
|
}
|
|
}
|
|
}
|
|
|
|
// One sidebar row — a folder header or a playlist (indented when in a
|
|
// folder), driven by the entry's `kind`. A hover "..." button opens a
|
|
// context popup: move-to-folder for playlists, delete for folders.
|
|
component SidebarRow inherits Rectangle {
|
|
in property <SidebarEntry> entry;
|
|
in property <bool> active: false;
|
|
callback open-playlist(string);
|
|
callback toggle-folder(string);
|
|
callback move-playlist(string, string);
|
|
callback delete-folder(string);
|
|
|
|
property <bool> is-folder: root.entry.kind == "folder";
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
background: ta.has-hover || more-ta.has-hover || root.active
|
|
? Theme.surface-hover : transparent;
|
|
|
|
HorizontalLayout {
|
|
padding-left: root.entry.indent ? 24px : 8px;
|
|
padding-right: 6px;
|
|
spacing: 8px;
|
|
// Leading icon — chevron+folder for folders, list-music for
|
|
// playlists.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: root.is-folder
|
|
? (root.entry.expanded
|
|
? @image-url("../assets/icons/folder-open.svg")
|
|
: @image-url("../assets/icons/folder.svg"))
|
|
: @image-url("../assets/icons/list-music.svg");
|
|
width: 15px;
|
|
height: 15px;
|
|
tint: root.active ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
}
|
|
Text {
|
|
text: root.entry.name;
|
|
color: root.active ? Theme.text-primary : Theme.text-secondary;
|
|
font-size: 13px;
|
|
font-weight: root.is-folder ? 600 : 400;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
}
|
|
// Folder playlist count.
|
|
if root.is-folder && root.entry.count > 0: Text {
|
|
text: root.entry.count;
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
vertical-alignment: center;
|
|
}
|
|
// Context "..." button (hover).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 4px;
|
|
opacity: (ta.has-hover || more-ta.has-hover) ? 1.0 : 0.0;
|
|
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/ellipsis.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: Theme.text-muted;
|
|
}
|
|
more-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
menu.show();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
if (root.is-folder) {
|
|
root.toggle-folder(root.entry.id);
|
|
} else {
|
|
root.open-playlist(root.entry.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
menu := PopupWindow {
|
|
x: root.width - 196px;
|
|
y: 28px;
|
|
width: 190px;
|
|
Rectangle {
|
|
background: Theme.surface-card;
|
|
border-width: 1px;
|
|
border-color: Theme.border-subtle;
|
|
border-radius: 8px;
|
|
drop-shadow-blur: 20px;
|
|
drop-shadow-color: #00000080;
|
|
VerticalLayout {
|
|
padding: 6px;
|
|
spacing: 1px;
|
|
// Folder: delete.
|
|
if root.is-folder: MenuItem {
|
|
label: @tr("Delete folder");
|
|
danger: true;
|
|
activated => {
|
|
root.delete-folder(root.entry.id);
|
|
}
|
|
}
|
|
// Playlist: move to folder. Wrapped so the `for` is not
|
|
// a direct child of an `if` (not allowed in Slint).
|
|
if !root.is-folder: VerticalLayout {
|
|
spacing: 1px;
|
|
Text {
|
|
text: @tr("Move to folder");
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
horizontal-alignment: left;
|
|
}
|
|
MenuItem {
|
|
label: @tr("No folder");
|
|
activated => {
|
|
root.move-playlist(root.entry.id, "");
|
|
}
|
|
}
|
|
for f in SidebarState.folders: MenuItem {
|
|
label: f.name;
|
|
activated => {
|
|
root.move-playlist(root.entry.id, f.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export component Sidebar inherits Rectangle {
|
|
// Rendered width — 0 when collapsed, otherwise the user-set width.
|
|
width: ShellState.sidebar-collapsed ? 0px : ShellState.sidebar-width;
|
|
// The sidebar sits one surface above the content area, matching the
|
|
// QBZ dark theme (--bg-secondary sidebar over --bg-primary content).
|
|
background: Theme.surface-card;
|
|
// Clip so content doesn't spill while the width animates to 0.
|
|
clip: true;
|
|
animate width { duration: 160ms; easing: ease-in-out; }
|
|
|
|
// Captured sidebar width at pointer-down, so drag deltas are
|
|
// measured from a stable origin.
|
|
property <length> drag-start-width: ShellState.sidebar-width;
|
|
|
|
VerticalLayout {
|
|
padding: Spacing.md;
|
|
spacing: Spacing.md;
|
|
|
|
HorizontalLayout {
|
|
spacing: 4px;
|
|
Text {
|
|
text: @tr("PLAYLISTS");
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
letter-spacing: 1.5px;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
}
|
|
// New folder.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 4px;
|
|
background: folder-ta.has-hover ? Theme.surface-hover : transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/folder-plus.svg");
|
|
width: 15px;
|
|
height: 15px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: folder-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
folder-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
SidebarActions.create-folder();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// New playlist.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 4px;
|
|
background: plus-ta.has-hover ? Theme.surface-hover : transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/plus.svg");
|
|
width: 16px;
|
|
height: 16px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: plus-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
plus-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
SidebarActions.create-playlist();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ScrollView {
|
|
vertical-stretch: 1;
|
|
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
|
VerticalLayout {
|
|
spacing: 2px;
|
|
alignment: start;
|
|
for entry in SidebarState.entries: SidebarRow {
|
|
entry: entry;
|
|
active: entry.kind == "playlist" && entry.id == SidebarState.active-id;
|
|
open-playlist(id) => {
|
|
SidebarActions.open-playlist(id);
|
|
}
|
|
toggle-folder(id) => {
|
|
SidebarActions.toggle-folder(id);
|
|
}
|
|
move-playlist(pid, fid) => {
|
|
SidebarActions.move-playlist(pid, fid);
|
|
}
|
|
delete-folder(id) => {
|
|
SidebarActions.delete-folder(id);
|
|
}
|
|
}
|
|
if SidebarState.entries.length == 0 && !SidebarState.loading: Text {
|
|
text: @tr("No playlists yet.");
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
wrap: word-wrap;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drag handle — a thin strip pinned to the right edge. Resizes the
|
|
// sidebar; below ~160px it collapses instead of resizing.
|
|
drag-handle := TouchArea {
|
|
x: parent.width - self.width;
|
|
width: 6px;
|
|
height: parent.height;
|
|
mouse-cursor: ew-resize;
|
|
|
|
pointer-event(e) => {
|
|
if (e.kind == PointerEventKind.down) {
|
|
root.drag-start-width = ShellState.sidebar-width;
|
|
}
|
|
}
|
|
moved => {
|
|
if (self.pressed) {
|
|
if (root.drag-start-width + (self.mouse-x - self.pressed-x) < 160px) {
|
|
ShellState.sidebar-collapsed = true;
|
|
} else {
|
|
ShellState.sidebar-width = Math.min(Math.max(
|
|
root.drag-start-width + (self.mouse-x - self.pressed-x),
|
|
200px), 420px);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|