mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-15 07:54:18 +02:00
feat(sidebar): multi-level playlist folders with tree and navigation views (#2017)
Group playlists into folders using a configurable separator (default '/'). Three view modes: - Single: first-level grouping only (original behavior) - Tree: full recursive nesting with connecting lines (configurable indent and line color) - Navigation: drill-down view with stacked breadcrumb chain Folders are sorted before playlists at every level. New settings render as indented sub-options under the master 'Enable folders' toggle.
This commit is contained in:
@@ -6,6 +6,8 @@
|
|||||||
"selectRangeOfItems": "Select a range of items",
|
"selectRangeOfItems": "Select a range of items",
|
||||||
"clearQueue": "Clear queue",
|
"clearQueue": "Clear queue",
|
||||||
"goToCurrent": "Go to current item",
|
"goToCurrent": "Go to current item",
|
||||||
|
"collapseAllFolders": "Collapse all folders",
|
||||||
|
"expandAllFolders": "Expand all folders",
|
||||||
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
|
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
|
||||||
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
|
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
|
||||||
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
|
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
"albumPeak": "Album peak",
|
"albumPeak": "Album peak",
|
||||||
"areYouSure": "Are you sure?",
|
"areYouSure": "Are you sure?",
|
||||||
"ascending": "Ascending",
|
"ascending": "Ascending",
|
||||||
|
"back": "Back",
|
||||||
"backward": "Backward",
|
"backward": "Backward",
|
||||||
"biography": "Biography",
|
"biography": "Biography",
|
||||||
"bitDepth": "Bit depth",
|
"bitDepth": "Bit depth",
|
||||||
@@ -1044,6 +1047,19 @@
|
|||||||
"sidebarConfiguration": "Sidebar configuration",
|
"sidebarConfiguration": "Sidebar configuration",
|
||||||
"playerItemConfiguration_description": "Configure what items are shown, and in what order, on the fullscreen player",
|
"playerItemConfiguration_description": "Configure what items are shown, and in what order, on the fullscreen player",
|
||||||
"playerItemConfiguration": "Player item configuration",
|
"playerItemConfiguration": "Player item configuration",
|
||||||
|
"sidebarPlaylistFolders_description": "Create a folder view for playlists that include the configured separator in the name",
|
||||||
|
"sidebarPlaylistFolders": "Enable folders",
|
||||||
|
"sidebarPlaylistFolderSeparator_description": "Character (or string) that separates folder levels in a playlist name",
|
||||||
|
"sidebarPlaylistFolderSeparator": "Folder separator",
|
||||||
|
"sidebarPlaylistFolderView_description": "How folders are displayed in the sidebar",
|
||||||
|
"sidebarPlaylistFolderView": "Folder view",
|
||||||
|
"sidebarPlaylistFolderView_optionSingle": "Single folder",
|
||||||
|
"sidebarPlaylistFolderView_optionTree": "Tree view",
|
||||||
|
"sidebarPlaylistFolderView_optionNavigation": "Navigation view",
|
||||||
|
"sidebarPlaylistFolderTreeIndent_description": "Pixels each tree level is indented",
|
||||||
|
"sidebarPlaylistFolderTreeIndent": "Tree indent",
|
||||||
|
"sidebarPlaylistFolderTreeLineColor_description": "Color of the connecting tree lines (leave empty for theme default)",
|
||||||
|
"sidebarPlaylistFolderTreeLineColor": "Tree line color",
|
||||||
"sidebarPlaylistList_description": "Show or hide the playlist list in the sidebar",
|
"sidebarPlaylistList_description": "Show or hide the playlist list in the sidebar",
|
||||||
"sidebarPlaylistList": "Sidebar playlist list",
|
"sidebarPlaylistList": "Sidebar playlist list",
|
||||||
"sidebarPlaylistSorting_description": "Allows manual playlist sorting in the sidebar using drag and drop instead of the default server order",
|
"sidebarPlaylistSorting_description": "Allows manual playlist sorting in the sidebar using drag and drop instead of the default server order",
|
||||||
|
|||||||
@@ -7,15 +7,28 @@ import {
|
|||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from '/@/renderer/features/settings/components/settings-section';
|
} from '/@/renderer/features/settings/components/settings-section';
|
||||||
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
|
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||||
|
import { ColorInput } from '/@/shared/components/color-input/color-input';
|
||||||
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
|
import { Select } from '/@/shared/components/select/select';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||||
|
|
||||||
|
type FolderView = 'navigation' | 'single' | 'tree';
|
||||||
|
|
||||||
export const SidebarSettings = memo(() => {
|
export const SidebarSettings = memo(() => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const settings = useGeneralSettings();
|
const settings = useGeneralSettings();
|
||||||
const { setSettings } = useSettingsStoreActions();
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
|
||||||
|
const handleSetSidebarPlaylistFolders = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolders: e.target.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSetSidebarPlaylistList = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleSetSidebarPlaylistList = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setSettings({
|
setSettings({
|
||||||
general: {
|
general: {
|
||||||
@@ -56,6 +69,45 @@ export const SidebarSettings = memo(() => {
|
|||||||
});
|
});
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
const [localSeparator, setLocalSeparator] = useState(settings.sidebarPlaylistFolderSeparator);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSeparator(settings.sidebarPlaylistFolderSeparator);
|
||||||
|
}, [settings.sidebarPlaylistFolderSeparator]);
|
||||||
|
|
||||||
|
const debouncedSetSeparator = useDebouncedCallback((value: string) => {
|
||||||
|
if (value.length === 0) return;
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolderSeparator: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const foldersEnabled = settings.sidebarPlaylistFolders;
|
||||||
|
const isTreeView = settings.sidebarPlaylistFolderView === 'tree';
|
||||||
|
|
||||||
|
const folderViewOptions: Array<{ label: string; value: FolderView }> = [
|
||||||
|
{
|
||||||
|
label: t('setting.sidebarPlaylistFolderView_optionSingle', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('setting.sidebarPlaylistFolderView_optionTree', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'tree',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('setting.sidebarPlaylistFolderView_optionNavigation', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'navigation',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const options: SettingOption[] = [
|
const options: SettingOption[] = [
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
@@ -98,6 +150,115 @@ export const SidebarSettings = memo(() => {
|
|||||||
}),
|
}),
|
||||||
title: t('setting.sidebarPlaylistSorting'),
|
title: t('setting.sidebarPlaylistSorting'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
checked={settings.sidebarPlaylistFolders}
|
||||||
|
onChange={handleSetSidebarPlaylistFolders}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolders', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
title: t('setting.sidebarPlaylistFolders', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<TextInput
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setLocalSeparator(value);
|
||||||
|
debouncedSetSeparator(value);
|
||||||
|
}}
|
||||||
|
value={localSeparator}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolderSeparator', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
indent: true,
|
||||||
|
isHidden: !foldersEnabled,
|
||||||
|
title: t('setting.sidebarPlaylistFolderSeparator', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Select
|
||||||
|
data={folderViewOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolderView: value as FolderView,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={settings.sidebarPlaylistFolderView}
|
||||||
|
width={200}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolderView', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
indent: true,
|
||||||
|
isHidden: !foldersEnabled,
|
||||||
|
title: t('setting.sidebarPlaylistFolderView', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<NumberInput
|
||||||
|
max={64}
|
||||||
|
min={0}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const value = Number(e.currentTarget.value);
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolderTreeIndent: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(64, Math.round(value)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={settings.sidebarPlaylistFolderTreeIndent}
|
||||||
|
width={100}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolderTreeIndent', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
indent: true,
|
||||||
|
isHidden: !foldersEnabled || !isTreeView,
|
||||||
|
title: t('setting.sidebarPlaylistFolderTreeIndent', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<ColorInput
|
||||||
|
format="rgba"
|
||||||
|
onChangeEnd={(value) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolderTreeLineColor: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={settings.sidebarPlaylistFolderTreeLineColor}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolderTreeLineColor', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
indent: true,
|
||||||
|
isHidden: !foldersEnabled || !isTreeView,
|
||||||
|
title: t('setting.sidebarPlaylistFolderTreeLineColor', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.row {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-indented {
|
||||||
|
padding-left: var(--theme-spacing-lg);
|
||||||
|
margin-left: var(--theme-spacing-md);
|
||||||
|
border-left: 2px solid var(--theme-colors-surface);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
import styles from './settings-option.module.css';
|
||||||
|
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
@@ -9,15 +11,20 @@ import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
|||||||
interface SettingsOptionProps {
|
interface SettingsOptionProps {
|
||||||
control: React.ReactNode;
|
control: React.ReactNode;
|
||||||
description?: React.ReactNode | string;
|
description?: React.ReactNode | string;
|
||||||
|
indent?: boolean;
|
||||||
note?: string;
|
note?: string;
|
||||||
title: React.ReactNode | string;
|
title: React.ReactNode | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsOptions = memo(
|
export const SettingsOptions = memo(
|
||||||
({ control, description, note, title }: SettingsOptionProps) => {
|
({ control, description, indent, note, title }: SettingsOptionProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" style={{ alignItems: 'center' }} wrap="nowrap">
|
<Group
|
||||||
|
className={indent ? styles.rowIndented : styles.row}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
<Stack
|
<Stack
|
||||||
gap="xs"
|
gap="xs"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TextTitle } from '/@/shared/components/text-title/text-title';
|
|||||||
export type SettingOption = {
|
export type SettingOption = {
|
||||||
control: ReactNode;
|
control: ReactNode;
|
||||||
description: ReactNode | string;
|
description: ReactNode | string;
|
||||||
|
indent?: boolean;
|
||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
note?: string;
|
note?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
.folder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--theme-spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--theme-colors-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
padding-left: var(--theme-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children {
|
||||||
|
padding-left: var(--playlist-folder-tree-indent, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-branch {
|
||||||
|
position: relative;
|
||||||
|
padding-left: calc(var(--playlist-folder-tree-indent, 16px) - 4px);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: calc(var(--playlist-folder-tree-indent, 16px) - 8px);
|
||||||
|
content: "";
|
||||||
|
border-bottom: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||||
|
border-left: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
content: "";
|
||||||
|
border-left: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-folder {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--theme-spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--theme-colors-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-folder-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
min-width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
background-color: var(--theme-colors-surface);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import { CSSProperties, MouseEvent, ReactElement, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import styles from './playlist-folder-tree.module.css';
|
||||||
|
|
||||||
|
import { PlaylistRowButton } from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
||||||
|
import {
|
||||||
|
useSidebarPlaylistFolders,
|
||||||
|
useSidebarPlaylistFolderSeparator,
|
||||||
|
useSidebarPlaylistFolderTreeIndent,
|
||||||
|
useSidebarPlaylistFolderTreeLineColor,
|
||||||
|
useSidebarPlaylistFolderView,
|
||||||
|
} from '/@/renderer/store';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||||
|
import { Playlist } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = 'feishin:playlist-folder-state';
|
||||||
|
|
||||||
|
export type FolderNode = {
|
||||||
|
children: TreeNode[];
|
||||||
|
leafCount: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'folder';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeafNode = {
|
||||||
|
displayName: string;
|
||||||
|
item: Playlist;
|
||||||
|
type: 'leaf';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaylistFolderScope = 'owned' | 'shared';
|
||||||
|
|
||||||
|
export type PlaylistGroup =
|
||||||
|
| { item: Playlist; type: 'root' }
|
||||||
|
| { items: Playlist[]; name: string; type: 'folder' };
|
||||||
|
|
||||||
|
export type TreeNode = FolderNode | LeafNode;
|
||||||
|
|
||||||
|
const splitOnce = (name: string, separator: string): [string, string] | null => {
|
||||||
|
const idx = name.indexOf(separator);
|
||||||
|
// Reject any separators at the end
|
||||||
|
if (idx <= 0 || idx >= name.length - separator.length) return null;
|
||||||
|
return [name.slice(0, idx), name.slice(idx + separator.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const groupPlaylists = (items: Playlist[], separator: string): PlaylistGroup[] => {
|
||||||
|
const folders: PlaylistGroup[] = [];
|
||||||
|
const roots: PlaylistGroup[] = [];
|
||||||
|
const folderIndex = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const split = splitOnce(item.name, separator);
|
||||||
|
if (split) {
|
||||||
|
const [folderName] = split;
|
||||||
|
const existing = folderIndex.get(folderName);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
const group = folders[existing];
|
||||||
|
if (group.type === 'folder') {
|
||||||
|
group.items.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folderIndex.set(folderName, folders.length);
|
||||||
|
folders.push({ items: [item], name: folderName, type: 'folder' });
|
||||||
|
} else {
|
||||||
|
roots.push({ item, type: 'root' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...folders, ...roots];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPlaylistTree = (items: Playlist[], separator: string): TreeNode[] => {
|
||||||
|
const root: TreeNode[] = [];
|
||||||
|
const folderByPath = new Map<string, FolderNode>();
|
||||||
|
|
||||||
|
const ensureFolder = (segments: string[], parent: TreeNode[]): FolderNode => {
|
||||||
|
const path = segments.join(separator);
|
||||||
|
const existing = folderByPath.get(path);
|
||||||
|
if (existing) return existing;
|
||||||
|
const node: FolderNode = {
|
||||||
|
children: [],
|
||||||
|
leafCount: 0,
|
||||||
|
name: segments[segments.length - 1],
|
||||||
|
path,
|
||||||
|
type: 'folder',
|
||||||
|
};
|
||||||
|
folderByPath.set(path, node);
|
||||||
|
parent.push(node);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const segments = separator ? item.name.split(separator) : [item.name];
|
||||||
|
const validSegments = segments.filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (validSegments.length <= 1) {
|
||||||
|
root.push({ displayName: item.name, item, type: 'leaf' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent: TreeNode[] = root;
|
||||||
|
const pathStack: string[] = [];
|
||||||
|
for (let i = 0; i < validSegments.length - 1; i++) {
|
||||||
|
pathStack.push(validSegments[i]);
|
||||||
|
const folder = ensureFolder([...pathStack], parent);
|
||||||
|
parent = folder.children;
|
||||||
|
}
|
||||||
|
const leafName = validSegments[validSegments.length - 1];
|
||||||
|
parent.push({ displayName: leafName, item, type: 'leaf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortFoldersFirst = (nodes: TreeNode[]): TreeNode[] => {
|
||||||
|
const folderNodes: TreeNode[] = [];
|
||||||
|
const leafNodes: TreeNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
node.children = sortFoldersFirst(node.children);
|
||||||
|
folderNodes.push(node);
|
||||||
|
} else {
|
||||||
|
leafNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...folderNodes, ...leafNodes];
|
||||||
|
};
|
||||||
|
|
||||||
|
const countLeaves = (nodes: TreeNode[]): number => {
|
||||||
|
let total = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'leaf') {
|
||||||
|
total += 1;
|
||||||
|
} else {
|
||||||
|
node.leafCount = countLeaves(node.children);
|
||||||
|
total += node.leafCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = sortFoldersFirst(root);
|
||||||
|
countLeaves(sorted);
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectFolderPaths = (nodes: TreeNode[]): string[] => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
const walk = (list: TreeNode[]) => {
|
||||||
|
for (const node of list) {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
paths.push(node.path);
|
||||||
|
walk(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(nodes);
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlaylistFolderState = (scope: PlaylistFolderScope) => {
|
||||||
|
const [expanded, setExpanded] = useLocalStorage<string[]>({
|
||||||
|
defaultValue: [],
|
||||||
|
key: `${STORAGE_KEY_PREFIX}:${scope}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expandedSet = useMemo(() => new Set(expanded), [expanded]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
return Array.from(next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMany = useCallback(
|
||||||
|
(paths: string[], shouldExpand: boolean) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (shouldExpand) {
|
||||||
|
for (const p of paths) next.add(p);
|
||||||
|
} else {
|
||||||
|
for (const p of paths) next.delete(p);
|
||||||
|
}
|
||||||
|
return Array.from(next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { expandedSet, setMany, toggle };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderTreeProps {
|
||||||
|
expandedSet: Set<string>;
|
||||||
|
groups: PlaylistGroup[];
|
||||||
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
|
onReorder: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||||
|
onToggleFolder: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistFolderTree = ({
|
||||||
|
expandedSet,
|
||||||
|
groups,
|
||||||
|
onContextMenu,
|
||||||
|
onReorder,
|
||||||
|
onToggleFolder,
|
||||||
|
}: PlaylistFolderTreeProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{groups.map((group) => {
|
||||||
|
if (group.type === 'root') {
|
||||||
|
return (
|
||||||
|
<PlaylistRowButton
|
||||||
|
item={group.item}
|
||||||
|
key={group.item.id}
|
||||||
|
name={group.item.name}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
to={group.item.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = expandedSet.has(group.name);
|
||||||
|
return (
|
||||||
|
<div className={styles.folder} key={`folder:${group.name}`}>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={group.name}
|
||||||
|
className={styles.header}
|
||||||
|
onClick={() => onToggleFolder(group.name)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={styles.chevron}
|
||||||
|
icon={isOpen ? 'arrowDownS' : 'arrowRightS'}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Icon color="muted" icon="folder" size="sm" />
|
||||||
|
<Text className={styles.name} fw={500} size="md">
|
||||||
|
{group.name}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.count} isMuted size="sm">
|
||||||
|
{group.items.length}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<PlaylistRowButton
|
||||||
|
item={item}
|
||||||
|
key={item.id}
|
||||||
|
name={item.name.slice(group.name.length + 1)}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
to={item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderTreeViewProps {
|
||||||
|
expandedSet: Set<string>;
|
||||||
|
nodes: TreeNode[];
|
||||||
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
|
onReorder: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||||
|
onToggleFolder: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistFolderTreeView = ({
|
||||||
|
expandedSet,
|
||||||
|
nodes,
|
||||||
|
onContextMenu,
|
||||||
|
onReorder,
|
||||||
|
onToggleFolder,
|
||||||
|
}: PlaylistFolderTreeViewProps) => {
|
||||||
|
const renderNode = (node: TreeNode): ReactElement => {
|
||||||
|
if (node.type === 'leaf') {
|
||||||
|
return (
|
||||||
|
<PlaylistRowButton
|
||||||
|
item={node.item}
|
||||||
|
key={node.item.id}
|
||||||
|
name={node.displayName}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
to={node.item.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = expandedSet.has(node.path);
|
||||||
|
return (
|
||||||
|
<div className={styles.folder} key={`folder:${node.path}`}>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={node.name}
|
||||||
|
className={styles.header}
|
||||||
|
onClick={() => onToggleFolder(node.path)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={styles.chevron}
|
||||||
|
icon={isOpen ? 'arrowDownS' : 'arrowRightS'}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Icon color="muted" icon="folder" size="sm" />
|
||||||
|
<Text className={styles.name} fw={500} size="md">
|
||||||
|
{node.name}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.count} isMuted size="sm">
|
||||||
|
{node.leafCount}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.treeChildren}>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<div className={styles.treeBranch} key={getNodeKey(child)}>
|
||||||
|
{renderNode(child)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>{nodes.map((node) => renderNode(node))}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeKey = (node: TreeNode) =>
|
||||||
|
node.type === 'leaf' ? `leaf:${node.item.id}` : `folder:${node.path}`;
|
||||||
|
|
||||||
|
export interface PlaylistNavigationState {
|
||||||
|
currentName: string | undefined;
|
||||||
|
enter: (name: string) => void;
|
||||||
|
goUp: () => void;
|
||||||
|
pathStack: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlaylistNavigationState = (): PlaylistNavigationState => {
|
||||||
|
const [pathStack, setPathStack] = useState<string[]>([]);
|
||||||
|
const enter = useCallback((name: string) => setPathStack((prev) => [...prev, name]), []);
|
||||||
|
const goUp = useCallback(() => setPathStack((prev) => prev.slice(0, -1)), []);
|
||||||
|
return {
|
||||||
|
currentName: pathStack[pathStack.length - 1],
|
||||||
|
enter,
|
||||||
|
goUp,
|
||||||
|
pathStack,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderNavigationViewProps {
|
||||||
|
nodes: TreeNode[];
|
||||||
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
|
onEnter: (name: string) => void;
|
||||||
|
onReorder: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||||
|
pathStack: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistFolderNavigationView = ({
|
||||||
|
nodes,
|
||||||
|
onContextMenu,
|
||||||
|
onEnter,
|
||||||
|
onReorder,
|
||||||
|
pathStack,
|
||||||
|
}: PlaylistFolderNavigationViewProps) => {
|
||||||
|
const currentNodes = useMemo(() => {
|
||||||
|
let list = nodes;
|
||||||
|
for (const segment of pathStack) {
|
||||||
|
const folder = list.find(
|
||||||
|
(n): n is FolderNode => n.type === 'folder' && n.name === segment,
|
||||||
|
);
|
||||||
|
if (!folder) return [] as TreeNode[];
|
||||||
|
list = folder.children;
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [nodes, pathStack]);
|
||||||
|
|
||||||
|
const { folders, leaves } = useMemo(() => {
|
||||||
|
const fs: FolderNode[] = [];
|
||||||
|
const ls: LeafNode[] = [];
|
||||||
|
for (const node of currentNodes) {
|
||||||
|
if (node.type === 'folder') fs.push(node);
|
||||||
|
else ls.push(node);
|
||||||
|
}
|
||||||
|
return { folders: fs, leaves: ls };
|
||||||
|
}, [currentNodes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.navigation}>
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<button
|
||||||
|
aria-label={folder.name}
|
||||||
|
className={styles.navFolder}
|
||||||
|
key={`navfolder:${folder.path}`}
|
||||||
|
onClick={() => onEnter(folder.name)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className={styles.navFolderIcon}>
|
||||||
|
<Icon color="muted" icon="folder" size="xl" />
|
||||||
|
</div>
|
||||||
|
<Text className={styles.name} fw={500} size="md">
|
||||||
|
{folder.name}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.count} isMuted size="sm">
|
||||||
|
{folder.leafCount}
|
||||||
|
</Text>
|
||||||
|
<Icon className={styles.navChevron} icon="arrowRightS" size="sm" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{leaves.map((leaf) => (
|
||||||
|
<PlaylistRowButton
|
||||||
|
item={leaf.item}
|
||||||
|
key={leaf.item.id}
|
||||||
|
name={leaf.displayName}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
to={leaf.item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaylistFolderViewState = {
|
||||||
|
foldersEnabled: boolean;
|
||||||
|
folderView: 'navigation' | 'single' | 'tree';
|
||||||
|
groups: PlaylistGroup[];
|
||||||
|
tree: TreeNode[];
|
||||||
|
treeStyle: CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlaylistFolderViewState = (items: Playlist[]): PlaylistFolderViewState => {
|
||||||
|
const foldersEnabled = useSidebarPlaylistFolders();
|
||||||
|
const folderView = useSidebarPlaylistFolderView();
|
||||||
|
const separator = useSidebarPlaylistFolderSeparator();
|
||||||
|
const treeIndent = useSidebarPlaylistFolderTreeIndent();
|
||||||
|
const treeLineColor = useSidebarPlaylistFolderTreeLineColor();
|
||||||
|
|
||||||
|
const groups = useMemo<PlaylistGroup[]>(
|
||||||
|
() =>
|
||||||
|
foldersEnabled && folderView === 'single'
|
||||||
|
? groupPlaylists(items, separator)
|
||||||
|
: items.map((item) => ({ item, type: 'root' as const })),
|
||||||
|
[foldersEnabled, folderView, items, separator],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tree = useMemo<TreeNode[]>(
|
||||||
|
() =>
|
||||||
|
foldersEnabled && folderView !== 'single' ? buildPlaylistTree(items, separator) : [],
|
||||||
|
[foldersEnabled, folderView, items, separator],
|
||||||
|
);
|
||||||
|
|
||||||
|
const treeStyle = useMemo<CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
...(typeof treeIndent === 'number'
|
||||||
|
? { ['--playlist-folder-tree-indent' as never]: `${treeIndent}px` }
|
||||||
|
: {}),
|
||||||
|
...(treeLineColor
|
||||||
|
? { ['--playlist-folder-tree-line-color' as never]: treeLineColor }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
[treeIndent, treeLineColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { foldersEnabled, folderView, groups, tree, treeStyle };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderViewsProps extends PlaylistFolderViewState {
|
||||||
|
expandedSet: Set<string>;
|
||||||
|
navigation: PlaylistNavigationState;
|
||||||
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
|
onReorder: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||||
|
onToggleFolder: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistFolderViews = ({
|
||||||
|
expandedSet,
|
||||||
|
foldersEnabled,
|
||||||
|
folderView,
|
||||||
|
groups,
|
||||||
|
navigation,
|
||||||
|
onContextMenu,
|
||||||
|
onReorder,
|
||||||
|
onToggleFolder,
|
||||||
|
tree,
|
||||||
|
treeStyle,
|
||||||
|
}: PlaylistFolderViewsProps) => {
|
||||||
|
if (foldersEnabled && folderView === 'tree') {
|
||||||
|
return (
|
||||||
|
<div style={treeStyle}>
|
||||||
|
<PlaylistFolderTreeView
|
||||||
|
expandedSet={expandedSet}
|
||||||
|
nodes={tree}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foldersEnabled && folderView === 'navigation') {
|
||||||
|
return (
|
||||||
|
<PlaylistFolderNavigationView
|
||||||
|
nodes={tree}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onEnter={navigation.enter}
|
||||||
|
onReorder={onReorder}
|
||||||
|
pathStack={navigation.pathStack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlaylistFolderTree
|
||||||
|
expandedSet={expandedSet}
|
||||||
|
groups={groups}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,13 @@ import { memo, MouseEvent, useCallback, useMemo, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generatePath, Link } from 'react-router';
|
import { generatePath, Link } from 'react-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectFolderPaths,
|
||||||
|
PlaylistFolderViews,
|
||||||
|
usePlaylistFolderState,
|
||||||
|
usePlaylistFolderViewState,
|
||||||
|
usePlaylistNavigationState,
|
||||||
|
} from './playlist-folder-tree';
|
||||||
import styles from './sidebar-playlist-list.module.css';
|
import styles from './sidebar-playlist-list.module.css';
|
||||||
|
|
||||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
@@ -50,7 +57,7 @@ const getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'sha
|
|||||||
return `playlist_order:${sid}:${scope}`;
|
return `playlist_order:${sid}:${scope}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
|
export interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
|
||||||
item: Playlist;
|
item: Playlist;
|
||||||
name: string;
|
name: string;
|
||||||
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
@@ -58,7 +65,7 @@ interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'on
|
|||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaylistRowButton = memo(
|
export const PlaylistRowButton = memo(
|
||||||
({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {
|
({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {
|
||||||
const url = {
|
const url = {
|
||||||
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
|
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
|
||||||
@@ -485,11 +492,62 @@ export const SidebarPlaylistList = () => {
|
|||||||
openCreatePlaylistModal(server, e);
|
openCreatePlaylistModal(server, e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
|
||||||
|
const { folderView, groups, tree } = folderViewState;
|
||||||
|
const navigation = usePlaylistNavigationState();
|
||||||
|
const inNavigation = folderView === 'navigation' && navigation.pathStack.length > 0;
|
||||||
|
|
||||||
|
const folderPaths = useMemo(() => {
|
||||||
|
if (folderView === 'single') {
|
||||||
|
return groups.reduce<string[]>((acc, g) => {
|
||||||
|
if (g.type === 'folder') acc.push(g.name);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
return collectFolderPaths(tree);
|
||||||
|
}, [folderView, groups, tree]);
|
||||||
|
|
||||||
|
const { expandedSet, setMany, toggle } = usePlaylistFolderState('owned');
|
||||||
|
const allExpanded =
|
||||||
|
folderPaths.length > 0 && folderPaths.every((path) => expandedSet.has(path));
|
||||||
|
|
||||||
|
const handleToggleAllFolders = useCallback(
|
||||||
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMany(folderPaths, !allExpanded);
|
||||||
|
},
|
||||||
|
[setMany, folderPaths, allExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNavigateUp = useCallback(
|
||||||
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigation.goUp();
|
||||||
|
},
|
||||||
|
[navigation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showExpandAll = folderView !== 'navigation' && folderPaths.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value="playlists">
|
<Accordion.Item value="playlists">
|
||||||
<Accordion.Control component="div" role="button" style={{ userSelect: 'none' }}>
|
<Accordion.Control component="div" role="button" style={{ userSelect: 'none' }}>
|
||||||
<Group justify="space-between" pr="var(--theme-spacing-md)">
|
<Group justify="space-between" pr="var(--theme-spacing-md)">
|
||||||
<Text fw={500}>{t('page.sidebar.playlists')}</Text>
|
<Group gap="xs" style={{ minWidth: 0 }} wrap="nowrap">
|
||||||
|
{inNavigation && (
|
||||||
|
<ActionIcon
|
||||||
|
icon="arrowLeftS"
|
||||||
|
iconProps={{ size: 'lg' }}
|
||||||
|
onClick={handleNavigateUp}
|
||||||
|
size="xs"
|
||||||
|
tooltip={{ label: t('common.back') }}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text className={styles.name} fw={500}>
|
||||||
|
{inNavigation ? navigation.currentName : t('page.sidebar.playlists')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="add"
|
icon="add"
|
||||||
@@ -503,6 +561,27 @@ export const SidebarPlaylistList = () => {
|
|||||||
}}
|
}}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
/>
|
/>
|
||||||
|
{showExpandAll && (
|
||||||
|
<ActionIcon
|
||||||
|
icon={allExpanded ? 'collapseAll' : 'expandAll'}
|
||||||
|
iconProps={{
|
||||||
|
size: 'lg',
|
||||||
|
}}
|
||||||
|
onClick={handleToggleAllFolders}
|
||||||
|
size="xs"
|
||||||
|
tooltip={{
|
||||||
|
label: t(
|
||||||
|
allExpanded
|
||||||
|
? 'action.collapseAllFolders'
|
||||||
|
: 'action.expandAllFolders',
|
||||||
|
{
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component={Link}
|
component={Link}
|
||||||
icon="list"
|
icon="list"
|
||||||
@@ -521,16 +600,14 @@ export const SidebarPlaylistList = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
{playlistItems?.items?.map((item, index) => (
|
<PlaylistFolderViews
|
||||||
<PlaylistRowButton
|
{...folderViewState}
|
||||||
item={item}
|
expandedSet={expandedSet}
|
||||||
key={index}
|
navigation={navigation}
|
||||||
name={item.name}
|
onContextMenu={handleContextMenu}
|
||||||
onContextMenu={handleContextMenu}
|
onReorder={handleReorder}
|
||||||
onReorder={handleReorder}
|
onToggleFolder={toggle}
|
||||||
to={item.id}
|
/>
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
);
|
);
|
||||||
@@ -668,28 +745,52 @@ export const SidebarSharedPlaylistList = () => {
|
|||||||
setPlaylistOrder(reorderedIds);
|
setPlaylistOrder(reorderedIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
|
||||||
|
const navigation = usePlaylistNavigationState();
|
||||||
|
const { expandedSet, toggle } = usePlaylistFolderState('shared');
|
||||||
|
const inNavigation =
|
||||||
|
folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0;
|
||||||
|
|
||||||
|
const handleNavigateUp = useCallback(
|
||||||
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigation.goUp();
|
||||||
|
},
|
||||||
|
[navigation],
|
||||||
|
);
|
||||||
|
|
||||||
if (playlistItems?.items?.length === 0) {
|
if (playlistItems?.items?.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value="shared-playlists">
|
<Accordion.Item value="shared-playlists">
|
||||||
<Accordion.Control>
|
<Accordion.Control component="div" role="button" style={{ userSelect: 'none' }}>
|
||||||
<Text fw={500} variant="secondary">
|
<Group gap="xs" style={{ minWidth: 0 }} wrap="nowrap">
|
||||||
{t('page.sidebar.shared')}
|
{inNavigation && (
|
||||||
</Text>
|
<ActionIcon
|
||||||
|
icon="arrowLeftS"
|
||||||
|
iconProps={{ size: 'lg' }}
|
||||||
|
onClick={handleNavigateUp}
|
||||||
|
size="xs"
|
||||||
|
tooltip={{ label: t('common.back') }}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text className={styles.name} fw={500} variant="secondary">
|
||||||
|
{inNavigation ? navigation.currentName : t('page.sidebar.shared')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
{playlistItems?.items?.map((item, index) => (
|
<PlaylistFolderViews
|
||||||
<PlaylistRowButton
|
{...folderViewState}
|
||||||
item={item}
|
expandedSet={expandedSet}
|
||||||
key={index}
|
navigation={navigation}
|
||||||
name={item.name}
|
onContextMenu={handleContextMenu}
|
||||||
onContextMenu={handleContextMenu}
|
onReorder={handleReorder}
|
||||||
onReorder={handleReorder}
|
onToggleFolder={toggle}
|
||||||
to={item.id}
|
/>
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ const SideQueueLayoutSchema = z.enum(['horizontal', 'vertical']);
|
|||||||
|
|
||||||
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
||||||
|
|
||||||
|
const SidebarPlaylistFolderViewSchema = z.enum(['single', 'tree', 'navigation']);
|
||||||
|
|
||||||
const CollectionSchema = z.object({
|
const CollectionSchema = z.object({
|
||||||
filterQueryString: z.string(),
|
filterQueryString: z.string(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -500,6 +502,11 @@ export const GeneralSettingsSchema = z.object({
|
|||||||
sidebarCollapseShared: z.boolean(),
|
sidebarCollapseShared: z.boolean(),
|
||||||
sidebarItems: z.array(SidebarItemTypeSchema),
|
sidebarItems: z.array(SidebarItemTypeSchema),
|
||||||
sidebarPanelOrder: z.array(SidebarPanelTypeSchema),
|
sidebarPanelOrder: z.array(SidebarPanelTypeSchema),
|
||||||
|
sidebarPlaylistFolders: z.boolean(),
|
||||||
|
sidebarPlaylistFolderSeparator: z.string().min(1),
|
||||||
|
sidebarPlaylistFolderTreeIndent: z.number().int().min(0).max(64),
|
||||||
|
sidebarPlaylistFolderTreeLineColor: z.string(),
|
||||||
|
sidebarPlaylistFolderView: SidebarPlaylistFolderViewSchema,
|
||||||
sidebarPlaylistList: z.boolean(),
|
sidebarPlaylistList: z.boolean(),
|
||||||
sidebarPlaylistListFilterRegex: z.string(),
|
sidebarPlaylistListFilterRegex: z.string(),
|
||||||
sidebarPlaylistSorting: z.boolean(),
|
sidebarPlaylistSorting: z.boolean(),
|
||||||
@@ -1169,6 +1176,11 @@ const initialState: SettingsState = {
|
|||||||
sidebarCollapseShared: false,
|
sidebarCollapseShared: false,
|
||||||
sidebarItems,
|
sidebarItems,
|
||||||
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
|
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
|
||||||
|
sidebarPlaylistFolders: true,
|
||||||
|
sidebarPlaylistFolderSeparator: '/',
|
||||||
|
sidebarPlaylistFolderTreeIndent: 16,
|
||||||
|
sidebarPlaylistFolderTreeLineColor: '',
|
||||||
|
sidebarPlaylistFolderView: 'single',
|
||||||
sidebarPlaylistList: true,
|
sidebarPlaylistList: true,
|
||||||
sidebarPlaylistListFilterRegex: '',
|
sidebarPlaylistListFilterRegex: '',
|
||||||
sidebarPlaylistSorting: false,
|
sidebarPlaylistSorting: false,
|
||||||
@@ -2552,6 +2564,21 @@ export const useCollections = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolders = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolders, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolderSeparator = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolderSeparator, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolderView = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolderView, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolderTreeIndent = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolderTreeIndent, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolderTreeLineColor = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolderTreeLineColor, shallow);
|
||||||
|
|
||||||
export const useSidebarPlaylistList = () =>
|
export const useSidebarPlaylistList = () =>
|
||||||
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
LuChevronLast,
|
LuChevronLast,
|
||||||
LuChevronLeft,
|
LuChevronLeft,
|
||||||
LuChevronRight,
|
LuChevronRight,
|
||||||
|
LuChevronsDownUp,
|
||||||
|
LuChevronsUpDown,
|
||||||
LuChevronUp,
|
LuChevronUp,
|
||||||
LuCircle,
|
LuCircle,
|
||||||
LuCircleCheck,
|
LuCircleCheck,
|
||||||
@@ -250,6 +252,7 @@ export const AppIcon = {
|
|||||||
check: LuCheck,
|
check: LuCheck,
|
||||||
circle: LuCircle,
|
circle: LuCircle,
|
||||||
clipboardCopy: LuClipboardCopy,
|
clipboardCopy: LuClipboardCopy,
|
||||||
|
collapseAll: LuChevronsDownUp,
|
||||||
collection: LuPackage2,
|
collection: LuPackage2,
|
||||||
delete: LuTrash,
|
delete: LuTrash,
|
||||||
disc: LuDisc,
|
disc: LuDisc,
|
||||||
@@ -269,6 +272,7 @@ export const AppIcon = {
|
|||||||
emptySongImage: LuMusic,
|
emptySongImage: LuMusic,
|
||||||
error: LuShieldAlert,
|
error: LuShieldAlert,
|
||||||
expand: LuExpand,
|
expand: LuExpand,
|
||||||
|
expandAll: LuChevronsUpDown,
|
||||||
externalLink: LuExternalLink,
|
externalLink: LuExternalLink,
|
||||||
favorite: LuHeart,
|
favorite: LuHeart,
|
||||||
fileJson: LuFileJson,
|
fileJson: LuFileJson,
|
||||||
|
|||||||
Reference in New Issue
Block a user