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

@ -8,7 +8,7 @@ import { useParams } from 'react-router';
import styled from 'styled-components';
import { api } from '/@/renderer/api';
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 {
Button,
DropdownMenu,
@ -79,10 +79,14 @@ const HeaderItems = styled.div`
`;
interface PlaylistDetailHeaderProps {
handleToggleShowQueryBuilder: () => void;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailSongListHeader = ({ tableRef }: PlaylistDetailHeaderProps) => {
export const PlaylistDetailSongListHeader = ({
tableRef,
handleToggleShowQueryBuilder,
}: PlaylistDetailHeaderProps) => {
const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient();
@ -365,18 +369,36 @@ export const PlaylistDetailSongListHeader = ({ tableRef }: PlaylistDetailHeaderP
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<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
disabled
onClick={() => handlePlay(Play.NEXT)}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue (last)
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
onClick={() => handlePlay(Play.LAST)}
>
Add to queue (next)
Delete playlist
</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>
</Flex>

View file

@ -1,17 +1,33 @@
import { useState, useImperativeHandle, forwardRef } from 'react';
import { Flex, Group, ScrollArea } from '@mantine/core';
import { useState } from 'react';
import { Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import clone from 'lodash/clone';
import get from 'lodash/get';
import setWith from 'lodash/setWith';
import { nanoid } from 'nanoid';
import { NDSongQueryFields } from '/@/renderer/api/navidrome.types';
import { Button, DropdownMenu, NumberInput, QueryBuilder } from '/@/renderer/components';
import {
Button,
DropdownMenu,
MotionFlex,
NumberInput,
QueryBuilder,
ScrollArea,
Select,
} from '/@/renderer/components';
import {
convertNDQueryToQueryGroup,
convertQueryGroupToNDQuery,
} from '/@/renderer/features/playlists/utils';
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 = {
groupIndex: number[];
@ -25,322 +41,410 @@ type DeleteArgs = {
};
interface PlaylistQueryBuilderProps {
onSave: (parsedFilter: any) => void;
onSaveAs: (parsedFilter: any) => void;
isSaving?: boolean;
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;
sortBy: SongListSort;
sortOrder: SortOrder;
}
export const PlaylistQueryBuilder = forwardRef(
({ query, onSave, onSaveAs }: PlaylistQueryBuilderProps, ref) => {
const [filters, setFilters] = useState<any>(
convertNDQueryToQueryGroup(query) || {
all: [],
},
);
const DEFAULT_QUERY = {
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: 'all' as 'all' | 'any',
uniqueId: nanoid(),
};
useImperativeHandle(ref, () => ({
reset() {
setFilters({
all: [],
});
},
}));
export const PlaylistQueryBuilder = ({
sortOrder,
sortBy,
limit,
isSaving,
query,
onSave,
onSaveAs,
}: PlaylistQueryBuilderProps) => {
const [filters, setFilters] = useState<QueryBuilderGroup>(
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
);
const setFilterHandler = (newFilters: QueryBuilderGroup) => {
setFilters(newFilters);
// onSave(newFilters);
};
const extraFiltersForm = useForm({
initialValues: {
limit,
sortBy,
sortOrder,
},
});
const handleSave = () => {
onSave(convertQueryGroupToNDQuery(filters));
};
const handleResetFilters = () => {
if (query) {
setFilters(convertNDQueryToQueryGroup(query));
} else {
setFilters(DEFAULT_QUERY);
}
};
const handleSaveAs = () => {
onSaveAs(convertQueryGroupToNDQuery(filters));
};
const handleClearFilters = () => {
setFilters(DEFAULT_QUERY);
};
const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const setFilterHandler = (newFilters: QueryBuilderGroup) => {
setFilters(newFilters);
};
const getPath = (level: number) => {
if (level === 0) return 'group';
const handleSave = () => {
onSave(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
const handleSaveAs = () => {
onSaveAs(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
return `${str.join('.')}.group`;
};
const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getPath(level);
const updatedFilters = setWith(
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 getPath = (level: number) => {
if (level === 0) return 'group';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.rules`;
return `${str.join('.')}.group`;
};
const handleAddRule = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getPath(level);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: 'any',
uniqueId: nanoid(),
},
],
clone,
);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path),
{
field: 'title',
operator: 'contains',
uniqueId: nanoid(),
value: null,
},
],
clone,
);
setFilterHandler(updatedFilters);
};
setFilterHandler(updatedFilters);
};
const handleDeleteRuleGroup = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const handleDeleteRule = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const getPath = (level: number) => {
if (level === 0) return 'group';
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;
// 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 str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
if (i !== groupIndex.length - 1) {
str.push(`group[${groupIndex[i]}]`);
} else {
str.push(`group`);
}
}
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);
return `${str.join('.')}`;
};
const handleChangeOperator = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getPath(level);
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);
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>
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 = [];
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 { Stack } from '@mantine/core';
import { Group, Stack } from '@mantine/core';
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 { PlaylistDetailSongListContent } from '../components/playlist-detail-song-list-content';
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 { AppRoute } from '/@/renderer/router/routes';
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 { useCurrentServer } from '/@/renderer/store';
import { ServerType } from '/@/renderer/api/types';
const PlaylistDetailSongListRoute = () => {
const navigate = useNavigate();
const tableRef = useRef<AgGridReactType | null>(null);
const { playlistId } = useParams() as { playlistId: string };
const currentServer = useCurrentServer();
const detailQuery = usePlaylistDetail({ id: playlistId });
const createPlaylistMutation = useCreatePlaylist();
const deletePlaylistMutation = useDeletePlaylist();
const handleSave = (filter: Record<string, any>) => {
const handleSave = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => {
const rules = {
...filter,
order: 'desc',
sort: 'year',
limit: extraFilters.limit || undefined,
order: extraFilters.sortOrder || 'desc',
sort: extraFilters.sortBy || 'dateAdded',
};
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 (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
<Stack
h="100%"
spacing={0}
>
<PlaylistDetailSongListHeader tableRef={tableRef} />
<Paper
sx={{
maxHeight: '35vh',
padding: '1rem',
}}
w="100%"
<PlaylistDetailSongListHeader
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
tableRef={tableRef}
/>
<AnimatePresence
custom={{ isQueryBuilderExpanded }}
initial={false}
>
{!detailQuery?.isLoading && (
<PlaylistQueryBuilder
query={detailQuery?.data?.rules || { all: [] }}
onSave={handleSave}
onSaveAs={handleSaveAs}
/>
{(isSmartPlaylist || showQueryBuilder) && (
<motion.div
animate="animate"
custom={{ isQueryBuilderExpanded }}
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} />
</Stack>
</AnimatedPage>