Finalize base features for smart playlist editor

This commit is contained in:
jeffvli 2023-01-05 02:27:29 -08:00
parent 0c7a0cc88a
commit df4f05b14c
6 changed files with 739 additions and 624 deletions

View file

@ -353,40 +353,74 @@ export type NDPlaylistSongList = {
}; };
export const NDSongQueryFields = [ export const NDSongQueryFields = [
{ label: 'Title', value: 'title' }, { label: 'Album', type: 'string', value: 'album' },
{ label: 'Album', value: 'album' }, { label: 'Album Artist', type: 'string', value: 'albumartist' },
{ label: 'Artist', value: 'artist' }, { label: 'Album Comment', type: 'string', value: 'albumcomment' },
{ label: 'Album artist', value: 'albumartist' }, { label: 'Album Type', type: 'string', value: 'albumtype' },
{ label: 'Has cover art', value: 'hascoverart' }, { label: 'Artist', type: 'string', value: 'artist' },
{ label: 'Track number', value: 'tracknumber' }, { label: 'Bitrate', type: 'number', value: 'bitrate' },
{ label: 'Disc number', value: 'discnumber' }, { label: 'BPM', type: 'number', value: 'bpm' },
{ label: 'Year', value: 'year' }, { label: 'Catalog Number', type: 'string', value: 'catalognumber' },
{ label: 'Size', value: 'size' }, { label: 'Channels', type: 'number', value: 'channels' },
{ label: 'Is compilation', value: 'compilation' }, { label: 'Comment', type: 'string', value: 'comment' },
{ label: 'Date added', value: 'dateadded' }, { label: 'Date Added', type: 'date', value: 'dateadded' },
{ label: 'Date modified', value: 'datemodified' }, { label: 'Date Favorited', type: 'date', value: 'dateloved' },
{ label: 'Disc subtitle', value: 'discsubtitle' }, { label: 'Date Last Played', type: 'date', value: 'lastplayed' },
{ label: 'Comment', value: 'comment' }, { label: 'Date Modified', type: 'date', value: 'datemodified' },
{ label: 'Lyrics', value: 'lyrics' }, { label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
{ label: 'Sort title', value: 'sorttitle' }, { label: 'Disc Number', type: 'number', value: 'discnumber' },
{ label: 'Sort album', value: 'sortalbum' }, { label: 'Duration', type: 'number', value: 'duration' },
{ label: 'Sort artist', value: 'sortartist' }, { label: 'File Path', type: 'string', value: 'filepath' },
{ label: 'Sort album artist', value: 'sortalbumartist' }, { label: 'File Type', type: 'string', value: 'filetype' },
{ label: 'Album type', value: 'albumtype' }, { label: 'Genre', type: 'string', value: 'genre' },
{ label: 'Album comment', value: 'albumcomment' }, { label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
{ label: 'Catalog number', value: 'catalognumber' }, { label: 'Is Compilation', type: 'boolean', value: 'compilation' },
{ label: 'File path', value: 'filepath' }, { label: 'Is Favorite', type: 'boolean', value: 'loved' },
{ label: 'File type', value: 'filetype' }, { label: 'Lyrics', type: 'string', value: 'lyrics' },
{ label: 'Duration', value: 'duration' }, { label: 'Name', type: 'string', value: 'title' },
{ label: 'Bitrate', value: 'bitrate' }, { label: 'Play Count', type: 'number', value: 'playcount' },
{ label: 'BPM', value: 'bpm' }, { label: 'Rating', type: 'number', value: 'rating' },
{ label: 'Channels', value: 'channels' }, { label: 'Size', type: 'number', value: 'size' },
{ label: 'Genre', value: 'genre' }, { label: 'Sort Album', type: 'string', value: 'sortalbum' },
{ label: 'Is favorite', value: 'loved' }, { label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
{ label: 'Date favorited', value: 'dateloved' }, { label: 'Sort Artist', type: 'string', value: 'sortartist' },
{ label: 'Last played', value: 'lastplayed' }, { label: 'Sort Name', type: 'string', value: 'sorttitle' },
{ label: 'Play count', value: 'playcount' }, { label: 'Track Number', type: 'number', value: 'tracknumber' },
{ label: 'Rating', value: 'rating' }, { label: 'Year', type: 'number', value: 'year' },
];
export const NDSongQueryDateOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'is before', value: 'before' },
{ label: 'is after', value: 'after' },
{ label: 'is in the last', value: 'inTheLast' },
{ label: 'is not in the last', value: 'notInTheLast' },
{ label: 'is in the range', value: 'inTheRange' },
];
export const NDSongQueryStringOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'starts with', value: 'startsWith' },
{ label: 'ends with', value: 'endsWith' },
];
export const NDSongQueryBooleanOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
];
export const NDSongQueryNumberOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'is greater than', value: 'gt' },
{ label: 'is less than', value: 'lt' },
{ label: 'is in the range', value: 'inTheRange' },
]; ];
export type NDUserListParams = { export type NDUserListParams = {

View file

@ -30,7 +30,7 @@ type DeleteArgs = {
}; };
interface QueryBuilderProps { interface QueryBuilderProps {
data: Record<string, any>; data: Record<string, any>;
filters: { label: string; value: string }[]; filters: { label: string; type: string; value: string }[];
groupIndex: number[]; groupIndex: number[];
level: number; level: number;
onAddRule: (args: AddArgs) => void; onAddRule: (args: AddArgs) => void;
@ -39,8 +39,16 @@ interface QueryBuilderProps {
onChangeOperator: (args: any) => void; onChangeOperator: (args: any) => void;
onChangeType: (args: any) => void; onChangeType: (args: any) => void;
onChangeValue: (args: any) => void; onChangeValue: (args: any) => void;
onClearFilters: () => void;
onDeleteRule: (args: DeleteArgs) => void; onDeleteRule: (args: DeleteArgs) => void;
onDeleteRuleGroup: (args: DeleteArgs) => void; onDeleteRuleGroup: (args: DeleteArgs) => void;
onResetFilters: () => void;
operators: {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
uniqueId: string; uniqueId: string;
} }
@ -53,8 +61,11 @@ export const QueryBuilder = ({
onAddRuleGroup, onAddRuleGroup,
onChangeType, onChangeType,
onChangeField, onChangeField,
operators,
onChangeOperator, onChangeOperator,
onChangeValue, onChangeValue,
onClearFilters,
onResetFilters,
groupIndex, groupIndex,
uniqueId, uniqueId,
filters, filters,
@ -95,7 +106,7 @@ export const QueryBuilder = ({
> >
<RiAddLine size={20} /> <RiAddLine size={20} />
</Button> </Button>
<DropdownMenu> <DropdownMenu position="bottom-start">
<DropdownMenu.Target> <DropdownMenu.Target>
<Button <Button
p={0} p={0}
@ -107,18 +118,33 @@ export const QueryBuilder = ({
</DropdownMenu.Target> </DropdownMenu.Target>
<DropdownMenu.Dropdown> <DropdownMenu.Dropdown>
<DropdownMenu.Item onClick={handleAddRuleGroup}>Add rule group</DropdownMenu.Item> <DropdownMenu.Item onClick={handleAddRuleGroup}>Add rule group</DropdownMenu.Item>
{level > 0 && ( {level > 0 && (
<DropdownMenu.Item onClick={handleDeleteRuleGroup}> <DropdownMenu.Item onClick={handleDeleteRuleGroup}>
Remove rule group Remove rule group
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
{level === 0 && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
$danger
onClick={onResetFilters}
>
Reset to default
</DropdownMenu.Item>
<DropdownMenu.Item
$danger
onClick={onClearFilters}
>
Clear filters
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
</Group> </Group>
<AnimatePresence <AnimatePresence initial={false}>
key="advanced-filter-option"
initial={false}
>
{data?.rules?.map((rule: QueryBuilderRule) => ( {data?.rules?.map((rule: QueryBuilderRule) => (
<motion.div <motion.div
key={rule.uniqueId} key={rule.uniqueId}
@ -131,9 +157,9 @@ export const QueryBuilder = ({
data={rule} data={rule}
filters={filters} filters={filters}
groupIndex={groupIndex || []} groupIndex={groupIndex || []}
// groupValue={groupValue}
level={level} level={level}
noRemove={data?.rules?.length === 1} noRemove={data?.rules?.length === 1}
operators={operators}
onChangeField={onChangeField} onChangeField={onChangeField}
onChangeOperator={onChangeOperator} onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue} onChangeValue={onChangeValue}
@ -143,10 +169,7 @@ export const QueryBuilder = ({
))} ))}
</AnimatePresence> </AnimatePresence>
{data?.group && ( {data?.group && (
<AnimatePresence <AnimatePresence initial={false}>
key="advanced-filter-group"
initial={false}
>
{data.group?.map((group: QueryBuilderGroup, index: number) => ( {data.group?.map((group: QueryBuilderGroup, index: number) => (
<motion.div <motion.div
key={group.uniqueId} key={group.uniqueId}
@ -160,6 +183,7 @@ export const QueryBuilder = ({
filters={filters} filters={filters}
groupIndex={[...(groupIndex || []), index]} groupIndex={[...(groupIndex || []), index]}
level={level + 1} level={level + 1}
operators={operators}
uniqueId={group.uniqueId} uniqueId={group.uniqueId}
onAddRule={onAddRule} onAddRule={onAddRule}
onAddRuleGroup={onAddRuleGroup} onAddRuleGroup={onAddRuleGroup}
@ -167,8 +191,10 @@ export const QueryBuilder = ({
onChangeOperator={onChangeOperator} onChangeOperator={onChangeOperator}
onChangeType={onChangeType} onChangeType={onChangeType}
onChangeValue={onChangeValue} onChangeValue={onChangeValue}
onClearFilters={onClearFilters}
onDeleteRule={onDeleteRule} onDeleteRule={onDeleteRule}
onDeleteRuleGroup={onDeleteRuleGroup} onDeleteRuleGroup={onDeleteRuleGroup}
onResetFilters={onResetFilters}
/> />
</motion.div> </motion.div>
))} ))}

View file

@ -1,27 +1,11 @@
import { Group } from '@mantine/core'; import { Group } from '@mantine/core';
import dayjs from 'dayjs'; import { useState } from 'react';
import { RiSubtractLine } from 'react-icons/ri'; import { RiSubtractLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button'; import { Button } from '/@/renderer/components/button';
import { TextInput } from '/@/renderer/components/input'; import { NumberInput, TextInput } from '/@/renderer/components/input';
import { Select } from '/@/renderer/components/select'; import { Select } from '/@/renderer/components/select';
import { QueryBuilderRule } from '/@/renderer/types'; import { QueryBuilderRule } from '/@/renderer/types';
const operators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'is greater than', value: 'gt' },
{ label: 'is less than', value: 'lt' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'starts with', value: 'startsWith' },
{ label: 'ends with', value: 'endsWith' },
{ label: 'is in the range', value: 'inTheRange' },
{ label: 'before', value: 'before' },
{ label: 'after', value: 'after' },
{ label: 'is in the last', value: 'inTheLast' },
{ label: 'is not in the last', value: 'notInTheLast' },
];
type DeleteArgs = { type DeleteArgs = {
groupIndex: number[]; groupIndex: number[];
level: number; level: number;
@ -30,22 +14,98 @@ type DeleteArgs = {
interface QueryOptionProps { interface QueryOptionProps {
data: QueryBuilderRule; data: QueryBuilderRule;
filters: { label: string; value: string }[]; filters: { label: string; type: string; value: string }[];
groupIndex: number[]; groupIndex: number[];
// groupValue: string;
level: number; level: number;
noRemove: boolean; noRemove: boolean;
onChangeField: (args: any) => void; onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void; onChangeOperator: (args: any) => void;
onChangeValue: (args: any) => void; onChangeValue: (args: any) => void;
onDeleteRule: (args: DeleteArgs) => void; onDeleteRule: (args: DeleteArgs) => void;
operators: {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
} }
const QueryValueInput = ({ onChange, type, ...props }: any) => {
const [numberRange, setNumberRange] = useState([0, 0]);
switch (type) {
case 'string':
return (
<TextInput
size="sm"
onChange={onChange}
{...props}
/>
);
case 'number':
return (
<NumberInput
size="sm"
onChange={onChange}
{...props}
/>
);
case 'date':
return (
<TextInput
size="sm"
onChange={onChange}
{...props}
/>
);
case 'dateRange':
return (
<>
<NumberInput
{...props}
defaultValue={props.defaultValue?.[0]}
maxWidth={81}
width="10%"
onChange={(e) => {
const newRange = [e || 0, numberRange[1]];
setNumberRange(newRange);
onChange(newRange);
}}
/>
<NumberInput
{...props}
defaultValue={props.defaultValue?.[1]}
maxWidth={81}
width="10%"
onChange={(e) => {
const newRange = [numberRange[0], e || 0];
setNumberRange(newRange);
onChange(newRange);
}}
/>
</>
);
case 'boolean':
return (
<Select
data={[]}
{...props}
/>
);
default:
return <></>;
}
};
export const QueryBuilderOption = ({ export const QueryBuilderOption = ({
data, data,
filters, filters,
level, level,
onDeleteRule, onDeleteRule,
operators,
groupIndex, groupIndex,
noRemove, noRemove,
onChangeField, onChangeField,
@ -67,8 +127,6 @@ export const QueryBuilderOption = ({
}; };
const handleChangeValue = (e: any) => { const handleChangeValue = (e: any) => {
console.log('e', e);
const isDirectValue = const isDirectValue =
typeof e === 'string' || typeof e === 'string' ||
typeof e === 'number' || typeof e === 'number' ||
@ -84,14 +142,25 @@ export const QueryBuilderOption = ({
}); });
} }
const isDate = e instanceof Date; // const isDate = e instanceof Date;
if (isDate) { // if (isDate) {
// return onChangeValue({
// groupIndex,
// level,
// uniqueId,
// value: dayjs(e).format('YYYY-MM-DD'),
// });
// }
const isArray = Array.isArray(e);
if (isArray) {
return onChangeValue({ return onChangeValue({
groupIndex, groupIndex,
level, level,
uniqueId, uniqueId,
value: dayjs(e).format('YYYY-MM-DD'), value: e,
}); });
} }
@ -103,233 +172,8 @@ export const QueryBuilderOption = ({
}); });
}; };
// const filterOperatorMap = { const fieldType = filters.find((f) => f.value === field)?.type;
// date: ( const operatorsByFieldType = operators[fieldType as keyof typeof operators];
// <Select
// searchable
// data={DATE_FILTER_OPTIONS_DATA}
// maxWidth={175}
// size="xs"
// value={operator}
// width="20%"
// onChange={handleChangeOperator}
// />
// ),
// id: (
// <Select
// searchable
// data={ID_FILTER_OPTIONS_DATA}
// maxWidth={175}
// size="xs"
// value={operator}
// width="20%"
// onChange={handleChangeOperator}
// />
// ),
// number: (
// <Select
// searchable
// data={NUMBER_FILTER_OPTIONS_DATA}
// maxWidth={175}
// size="xs"
// value={operator}
// width="20%"
// onChange={handleChangeOperator}
// />
// ),
// string: (
// <Select
// searchable
// data={STRING_FILTER_OPTIONS_DATA}
// maxWidth={175}
// size="xs"
// value={operator}
// width="20%"
// onChange={handleChangeOperator}
// />
// ),
// };
// const filterInputValueMap = {
// 'albumArtists.genres.id': (
// <Select
// searchable
// data={genresData?.albumArtist || []}
// maxWidth={175}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'albumArtists.name': (
// <TextInput
// maxWidth={175}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'albumArtists.ratings.value': (
// <NumberInput
// max={5}
// maxWidth={175}
// min={0}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'albums.dateAdded': (
// <DatePicker
// initialLevel="year"
// maxDate={dayjs(new Date()).year(3000).toDate()}
// maxWidth={175}
// minDate={dayjs(new Date()).year(1950).toDate()}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'albums.genres.id': (
// <Select
// searchable
// data={genresData?.album || []}
// maxWidth={175}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'albums.name': (
// <TextInput
// maxWidth={175}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'albums.playCount': (
// <NumberInput
// maxWidth={175}
// min={0}
// size="xs"
// value={value}
// width="20%"
// onChange={(e) => handleChangeValue(e)}
// />
// ),
// 'albums.ratings.value': (
// <NumberInput
// max={5}
// maxWidth={175}
// min={0}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'albums.releaseDate': (
// <DatePicker
// initialLevel="year"
// maxDate={dayjs(new Date()).year(3000).toDate()}
// maxWidth={175}
// minDate={dayjs(new Date()).year(1950).toDate()}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'albums.releaseYear': (
// <NumberInput
// maxWidth={175}
// min={0}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'artists.genres.id': (
// <Select
// searchable
// data={genresData?.artist || []}
// maxWidth={175}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'artists.name': (
// <TextInput
// maxWidth={175}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'artists.ratings.value': (
// <NumberInput
// max={5}
// maxWidth={175}
// min={0}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'songs.genres.id': (
// <Select
// searchable
// data={genresData?.song || []}
// maxWidth={175}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'songs.name': (
// <TextInput
// maxWidth={175}
// size="xs"
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'songs.playCount': (
// <NumberInput
// maxWidth={175}
// min={0}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// 'songs.ratings.value': (
// <NumberInput
// max={5}
// maxWidth={175}
// min={0}
// size="xs"
// value={value}
// width="20%"
// onChange={handleChangeValue}
// />
// ),
// };
const ml = (level + 1) * 10; const ml = (level + 1) * 10;
return ( return (
@ -337,7 +181,7 @@ export const QueryBuilderOption = ({
<Select <Select
searchable searchable
data={filters} data={filters}
maxWidth={175} maxWidth={170}
size="sm" size="sm"
value={field} value={field}
width="20%" width="20%"
@ -345,19 +189,20 @@ export const QueryBuilderOption = ({
/> />
<Select <Select
searchable searchable
data={operators} data={operatorsByFieldType || []}
disabled={!field} disabled={!field}
maxWidth={175} maxWidth={170}
size="sm" size="sm"
value={operator} value={operator}
width="20%" width="20%"
onChange={handleChangeOperator} onChange={handleChangeOperator}
/> />
{field ? ( {field ? (
<TextInput <QueryValueInput
defaultValue={value} defaultValue={value}
maxWidth={175} maxWidth={170}
size="sm" size="sm"
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
width="20%" width="20%"
onChange={handleChangeValue} onChange={handleChangeValue}
/> />
@ -365,14 +210,12 @@ export const QueryBuilderOption = ({
<TextInput <TextInput
disabled disabled
defaultValue={value} defaultValue={value}
maxWidth={175} maxWidth={170}
size="sm" size="sm"
width="20%" width="20%"
onChange={handleChangeValue} onChange={handleChangeValue}
/> />
)} )}
{/* // filterOperatorMap[ // OPTIONS_MAP[field as keyof typeof OPTIONS_MAP].type as keyof typeof
filterOperatorMap // ] */}
<Button <Button
disabled={noRemove} disabled={noRemove}
px={5} px={5}

View file

@ -8,7 +8,7 @@ import { useParams } from 'react-router';
import styled from 'styled-components'; import styled from 'styled-components';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types'; import { PlaylistSongListQuery, ServerType, SongListSort, SortOrder } from '/@/renderer/api/types';
import { import {
Button, Button,
DropdownMenu, DropdownMenu,
@ -79,10 +79,14 @@ const HeaderItems = styled.div`
`; `;
interface PlaylistDetailHeaderProps { interface PlaylistDetailHeaderProps {
handleToggleShowQueryBuilder: () => void;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const PlaylistDetailSongListHeader = ({ tableRef }: PlaylistDetailHeaderProps) => { export const PlaylistDetailSongListHeader = ({
tableRef,
handleToggleShowQueryBuilder,
}: PlaylistDetailHeaderProps) => {
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -365,18 +369,36 @@ export const PlaylistDetailSongListHeader = ({ tableRef }: PlaylistDetailHeaderP
</DropdownMenu.Target> </DropdownMenu.Target>
<DropdownMenu.Dropdown> <DropdownMenu.Dropdown>
<DropdownMenu.Item onClick={() => handlePlay(Play.NOW)}>Play</DropdownMenu.Item> <DropdownMenu.Item onClick={() => handlePlay(Play.NOW)}>Play</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => handlePlay(Play.LAST)}>
Add to queue (last)
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => handlePlay(Play.NEXT)}>
Add to queue (next)
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item <DropdownMenu.Item
disabled disabled
onClick={() => handlePlay(Play.NEXT)} onClick={() => handlePlay(Play.LAST)}
> >
Add to queue (last) Edit playlist
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
disabled disabled
onClick={() => handlePlay(Play.LAST)} onClick={() => handlePlay(Play.LAST)}
> >
Add to queue (next) Delete playlist
</DropdownMenu.Item> </DropdownMenu.Item>
{server?.type === ServerType.NAVIDROME && !detailQuery?.data?.rules && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
$danger
onClick={handleToggleShowQueryBuilder}
>
Toggle smart playlist editor
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
</Flex> </Flex>

View file

@ -1,17 +1,33 @@
import { useState, useImperativeHandle, forwardRef } from 'react'; import { useState } from 'react';
import { Flex, Group, ScrollArea } from '@mantine/core'; import { Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import clone from 'lodash/clone'; import clone from 'lodash/clone';
import get from 'lodash/get'; import get from 'lodash/get';
import setWith from 'lodash/setWith'; import setWith from 'lodash/setWith';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { NDSongQueryFields } from '/@/renderer/api/navidrome.types'; import {
import { Button, DropdownMenu, NumberInput, QueryBuilder } from '/@/renderer/components'; Button,
DropdownMenu,
MotionFlex,
NumberInput,
QueryBuilder,
ScrollArea,
Select,
} from '/@/renderer/components';
import { import {
convertNDQueryToQueryGroup, convertNDQueryToQueryGroup,
convertQueryGroupToNDQuery, convertQueryGroupToNDQuery,
} from '/@/renderer/features/playlists/utils'; } from '/@/renderer/features/playlists/utils';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types'; import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
import { RiMore2Fill } from 'react-icons/ri'; import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
import { SongListSort, SortOrder } from '/@/renderer/api/types';
import {
NDSongQueryBooleanOperators,
NDSongQueryDateOperators,
NDSongQueryFields,
NDSongQueryNumberOperators,
NDSongQueryStringOperators,
} from '/@/renderer/api/navidrome.types';
type AddArgs = { type AddArgs = {
groupIndex: number[]; groupIndex: number[];
@ -25,322 +41,410 @@ type DeleteArgs = {
}; };
interface PlaylistQueryBuilderProps { interface PlaylistQueryBuilderProps {
onSave: (parsedFilter: any) => void; isSaving?: boolean;
onSaveAs: (parsedFilter: any) => void; limit?: number;
onSave: (
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => void;
onSaveAs: (
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => void;
query: any; query: any;
sortBy: SongListSort;
sortOrder: SortOrder;
} }
export const PlaylistQueryBuilder = forwardRef( const DEFAULT_QUERY = {
({ query, onSave, onSaveAs }: PlaylistQueryBuilderProps, ref) => { group: [],
const [filters, setFilters] = useState<any>( rules: [
convertNDQueryToQueryGroup(query) || { {
all: [], field: '',
}, operator: '',
); uniqueId: nanoid(),
value: '',
},
],
type: 'all' as 'all' | 'any',
uniqueId: nanoid(),
};
useImperativeHandle(ref, () => ({ export const PlaylistQueryBuilder = ({
reset() { sortOrder,
setFilters({ sortBy,
all: [], limit,
}); isSaving,
}, query,
})); onSave,
onSaveAs,
}: PlaylistQueryBuilderProps) => {
const [filters, setFilters] = useState<QueryBuilderGroup>(
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
);
const setFilterHandler = (newFilters: QueryBuilderGroup) => { const extraFiltersForm = useForm({
setFilters(newFilters); initialValues: {
// onSave(newFilters); limit,
}; sortBy,
sortOrder,
},
});
const handleSave = () => { const handleResetFilters = () => {
onSave(convertQueryGroupToNDQuery(filters)); if (query) {
}; setFilters(convertNDQueryToQueryGroup(query));
} else {
setFilters(DEFAULT_QUERY);
}
};
const handleSaveAs = () => { const handleClearFilters = () => {
onSaveAs(convertQueryGroupToNDQuery(filters)); setFilters(DEFAULT_QUERY);
}; };
const handleAddRuleGroup = (args: AddArgs) => { const setFilterHandler = (newFilters: QueryBuilderGroup) => {
const { level, groupIndex } = args; setFilters(newFilters);
const filtersCopy = clone(filters); };
const getPath = (level: number) => { const handleSave = () => {
if (level === 0) return 'group'; onSave(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
const str = []; const handleSaveAs = () => {
for (const index of groupIndex) { onSaveAs(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
str.push(`group[${index}]`); };
}
return `${str.join('.')}.group`; const handleAddRuleGroup = (args: AddArgs) => {
}; const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getPath(level); const getPath = (level: number) => {
const updatedFilters = setWith( if (level === 0) return 'group';
filtersCopy,
path,
[
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: 'any',
uniqueId: nanoid(),
},
],
clone,
);
setFilterHandler(updatedFilters);
};
const handleDeleteRuleGroup = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const getPath = (level: number) => {
if (level === 0) return 'group';
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
if (i !== groupIndex.length - 1) {
str.push(`group[${groupIndex[i]}]`);
} else {
str.push(`group`);
}
}
return `${str.join('.')}`;
};
const path = getPath(level);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path).filter(
(group: QueryBuilderGroup) => group.uniqueId !== uniqueId,
),
],
clone,
);
setFilterHandler(updatedFilters);
};
const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return 'rules';
const str = []; const str = [];
for (const index of groupIndex) { for (const index of groupIndex) {
str.push(`group[${index}]`); str.push(`group[${index}]`);
} }
return `${str.join('.')}.rules`; return `${str.join('.')}.group`;
}; };
const handleAddRule = (args: AddArgs) => { const path = getPath(level);
const { level, groupIndex } = args; const updatedFilters = setWith(
const filtersCopy = clone(filters); filtersCopy,
path,
[
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: 'any',
uniqueId: nanoid(),
},
],
clone,
);
const path = getRulePath(level, groupIndex); setFilterHandler(updatedFilters);
const updatedFilters = setWith( };
filtersCopy,
path,
[
...get(filtersCopy, path),
{
field: 'title',
operator: 'contains',
uniqueId: nanoid(),
value: null,
},
],
clone,
);
setFilterHandler(updatedFilters); const handleDeleteRuleGroup = (args: DeleteArgs) => {
}; const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const handleDeleteRule = (args: DeleteArgs) => { const getPath = (level: number) => {
const { uniqueId, level, groupIndex } = args; if (level === 0) return 'group';
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const str = [];
const updatedFilters = setWith( for (let i = 0; i < groupIndex.length; i += 1) {
filtersCopy, if (i !== groupIndex.length - 1) {
path, str.push(`group[${groupIndex[i]}]`);
get(filtersCopy, path).filter((rule: QueryBuilderRule) => rule.uniqueId !== uniqueId), } else {
clone, str.push(`group`);
); }
setFilterHandler(updatedFilters);
};
const handleChangeField = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderGroup) => {
if (rule.uniqueId !== uniqueId) return rule;
// const defaultOperator = FILTER_OPTIONS_DATA.find(
// (option) => option.value === value,
// )?.default;
return {
...rule,
field: value,
operator: '',
value: '',
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeType = (args: any) => {
const { level, groupIndex, value } = args;
const filtersCopy = clone(filters);
if (level === 0) {
return setFilterHandler({ ...filtersCopy, type: value });
} }
const getTypePath = () => { return `${str.join('.')}`;
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
str.push(`group[${groupIndex[i]}]`);
}
return `${str.join('.')}`;
};
const path = getTypePath();
const updatedFilters = setWith(
filtersCopy,
path,
{
...get(filtersCopy, path),
type: value,
},
clone,
);
return setFilterHandler(updatedFilters);
}; };
const handleChangeOperator = (args: any) => { const path = getPath(level);
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex); const updatedFilters = setWith(
const updatedFilters = setWith( filtersCopy,
filtersCopy, path,
path, [...get(filtersCopy, path).filter((group: QueryBuilderGroup) => group.uniqueId !== uniqueId)],
get(filtersCopy, path).map((rule: QueryBuilderRule) => { clone,
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
operator: value,
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeValue = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
console.log('path', path);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
value,
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
return (
<Flex
direction="column"
h="100%"
justify="space-between"
>
<ScrollArea h="100%">
<QueryBuilder
data={filters}
filters={NDSongQueryFields}
groupIndex={[]}
level={0}
uniqueId={filters.uniqueId}
onAddRule={handleAddRule}
onAddRuleGroup={handleAddRuleGroup}
onChangeField={handleChangeField}
onChangeOperator={handleChangeOperator}
onChangeType={handleChangeType}
onChangeValue={handleChangeValue}
onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup}
/>
</ScrollArea>
<Group
align="flex-end"
p="1rem 1rem 0"
position="apart"
>
<NumberInput
label="Limit to"
width={75}
/>
<Group>
<Button
variant="filled"
onClick={handleSave}
>
Save
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
p="0.5em"
variant="default"
>
<RiMore2Fill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item onClick={handleSaveAs}>Save as</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Group>
</Flex>
); );
},
); setFilterHandler(updatedFilters);
};
const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return 'rules';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.rules`;
};
const handleAddRule = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path),
{
field: '',
operator: '',
uniqueId: nanoid(),
value: null,
},
],
clone,
);
setFilterHandler(updatedFilters);
};
const handleDeleteRule = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).filter((rule: QueryBuilderRule) => rule.uniqueId !== uniqueId),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeField = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderGroup) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
field: value,
operator: '',
value: '',
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeType = (args: any) => {
const { level, groupIndex, value } = args;
const filtersCopy = clone(filters);
if (level === 0) {
return setFilterHandler({ ...filtersCopy, type: value });
}
const getTypePath = () => {
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
str.push(`group[${groupIndex[i]}]`);
}
return `${str.join('.')}`;
};
const path = getTypePath();
const updatedFilters = setWith(
filtersCopy,
path,
{
...get(filtersCopy, path),
type: value,
},
clone,
);
return setFilterHandler(updatedFilters);
};
const handleChangeOperator = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
operator: value,
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeValue = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
value,
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const sortOptions = [{ label: 'Random', type: 'string', value: 'random' }, ...NDSongQueryFields];
return (
<MotionFlex
direction="column"
h="calc(100% - 3rem)"
justify="space-between"
>
<ScrollArea
h="100%"
p="1rem"
>
<QueryBuilder
data={filters}
filters={NDSongQueryFields}
groupIndex={[]}
level={0}
operators={{
boolean: NDSongQueryBooleanOperators,
date: NDSongQueryDateOperators,
number: NDSongQueryNumberOperators,
string: NDSongQueryStringOperators,
}}
uniqueId={filters.uniqueId}
onAddRule={handleAddRule}
onAddRuleGroup={handleAddRuleGroup}
onChangeField={handleChangeField}
onChangeOperator={handleChangeOperator}
onChangeType={handleChangeType}
onChangeValue={handleChangeValue}
onClearFilters={handleClearFilters}
onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup}
onResetFilters={handleResetFilters}
/>
</ScrollArea>
<Group
noWrap
align="flex-end"
p="1rem"
position="apart"
>
<Group
noWrap
w="100%"
>
<Select
searchable
data={sortOptions}
label="Sort"
maxWidth="20%"
width={125}
{...extraFiltersForm.getInputProps('sortBy')}
/>
<Select
data={[
{
label: 'Ascending',
value: 'asc',
},
{
label: 'Descending',
value: 'desc',
},
]}
label="Order"
maxWidth="20%"
width={125}
{...extraFiltersForm.getInputProps('sortOrder')}
/>
<NumberInput
label="Limit"
maxWidth="20%"
width={75}
{...extraFiltersForm.getInputProps('limit')}
/>
</Group>
<Group noWrap>
<Button
loading={isSaving}
variant="filled"
onClick={handleSaveAs}
>
Save as
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
disabled={isSaving}
p="0.5em"
variant="default"
>
<RiMore2Fill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
$danger
rightSection={
<RiSaveLine
color="var(--danger-color)"
size={15}
/>
}
onClick={handleSave}
>
Save and replace
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Group>
</MotionFlex>
);
};

View file

@ -1,7 +1,9 @@
import { useRef } from 'react'; import { useRef, useState } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core'; import { Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { AnimatePresence, motion, Variants } from 'framer-motion';
import { RiArrowDownSLine, RiArrowUpSLine } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router'; import { generatePath, useNavigate, useParams } from 'react-router';
import { PlaylistDetailSongListContent } from '../components/playlist-detail-song-list-content'; import { PlaylistDetailSongListContent } from '../components/playlist-detail-song-list-content';
import { PlaylistDetailSongListHeader } from '../components/playlist-detail-song-list-header'; import { PlaylistDetailSongListHeader } from '../components/playlist-detail-song-list-header';
@ -11,23 +13,30 @@ import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playli
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { Paper, toast } from '/@/renderer/components'; import { Button, Paper, Text, toast } from '/@/renderer/components';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form'; import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
import { useCurrentServer } from '/@/renderer/store';
import { ServerType } from '/@/renderer/api/types';
const PlaylistDetailSongListRoute = () => { const PlaylistDetailSongListRoute = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const tableRef = useRef<AgGridReactType | null>(null); const tableRef = useRef<AgGridReactType | null>(null);
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const currentServer = useCurrentServer();
const detailQuery = usePlaylistDetail({ id: playlistId }); const detailQuery = usePlaylistDetail({ id: playlistId });
const createPlaylistMutation = useCreatePlaylist(); const createPlaylistMutation = useCreatePlaylist();
const deletePlaylistMutation = useDeletePlaylist(); const deletePlaylistMutation = useDeletePlaylist();
const handleSave = (filter: Record<string, any>) => { const handleSave = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => {
const rules = { const rules = {
...filter, ...filter,
order: 'desc', limit: extraFilters.limit || undefined,
sort: 'year', order: extraFilters.sortOrder || 'desc',
sort: extraFilters.sortBy || 'dateAdded',
}; };
if (!detailQuery?.data) return; if (!detailQuery?.data) return;
@ -87,28 +96,105 @@ const PlaylistDetailSongListRoute = () => {
}); });
}; };
const smartPlaylistVariants: Variants = {
animate: (custom: { isQueryBuilderExpanded: boolean }) => {
return {
maxHeight: custom.isQueryBuilderExpanded ? '35vh' : '3.5em',
opacity: 1,
transition: {
duration: 0.3,
ease: 'easeInOut',
},
y: 0,
};
},
exit: {
opacity: 0,
y: -25,
},
initial: {
opacity: 0,
y: -25,
},
};
const isSmartPlaylist =
!detailQuery?.isLoading &&
detailQuery?.data?.rules &&
currentServer?.type === ServerType.NAVIDROME;
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
const handleToggleExpand = () => {
setIsQueryBuilderExpanded((prev) => !prev);
};
const handleToggleShowQueryBuilder = () => {
setShowQueryBuilder((prev) => !prev);
setIsQueryBuilderExpanded(true);
};
console.log('detailQuery?.data?.rules', detailQuery?.data?.rules);
return ( return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}> <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
<Stack <Stack
h="100%" h="100%"
spacing={0} spacing={0}
> >
<PlaylistDetailSongListHeader tableRef={tableRef} /> <PlaylistDetailSongListHeader
<Paper handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
sx={{ tableRef={tableRef}
maxHeight: '35vh', />
padding: '1rem', <AnimatePresence
}} custom={{ isQueryBuilderExpanded }}
w="100%" initial={false}
> >
{!detailQuery?.isLoading && ( {(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryBuilder <motion.div
query={detailQuery?.data?.rules || { all: [] }} animate="animate"
onSave={handleSave} custom={{ isQueryBuilderExpanded }}
onSaveAs={handleSaveAs} exit="exit"
/> initial="initial"
transition={{ duration: 0.2, ease: 'easeInOut' }}
variants={smartPlaylistVariants}
>
<Paper
h="100%"
pos="relative"
w="100%"
>
<Group
pt="1rem"
px="1rem"
>
<Button
compact
variant="default"
onClick={handleToggleExpand}
>
{isQueryBuilderExpanded ? (
<RiArrowUpSLine size={20} />
) : (
<RiArrowDownSLine size={20} />
)}
</Button>
<Text>Query Editor</Text>
</Group>
<PlaylistQueryBuilder
isSaving={createPlaylistMutation?.isLoading}
limit={detailQuery?.data?.rules?.limit}
query={detailQuery?.data?.rules}
sortBy={detailQuery?.data?.rules?.sort || 'year'}
sortOrder={detailQuery?.data?.rules?.order || 'desc'}
onSave={handleSave}
onSaveAs={handleSaveAs}
/>
</Paper>
</motion.div>
)} )}
</Paper> </AnimatePresence>
<PlaylistDetailSongListContent tableRef={tableRef} /> <PlaylistDetailSongListContent tableRef={tableRef} />
</Stack> </Stack>
</AnimatedPage> </AnimatedPage>