redesign smart playlist, add multisort

This commit is contained in:
jeffvli
2025-11-29 06:12:33 -08:00
parent 974e96c7b4
commit bb1705a774
8 changed files with 547 additions and 295 deletions
+6 -1
View File
@@ -123,6 +123,7 @@
"setting_other": "settings", "setting_other": "settings",
"share": "share", "share": "share",
"size": "size", "size": "size",
"sort": "sort",
"sortOrder": "order", "sortOrder": "order",
"tags": "tags", "tags": "tags",
"title": "title", "title": "title",
@@ -302,7 +303,11 @@
"queryEditor": { "queryEditor": {
"title": "query editor", "title": "query editor",
"input_optionMatchAll": "match all", "input_optionMatchAll": "match all",
"input_optionMatchAny": "match any" "input_optionMatchAny": "match any",
"addRuleGroup": "add rule group",
"removeRuleGroup": "remove rule group",
"resetToDefault": "reset to default",
"clearFilters": "clear filters"
}, },
"shareItem": { "shareItem": {
"allowDownloading": "allow downloading", "allowDownloading": "allow downloading",
+142 -117
View File
@@ -1,8 +1,9 @@
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option'; import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
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';
@@ -10,23 +11,6 @@ import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types'; import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
const FILTER_GROUP_OPTIONS_DATA = [
{
label: i18n.t('form.queryEditor.input', {
context: 'optionMatchAll',
postProcess: 'sentenceCase',
}),
value: 'all',
},
{
label: i18n.t('form.queryEditor.input', {
context: 'optionMatchAny',
postProcess: 'sentenceCase',
}),
value: 'any',
},
];
type AddArgs = { type AddArgs = {
groupIndex: number[]; groupIndex: number[];
level: number; level: number;
@@ -60,6 +44,7 @@ interface QueryBuilderProps {
string: { label: string; value: string }[]; string: { label: string; value: string }[];
}; };
playlists?: { label: string; value: string }[]; playlists?: { label: string; value: string }[];
saveActions?: React.ReactNode;
uniqueId: string; uniqueId: string;
} }
@@ -80,8 +65,28 @@ export const QueryBuilder = ({
onResetFilters, onResetFilters,
operators, operators,
playlists, playlists,
saveActions,
uniqueId, uniqueId,
}: QueryBuilderProps) => { }: QueryBuilderProps) => {
const { t } = useTranslation();
const FILTER_GROUP_OPTIONS_DATA = [
{
label: t('form.queryEditor.input', {
context: 'optionMatchAll',
postProcess: 'sentenceCase',
}),
value: 'all',
},
{
label: t('form.queryEditor.input', {
context: 'optionMatchAny',
postProcess: 'sentenceCase',
}),
value: 'any',
},
];
const handleAddRule = () => { const handleAddRule = () => {
onAddRule({ groupIndex, level }); onAddRule({ groupIndex, level });
}; };
@@ -99,124 +104,144 @@ export const QueryBuilder = ({
}; };
return ( return (
<Stack gap="sm" ml={`${level * 10}px`}> <Box
<Group gap="sm"> p="md"
<Select style={{
data={FILTER_GROUP_OPTIONS_DATA} border: '1px solid var(--theme-colors-border)',
maxWidth={175} borderRadius: 'var(--theme-radius-md)',
onChange={handleChangeType} marginLeft: level > 0 ? '20px' : '0px',
size="sm" }}
value={data.type} >
width="20%" <Stack gap="sm">
/> <Group gap="sm" justify="space-between" wrap="nowrap">
<ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" /> <Group gap="sm" wrap="nowrap">
<DropdownMenu position="bottom-start"> <Select
<DropdownMenu.Target> data={FILTER_GROUP_OPTIONS_DATA}
<ActionIcon maxWidth={250}
icon="ellipsisVertical" onChange={handleChangeType}
size="sm" size="sm"
style={{ value={data.type}
padding: 0, width={200}
}}
variant="subtle"
/> />
</DropdownMenu.Target> <ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" />
<DropdownMenu.Dropdown> <DropdownMenu position="bottom-start">
<DropdownMenu.Item <DropdownMenu.Target>
leftSection={<Icon icon="add" />} <ActionIcon
onClick={handleAddRuleGroup} icon="ellipsisVertical"
> size="sm"
Add rule group style={{
</DropdownMenu.Item> padding: 0,
}}
variant="subtle"
/>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
leftSection={<Icon icon="add" />}
onClick={handleAddRuleGroup}
>
{t('form.queryEditor.addRuleGroup', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
{level > 0 && ( {level > 0 && (
<DropdownMenu.Item <DropdownMenu.Item
leftSection={<Icon icon="delete" />} leftSection={<Icon icon="delete" />}
onClick={handleDeleteRuleGroup} onClick={handleDeleteRuleGroup}
> >
Remove rule group {t('form.queryEditor.removeRuleGroup', {
</DropdownMenu.Item> postProcess: 'sentenceCase',
)} })}
{level === 0 && ( </DropdownMenu.Item>
<> )}
<DropdownMenu.Divider /> {level === 0 && (
<DropdownMenu.Item <>
isDanger <DropdownMenu.Divider />
leftSection={<Icon color="error" icon="refresh" />} <DropdownMenu.Item
onClick={onResetFilters} isDanger
> leftSection={<Icon color="error" icon="refresh" />}
Reset to default onClick={onResetFilters}
</DropdownMenu.Item> >
<DropdownMenu.Item {t('form.queryEditor.resetToDefault', {
isDanger postProcess: 'sentenceCase',
leftSection={<Icon color="error" icon="delete" />} })}
onClick={onClearFilters} </DropdownMenu.Item>
> <DropdownMenu.Item
Clear filters isDanger
</DropdownMenu.Item> leftSection={<Icon color="error" icon="delete" />}
</> onClick={onClearFilters}
)} >
</DropdownMenu.Dropdown> {t('form.queryEditor.clearFilters', {
</DropdownMenu> postProcess: 'sentenceCase',
</Group> })}
<AnimatePresence initial={false}> </DropdownMenu.Item>
{data?.rules?.map((rule: QueryBuilderRule) => ( </>
<motion.div )}
animate={{ opacity: 1, x: 0 }} </DropdownMenu.Dropdown>
exit={{ opacity: 0, x: -25 }} </DropdownMenu>
initial={{ opacity: 0, x: -25 }} </Group>
key={rule.uniqueId} {level === 0 && saveActions}
transition={{ duration: 0.2, ease: 'easeInOut' }} </Group>
>
<QueryBuilderOption
data={rule}
filters={filters}
groupIndex={groupIndex || []}
level={level}
noRemove={data?.rules?.length === 1}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue}
onDeleteRule={onDeleteRule}
operators={operators}
selectData={playlists}
/>
</motion.div>
))}
</AnimatePresence>
{data?.group && (
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{data.group?.map((group: QueryBuilderGroup, index: number) => ( {data?.rules?.map((rule: QueryBuilderRule) => (
<motion.div <motion.div
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }} exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }} initial={{ opacity: 0, x: -25 }}
key={group.uniqueId} key={rule.uniqueId}
transition={{ duration: 0.2, ease: 'easeInOut' }} transition={{ duration: 0.2, ease: 'easeInOut' }}
> >
<QueryBuilder <QueryBuilderOption
data={group} data={rule}
filters={filters} filters={filters}
groupIndex={[...(groupIndex || []), index]} groupIndex={groupIndex || []}
level={level + 1} level={level}
onAddRule={onAddRule} noRemove={data?.rules?.length === 1}
onAddRuleGroup={onAddRuleGroup}
onChangeField={onChangeField} onChangeField={onChangeField}
onChangeOperator={onChangeOperator} onChangeOperator={onChangeOperator}
onChangeType={onChangeType}
onChangeValue={onChangeValue} onChangeValue={onChangeValue}
onClearFilters={onClearFilters}
onDeleteRule={onDeleteRule} onDeleteRule={onDeleteRule}
onDeleteRuleGroup={onDeleteRuleGroup}
onResetFilters={onResetFilters}
operators={operators} operators={operators}
playlists={playlists} selectData={playlists}
uniqueId={group.uniqueId}
/> />
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
)} {data?.group && (
</Stack> <AnimatePresence initial={false}>
{data.group?.map((group: QueryBuilderGroup, index: number) => (
<motion.div
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }}
key={group.uniqueId}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<QueryBuilder
data={group}
filters={filters}
groupIndex={[...(groupIndex || []), index]}
level={level + 1}
onAddRule={onAddRule}
onAddRuleGroup={onAddRuleGroup}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeType={onChangeType}
onChangeValue={onChangeValue}
onClearFilters={onClearFilters}
onDeleteRule={onDeleteRule}
onDeleteRuleGroup={onDeleteRuleGroup}
onResetFilters={onResetFilters}
operators={operators}
playlists={playlists}
uniqueId={group.uniqueId}
/>
</motion.div>
))}
</AnimatePresence>
)}
</Stack>
</Box>
); );
}; };
@@ -167,7 +167,7 @@ export const QueryBuilderOption = ({
const fieldType = filters.find((f) => f.value === field)?.type; const fieldType = filters.find((f) => f.value === field)?.type;
const operatorsByFieldType = operators[fieldType as keyof typeof operators]; const operatorsByFieldType = operators[fieldType as keyof typeof operators];
const ml = (level + 1) * 10; const ml = 20;
return ( return (
<Group gap="sm" ml={ml}> <Group gap="sm" ml={ml}>
@@ -57,9 +57,11 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
const smartPlaylist = queryBuilderRef.current?.getFilters(); const smartPlaylist = queryBuilderRef.current?.getFilters();
// New syntax: sortBy is now a single string with comma-separated fields and +/- prefix
// e.g., "+album,-year" means sort by album ascending, then year descending
const sortValue = const sortValue =
isSmartPlaylist && smartPlaylist?.extraFilters?.sortBy isSmartPlaylist && smartPlaylist?.extraFilters?.sortBy?.[0]
? smartPlaylist.extraFilters.sortBy.join(',') ? smartPlaylist.extraFilters.sortBy[0]
: undefined; : undefined;
const rules = const rules =
@@ -67,8 +69,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
? { ? {
...convertQueryGroupToNDQuery(smartPlaylist.filters), ...convertQueryGroupToNDQuery(smartPlaylist.filters),
limit: smartPlaylist.extraFilters.limit, limit: smartPlaylist.extraFilters.limit,
order: smartPlaylist.extraFilters.sortOrder, // order field is now optional - sort direction is embedded in sort field
sort: sortValue || 'dateAdded', sort: sortValue || '+dateAdded',
} }
: undefined; : undefined;
@@ -1,5 +1,4 @@
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import clone from 'lodash/clone'; import clone from 'lodash/clone';
import get from 'lodash/get'; import get from 'lodash/get';
@@ -10,11 +9,7 @@ import { useTranslation } from 'react-i18next';
import { QueryBuilder } from '/@/renderer/components/query-builder'; import { QueryBuilder } from '/@/renderer/components/query-builder';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { import { convertNDQueryToQueryGroup } from '/@/renderer/features/playlists/utils';
convertNDQueryToQueryGroup,
convertQueryGroupToNDQuery,
} from '/@/renderer/features/playlists/utils';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { import {
NDSongQueryBooleanOperators, NDSongQueryBooleanOperators,
@@ -25,15 +20,12 @@ import {
NDSongQueryStringOperators, NDSongQueryStringOperators,
} from '/@/shared/api/navidrome/navidrome-types'; } from '/@/shared/api/navidrome/navidrome-types';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Select } from '/@/shared/components/select/select'; import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { PlaylistListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { PlaylistListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types'; import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
@@ -49,22 +41,18 @@ type DeleteArgs = {
}; };
interface PlaylistQueryBuilderProps { interface PlaylistQueryBuilderProps {
isSaving?: boolean;
limit?: number; limit?: number;
onSave?: (
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
onSaveAs?: (
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
playlistId?: string; playlistId?: string;
query: any; query: any;
sortBy: SongListSort | SongListSort[]; sortBy: SongListSort | SongListSort[];
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
} }
type SortEntry = {
field: string;
order: 'asc' | 'desc';
};
const DEFAULT_QUERY = { const DEFAULT_QUERY = {
group: [], group: [],
rules: [ rules: [
@@ -92,16 +80,7 @@ export type PlaylistQueryBuilderRef = {
export const PlaylistQueryBuilder = forwardRef( export const PlaylistQueryBuilder = forwardRef(
( (
{ { limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
isSaving,
limit,
onSave,
onSaveAs,
playlistId,
query,
sortBy,
sortOrder,
}: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>, ref: Ref<PlaylistQueryBuilderRef>,
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -131,19 +110,91 @@ export const PlaylistQueryBuilder = forwardRef(
})); }));
}, [playlistId, playlists]); }, [playlistId, playlists]);
// Parse sortBy and sortOrder into array of sort entries
// Handle new syntax: comma-separated fields with +/- prefix (e.g., "+album,-year")
// Or old syntax: sortBy array + single sortOrder
const parseSortEntries = (): SortEntry[] => {
if (Array.isArray(sortBy) && sortBy.length > 0) {
const firstSort = sortBy[0];
// Check if first entry is a string with commas (new syntax as single string)
if (typeof firstSort === 'string' && firstSort.includes(',')) {
// Split the comma-separated string and parse each field
return firstSort.split(',').map((s) => {
const trimmed = s.trim();
const field =
trimmed.startsWith('+') || trimmed.startsWith('-')
? trimmed.slice(1)
: trimmed;
const order = trimmed.startsWith('-') ? 'desc' : 'asc';
return { field, order };
});
}
// Check if first entry has +/- prefix (new syntax as array of prefixed strings)
if (
typeof firstSort === 'string' &&
(firstSort.startsWith('+') || firstSort.startsWith('-'))
) {
return sortBy.map((s) => {
const field = s.startsWith('+') || s.startsWith('-') ? s.slice(1) : s;
const order = s.startsWith('-') ? 'desc' : 'asc';
return { field, order };
});
}
// Old syntax: array of fields with single order
return sortBy.map((field) => ({ field, order: sortOrder }));
}
if (sortBy && typeof sortBy === 'string') {
// Check if it's new syntax with +/- prefix
if (sortBy.includes(',') || sortBy.startsWith('+') || sortBy.startsWith('-')) {
return sortBy.split(',').map((s) => {
const trimmed = s.trim();
const field =
trimmed.startsWith('+') || trimmed.startsWith('-')
? trimmed.slice(1)
: trimmed;
const order = trimmed.startsWith('-') ? 'desc' : 'asc';
return { field, order };
});
}
// Single field, use provided sortOrder
return [{ field: sortBy, order: sortOrder }];
}
// Default
return [{ field: 'dateAdded', order: 'asc' }];
};
const extraFiltersForm = useForm({ const extraFiltersForm = useForm({
initialValues: { initialValues: {
limit, limit,
sortBy: Array.isArray(sortBy) ? sortBy : sortBy ? [sortBy] : [], sortEntries: parseSortEntries(),
sortOrder,
}, },
}); });
// Convert sort entries to new syntax: comma-separated with +/- prefix
const convertSortEntriesToSortString = (entries: SortEntry[]): string => {
return entries
.filter((entry) => entry.field) // Filter out empty fields
.map((entry) => {
const prefix = entry.order === 'desc' ? '-' : '+';
return `${prefix}${entry.field}`;
})
.join(',');
};
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getFilters: () => ({ getFilters: () => {
extraFilters: extraFiltersForm.values, const sortString = convertSortEntriesToSortString(
filters, extraFiltersForm.values.sortEntries,
}), );
return {
extraFilters: {
limit: extraFiltersForm.values.limit,
sortBy: sortString ? [sortString] : undefined,
// sortOrder is now optional and embedded in sortBy
},
filters,
};
},
})); }));
const handleResetFilters = () => { const handleResetFilters = () => {
@@ -162,24 +213,6 @@ export const PlaylistQueryBuilder = forwardRef(
setFilters(newFilters); setFilters(newFilters);
}; };
const handleSave = () => {
onSave?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
const handleSaveAs = () => {
onSaveAs?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
const openPreviewModal = () => {
const previewValue = convertQueryGroupToNDQuery(filters);
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
};
const handleAddRuleGroup = (args: AddArgs) => { const handleAddRuleGroup = (args: AddArgs) => {
const { groupIndex, level } = args; const { groupIndex, level } = args;
const filtersCopy = clone(filters); const filtersCopy = clone(filters);
@@ -413,96 +446,127 @@ export const PlaylistQueryBuilder = forwardRef(
...NDSongQueryFields, ...NDSongQueryFields,
]; ];
const handleAddSortEntry = () => {
extraFiltersForm.insertListItem('sortEntries', { field: '', order: 'asc' });
};
const handleRemoveSortEntry = (index: number) => {
extraFiltersForm.removeListItem('sortEntries', index);
};
const handleSortFieldChange = (index: number, value: string) => {
extraFiltersForm.setFieldValue(`sortEntries.${index}.field`, value);
};
const handleSortOrderChange = (index: number, value: 'asc' | 'desc') => {
extraFiltersForm.setFieldValue(`sortEntries.${index}.order`, value);
};
return ( return (
<Flex direction="column" h="calc(100% - 2rem)" justify="space-between"> <Flex direction="column" h="calc(100% - 2rem)" justify="space-between">
<ScrollArea> <ScrollArea>
<QueryBuilder <Stack gap="md" p="1rem">
data={filters} <QueryBuilder
filters={NDSongQueryFields} data={filters}
groupIndex={[]} filters={NDSongQueryFields}
level={0} groupIndex={[]}
onAddRule={handleAddRule} level={0}
onAddRuleGroup={handleAddRuleGroup} onAddRule={handleAddRule}
onChangeField={handleChangeField} onAddRuleGroup={handleAddRuleGroup}
onChangeOperator={handleChangeOperator} onChangeField={handleChangeField}
onChangeType={handleChangeType} onChangeOperator={handleChangeOperator}
onChangeValue={handleChangeValue} onChangeType={handleChangeType}
onClearFilters={handleClearFilters} onChangeValue={handleChangeValue}
onDeleteRule={handleDeleteRule} onClearFilters={handleClearFilters}
onDeleteRuleGroup={handleDeleteRuleGroup} onDeleteRule={handleDeleteRule}
onResetFilters={handleResetFilters} onDeleteRuleGroup={handleDeleteRuleGroup}
operators={{ onResetFilters={handleResetFilters}
boolean: NDSongQueryBooleanOperators, operators={{
date: NDSongQueryDateOperators, boolean: NDSongQueryBooleanOperators,
number: NDSongQueryNumberOperators, date: NDSongQueryDateOperators,
playlist: NDSongQueryPlaylistOperators, number: NDSongQueryNumberOperators,
string: NDSongQueryStringOperators, playlist: NDSongQueryPlaylistOperators,
}} string: NDSongQueryStringOperators,
playlists={playlistData} }}
uniqueId={filters.uniqueId} playlists={playlistData}
/> uniqueId={filters.uniqueId}
</ScrollArea>
<Group align="flex-end" justify="space-between" m="1rem" wrap="nowrap">
<Group align="flex-end" gap="sm" w="100%" wrap="nowrap">
<MultiSelect
data={sortOptions}
label="Sort"
maxWidth="50%"
searchable
{...extraFiltersForm.getInputProps('sortBy')}
/> />
<Select <Group align="flex-end" gap="sm" w="100%" wrap="nowrap">
data={[ <Stack gap="xs" w="100%">
{ {extraFiltersForm.values.sortEntries.map((entry, index) => (
label: t('common.ascending', { postProcess: 'sentenceCase' }), <Group align="flex-end" gap="sm" key={index} wrap="nowrap">
value: 'asc', <Select
}, data={sortOptions}
{ label={
label: t('common.descending', { postProcess: 'sentenceCase' }), index === 0
value: 'desc', ? t('common.sort', { postProcess: 'titleCase' })
}, : ''
]} }
label={t('common.sortOrder', { postProcess: 'titleCase' })} onChange={(value) =>
maxWidth="20%" handleSortFieldChange(index, value || '')
width={125} }
{...extraFiltersForm.getInputProps('sortOrder')} searchable
/> value={entry.field}
<NumberInput width={200}
label={t('common.limit', { postProcess: 'titleCase' })} />
maxWidth="20%" <Select
width={75} data={[
{...extraFiltersForm.getInputProps('limit')} {
/> label: t('common.ascending', {
</Group> postProcess: 'sentenceCase',
{onSave && onSaveAs && ( }),
<Group gap="sm" wrap="nowrap"> value: 'asc',
<Button loading={isSaving} onClick={handleSaveAs}> },
{t('common.saveAs', { postProcess: 'titleCase' })} {
</Button> label: t('common.descending', {
<Button onClick={openPreviewModal} variant="subtle"> postProcess: 'sentenceCase',
{t('common.preview', { postProcess: 'titleCase' })} }),
</Button> value: 'desc',
<DropdownMenu position="bottom-end"> },
<DropdownMenu.Target> ]}
<ActionIcon label={
disabled={isSaving} index === 0
icon="ellipsisHorizontal" ? t('common.sortOrder', {
variant="subtle" postProcess: 'titleCase',
/> })
</DropdownMenu.Target> : ''
<DropdownMenu.Dropdown> }
<DropdownMenu.Item onChange={(value) =>
isDanger handleSortOrderChange(
leftSection={<Icon color="error" icon="save" />} index,
onClick={handleSave} (value as 'asc' | 'desc') || 'asc',
> )
{t('common.saveAndReplace', { postProcess: 'titleCase' })} }
</DropdownMenu.Item> value={entry.order}
</DropdownMenu.Dropdown> width={125}
</DropdownMenu> />
{extraFiltersForm.values.sortEntries.length > 1 && (
<ActionIcon
icon="minus"
onClick={() => handleRemoveSortEntry(index)}
variant="subtle"
/>
)}
{index ===
extraFiltersForm.values.sortEntries.length - 1 && (
<ActionIcon
icon="plus"
onClick={handleAddSortEntry}
variant="subtle"
/>
)}
</Group>
))}
</Stack>
<NumberInput
label={t('common.limit', { postProcess: 'titleCase' })}
maxWidth="20%"
width={75}
{...extraFiltersForm.getInputProps('limit')}
/>
</Group> </Group>
)} </Stack>
</Group> </ScrollArea>
</Flex> </Flex>
); );
}, },
@@ -1,7 +1,7 @@
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate, useParams } from 'react-router'; import { generatePath, useNavigate, useParams } from 'react-router';
@@ -9,22 +9,29 @@ import { ListContext } from '/@/renderer/context/list-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content'; import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header'; import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
import { PlaylistQueryBuilder } from '/@/renderer/features/playlists/components/playlist-query-builder'; import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form'; import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container'; import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { ConfirmModal } from '/@/shared/components/modal/modal'; import { ConfirmModal } from '/@/shared/components/modal/modal';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { ServerType, SongListSort } from '/@/shared/types/domain-types'; import { ServerType } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
const PlaylistDetailSongListRoute = () => { const PlaylistDetailSongListRoute = () => {
@@ -45,15 +52,17 @@ const PlaylistDetailSongListRoute = () => {
) => { ) => {
if (!detailQuery?.data) return; if (!detailQuery?.data) return;
// New syntax: sortBy is now a single string with comma-separated fields and +/- prefix
// e.g., "+album,-year" means sort by album ascending, then year descending
const sortValue = const sortValue =
extraFilters.sortBy && extraFilters.sortBy.length > 0 extraFilters.sortBy && extraFilters.sortBy.length > 0
? extraFilters.sortBy.join(',') ? extraFilters.sortBy[0]
: 'dateAdded'; : '+dateAdded';
const rules = { const rules = {
...filter, ...filter,
limit: extraFilters.limit || undefined, limit: extraFilters.limit || undefined,
order: extraFilters.sortOrder || 'desc', // order field is now optional - sort direction is embedded in sort field
sort: sortValue, sort: sortValue,
}; };
@@ -100,13 +109,12 @@ const PlaylistDetailSongListRoute = () => {
const sortValue = const sortValue =
extraFilters.sortBy && extraFilters.sortBy.length > 0 extraFilters.sortBy && extraFilters.sortBy.length > 0
? extraFilters.sortBy.join(',') ? extraFilters.sortBy[0]
: 'dateAdded'; : '+dateAdded';
const rules = { const rules = {
...filter, ...filter,
limit: extraFilters.limit || undefined, limit: extraFilters.limit || undefined,
order: extraFilters.sortOrder || 'desc',
sort: sortValue, sort: sortValue,
}; };
@@ -178,6 +186,35 @@ const PlaylistDetailSongListRoute = () => {
}); });
}; };
const openSaveAndReplaceModal = () => {
if (!isQueryBuilderExpanded) {
return;
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
openModal({
children: (
<ConfirmModal
onConfirm={() => {
handleSave(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
closeAllModals();
}}
>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal>
),
title: t('common.saveAndReplace', { postProcess: 'sentenceCase' }),
});
};
const isSmartPlaylist = const isSmartPlaylist =
!detailQuery?.isLoading && !detailQuery?.isLoading &&
detailQuery?.data?.rules && detailQuery?.data?.rules &&
@@ -185,6 +222,7 @@ const PlaylistDetailSongListRoute = () => {
const [showQueryBuilder, setShowQueryBuilder] = useState(false); const [showQueryBuilder, setShowQueryBuilder] = useState(false);
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false); const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
const handleToggleExpand = () => { const handleToggleExpand = () => {
setIsQueryBuilderExpanded((prev) => !prev); setIsQueryBuilderExpanded((prev) => !prev);
@@ -195,6 +233,34 @@ const PlaylistDetailSongListRoute = () => {
setIsQueryBuilderExpanded(true); setIsQueryBuilderExpanded(true);
}; };
const openPreviewModal = () => {
if (!isQueryBuilderExpanded) return;
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
toast.error({
message:
t('error.queryBuilderNotReady', { postProcess: 'sentenceCase' }) ||
'Query builder is not ready. Please expand it first.',
});
return;
}
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
const previewValue = {
...queryValue,
...(filters.extraFilters.limit && { limit: filters.extraFilters.limit }),
...(sortString && { sort: sortString }),
};
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
};
const playlistSongs = useQuery( const playlistSongs = useQuery(
playlistsQueries.songList({ playlistsQueries.songList({
query: { query: {
@@ -242,42 +308,120 @@ const PlaylistDetailSongListRoute = () => {
/> />
{(isSmartPlaylist || showQueryBuilder) && ( {(isSmartPlaylist || showQueryBuilder) && (
<motion.div> <motion.div>
<Box h="100%" mah="35vh" p="md" w="100%"> <Box h="100%" mah="50dvh" p="md" w="100%">
<Group pb="md"> <Group justify="space-between" pb="md" wrap="nowrap">
<ActionIcon <Group gap="sm" wrap="nowrap">
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'} <ActionIcon
iconProps={{ icon={
size: 'md', isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'
}} }
onClick={handleToggleExpand} iconProps={{
size="xs" size: 'md',
/> }}
<Text> onClick={handleToggleExpand}
{t('form.queryEditor.title', { postProcess: 'titleCase' })} size="xs"
</Text> />
<Text>
{t('form.queryEditor.title', {
postProcess: 'titleCase',
})}
</Text>
</Group>
<Group gap="xs">
<Button
disabled={!isQueryBuilderExpanded}
onClick={openPreviewModal}
size="sm"
variant="subtle"
>
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending}
onClick={() => {
if (!isQueryBuilderExpanded) return;
const filters =
queryBuilderRef.current?.getFilters();
if (filters) {
handleSaveAs(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
}
}}
size="sm"
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon color="error" icon="save" />}
onClick={openSaveAndReplaceModal}
size="sm"
variant="default"
>
{t('common.saveAndReplace', {
postProcess: 'titleCase',
})}
</Button>
</Group>
</Group> </Group>
{isQueryBuilderExpanded && ( <div style={{ display: isQueryBuilderExpanded ? 'block' : 'none' }}>
<PlaylistQueryBuilder <PlaylistQueryBuilder
isSaving={createPlaylistMutation?.isPending}
key={JSON.stringify(detailQuery?.data?.rules)} key={JSON.stringify(detailQuery?.data?.rules)}
limit={detailQuery?.data?.rules?.limit} limit={detailQuery?.data?.rules?.limit}
onSave={handleSave}
onSaveAs={handleSaveAs}
playlistId={playlistId} playlistId={playlistId}
query={detailQuery?.data?.rules} query={detailQuery?.data?.rules}
ref={queryBuilderRef}
sortBy={(() => { sortBy={(() => {
const sort = detailQuery?.data?.rules?.sort; const sort = detailQuery?.data?.rules?.sort;
// Handle new syntax: comma-separated with +/- prefix
// e.g., "+album,-year" -> return as single string in array
if (typeof sort === 'string') {
// Check if it's new syntax (has +/- prefix or commas)
if (
sort.includes(',') ||
sort.startsWith('+') ||
sort.startsWith('-')
) {
return [sort];
}
// Old syntax: single field, convert to new format with default order
const order =
detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return [`${prefix}${sort}`];
}
if (Array.isArray(sort)) { if (Array.isArray(sort)) {
return sort; // If array, check if first item has +/- prefix
if (
sort.length > 0 &&
typeof sort[0] === 'string' &&
(sort[0].startsWith('+') ||
sort[0].startsWith('-'))
) {
return sort;
}
// Old array format, convert to new format
const order =
detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return sort.map((s) => `${prefix}${s}`);
} }
if (typeof sort === 'string' && sort.includes(',')) { return ['+dateAdded'];
return sort.split(',').map((s) => s.trim()); })()}
} sortOrder={(() => {
return sort ? [sort] : [SongListSort.ALBUM]; const sort = detailQuery?.data?.rules?.sort;
if (typeof sort === 'string' && sort.startsWith('-')) {
return 'desc';
}
// Fall back to old order field or default
return detailQuery?.data?.rules?.order || 'asc';
})()} })()}
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
/> />
)} </div>
</Box> </Box>
</motion.div> </motion.div>
)} )}
@@ -0,0 +1,6 @@
.preview {
padding: var(--theme-spacing-md);
font-family: var(--theme-content-font-family);
font-size: var(--theme-font-size-md);
background: var(--theme-colors-surface);
}
@@ -1,7 +1,13 @@
import styles from './json-preview.module.css';
interface JsonPreviewProps { interface JsonPreviewProps {
value: Record<string, any> | string; value: Record<string, any> | string;
} }
export const JsonPreview = ({ value }: JsonPreviewProps) => { export const JsonPreview = ({ value }: JsonPreviewProps) => {
return <pre style={{ userSelect: 'all' }}>{JSON.stringify(value, null, 2)}</pre>; return (
<pre className={styles.preview} style={{ userSelect: 'all' }}>
{JSON.stringify(value, null, 4)}
</pre>
);
}; };