mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(playlists): local playlist folders (sidebar + page, DnD, view toggle) (#1119)
* feat(playlists): playlist folder model — store + pure grouping core Local, per-server folder layer over the server's flat playlist list (the Subsonic API has no folder concept). Adds: - playlistFolders.ts: shared types + pure groupPlaylistsByFolder (used by every surface), with full unit coverage. - playlistFolderStore.ts: persisted Zustand store (create/rename/delete/ assign/collapse), per-server scoped; deleting a folder drops its assignments so playlists fall back to ungrouped. UI surfaces (sidebar + Playlists page) build on this in following commits. * i18n(playlists): folder strings across all 9 locales Adds the playlists.folders.* namespace (folder names, move/remove, expand/ collapse, group-by-folders toggle, count plurals, and the local-only notice explaining that Navidrome and the Subsonic API have no native folder support). * feat(playlists): folder views, drag-to-folder, move-to-folder menu, view toggle Surfaces the local folder layer on both the Playlists page and the sidebar: - Page: collapsible folder sections + ungrouped remainder, each reusing VirtualCardGrid; flat grid is kept verbatim when no folders exist or the group view is toggled off. - Drag-to-folder via the shared mouse-based psy-drop system (HTML5 DnD is unusable in WebKitGTK); the whole section is the drop zone, and the ungrouped zone appears during a drag so a playlist can always return to root. - "Move to folder" submenu in the playlist context menu (keyboard-accessible path; also creates folders on the fly) — stays available offline. - Header gets a "New folder" action and a "Group by folders" view toggle (both context-aware); a notice surfaces the local-only caveat. - Sidebar renders the same collapsible folder groups. - groupView preference added to the folder store. * docs(changelog): note playlist folders (#1119)
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
@import './lucky-mix-pips-shared-by-the-inline-queue-lucky-cube-indicator.css';
|
||||
@import './playback-buffering-overlay.css';
|
||||
@import './playlist-edit-modal.css';
|
||||
@import './playlist-folders.css';
|
||||
@import './artists-page.css';
|
||||
@import './years-page.css';
|
||||
@import './settings.css';
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/* Playlist folders — Playlists page notice + folder sections, and sidebar groups. */
|
||||
|
||||
/* ── Local-only notice ──────────────────────────────────────────────────── */
|
||||
.playlist-folder-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.playlist-folder-notice svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.playlist-folder-notice--page {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Folder sections on the page ────────────────────────────────────────── */
|
||||
.playlist-folder-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.playlist-folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.playlist-folder-header:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.playlist-folder-header--static {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
border-radius: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.playlist-folder-header--static:hover {
|
||||
border-color: transparent;
|
||||
border-bottom-color: var(--border-subtle);
|
||||
}
|
||||
.playlist-folder-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
transition: transform var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.playlist-folder-toggle.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.playlist-folder-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.playlist-folder-icon {
|
||||
color: var(--accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.playlist-folder-name {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
max-width: 40ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.playlist-folder-name--static {
|
||||
cursor: default;
|
||||
}
|
||||
.playlist-folder-rename-input {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.playlist-folder-rename-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.playlist-folder-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-hover);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.playlist-folder-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.playlist-folder-header:hover .playlist-folder-actions,
|
||||
.playlist-folder-actions:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
.playlist-folder-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.playlist-folder-action:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.playlist-folder-action--delete:hover,
|
||||
.playlist-folder-action--delete.is-confirm {
|
||||
color: var(--danger);
|
||||
}
|
||||
.playlist-folder-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-2) 0 var(--space-3);
|
||||
}
|
||||
.playlist-folder-dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Drag-and-drop: dropping a playlist card anywhere in a folder files it there. */
|
||||
.playlist-folder.drag-over {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.album-card--draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
.album-card--draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── Sidebar folder groups ──────────────────────────────────────────────── */
|
||||
.sidebar-playlist-folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--sidebar-text, var(--text-muted));
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.sidebar-playlist-folder-header:hover {
|
||||
background: var(--sidebar-item-hover);
|
||||
}
|
||||
.sidebar-playlist-folder-chevron {
|
||||
flex: 0 0 auto;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.sidebar-playlist-folder-header.expanded .sidebar-playlist-folder-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.sidebar-playlist-folder-name {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-playlist-folder-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.sidebar-playlist-folder-items {
|
||||
padding-left: 12px;
|
||||
}
|
||||
Reference in New Issue
Block a user