mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-16 13:40:24 +02:00
redesign smart playlist, add multisort
This commit is contained in:
@@ -123,6 +123,7 @@
|
||||
"setting_other": "settings",
|
||||
"share": "share",
|
||||
"size": "size",
|
||||
"sort": "sort",
|
||||
"sortOrder": "order",
|
||||
"tags": "tags",
|
||||
"title": "title",
|
||||
@@ -302,7 +303,11 @@
|
||||
"queryEditor": {
|
||||
"title": "query editor",
|
||||
"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": {
|
||||
"allowDownloading": "allow downloading",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { 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 { Group } from '/@/shared/components/group/group';
|
||||
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 { 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 = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
@@ -60,6 +44,7 @@ interface QueryBuilderProps {
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
playlists?: { label: string; value: string }[];
|
||||
saveActions?: React.ReactNode;
|
||||
uniqueId: string;
|
||||
}
|
||||
|
||||
@@ -80,8 +65,28 @@ export const QueryBuilder = ({
|
||||
onResetFilters,
|
||||
operators,
|
||||
playlists,
|
||||
saveActions,
|
||||
uniqueId,
|
||||
}: 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 = () => {
|
||||
onAddRule({ groupIndex, level });
|
||||
};
|
||||
@@ -99,124 +104,144 @@ export const QueryBuilder = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" ml={`${level * 10}px`}>
|
||||
<Group gap="sm">
|
||||
<Select
|
||||
data={FILTER_GROUP_OPTIONS_DATA}
|
||||
maxWidth={175}
|
||||
onChange={handleChangeType}
|
||||
size="sm"
|
||||
value={data.type}
|
||||
width="20%"
|
||||
/>
|
||||
<ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<ActionIcon
|
||||
icon="ellipsisVertical"
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
border: '1px solid var(--theme-colors-border)',
|
||||
borderRadius: 'var(--theme-radius-md)',
|
||||
marginLeft: level > 0 ? '20px' : '0px',
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Select
|
||||
data={FILTER_GROUP_OPTIONS_DATA}
|
||||
maxWidth={250}
|
||||
onChange={handleChangeType}
|
||||
size="sm"
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
value={data.type}
|
||||
width={200}
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="add" />}
|
||||
onClick={handleAddRuleGroup}
|
||||
>
|
||||
Add rule group
|
||||
</DropdownMenu.Item>
|
||||
<ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<ActionIcon
|
||||
icon="ellipsisVertical"
|
||||
size="sm"
|
||||
style={{
|
||||
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 && (
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="delete" />}
|
||||
onClick={handleDeleteRuleGroup}
|
||||
>
|
||||
Remove rule group
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{level === 0 && (
|
||||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
isDanger
|
||||
leftSection={<Icon color="error" icon="refresh" />}
|
||||
onClick={onResetFilters}
|
||||
>
|
||||
Reset to default
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
isDanger
|
||||
leftSection={<Icon color="error" icon="delete" />}
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<AnimatePresence initial={false}>
|
||||
{data?.rules?.map((rule: QueryBuilderRule) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
key={rule.uniqueId}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<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 && (
|
||||
{level > 0 && (
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="delete" />}
|
||||
onClick={handleDeleteRuleGroup}
|
||||
>
|
||||
{t('form.queryEditor.removeRuleGroup', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{level === 0 && (
|
||||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
isDanger
|
||||
leftSection={<Icon color="error" icon="refresh" />}
|
||||
onClick={onResetFilters}
|
||||
>
|
||||
{t('form.queryEditor.resetToDefault', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
isDanger
|
||||
leftSection={<Icon color="error" icon="delete" />}
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
{t('form.queryEditor.clearFilters', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
{level === 0 && saveActions}
|
||||
</Group>
|
||||
<AnimatePresence initial={false}>
|
||||
{data.group?.map((group: QueryBuilderGroup, index: number) => (
|
||||
{data?.rules?.map((rule: QueryBuilderRule) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
key={group.uniqueId}
|
||||
key={rule.uniqueId}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilder
|
||||
data={group}
|
||||
<QueryBuilderOption
|
||||
data={rule}
|
||||
filters={filters}
|
||||
groupIndex={[...(groupIndex || []), index]}
|
||||
level={level + 1}
|
||||
onAddRule={onAddRule}
|
||||
onAddRuleGroup={onAddRuleGroup}
|
||||
groupIndex={groupIndex || []}
|
||||
level={level}
|
||||
noRemove={data?.rules?.length === 1}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeType={onChangeType}
|
||||
onChangeValue={onChangeValue}
|
||||
onClearFilters={onClearFilters}
|
||||
onDeleteRule={onDeleteRule}
|
||||
onDeleteRuleGroup={onDeleteRuleGroup}
|
||||
onResetFilters={onResetFilters}
|
||||
operators={operators}
|
||||
playlists={playlists}
|
||||
uniqueId={group.uniqueId}
|
||||
selectData={playlists}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</Stack>
|
||||
{data?.group && (
|
||||
<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 operatorsByFieldType = operators[fieldType as keyof typeof operators];
|
||||
const ml = (level + 1) * 10;
|
||||
const ml = 20;
|
||||
|
||||
return (
|
||||
<Group gap="sm" ml={ml}>
|
||||
|
||||
@@ -57,9 +57,11 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
|
||||
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 =
|
||||
isSmartPlaylist && smartPlaylist?.extraFilters?.sortBy
|
||||
? smartPlaylist.extraFilters.sortBy.join(',')
|
||||
isSmartPlaylist && smartPlaylist?.extraFilters?.sortBy?.[0]
|
||||
? smartPlaylist.extraFilters.sortBy[0]
|
||||
: undefined;
|
||||
|
||||
const rules =
|
||||
@@ -67,8 +69,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
? {
|
||||
...convertQueryGroupToNDQuery(smartPlaylist.filters),
|
||||
limit: smartPlaylist.extraFilters.limit,
|
||||
order: smartPlaylist.extraFilters.sortOrder,
|
||||
sort: sortValue || 'dateAdded',
|
||||
// order field is now optional - sort direction is embedded in sort field
|
||||
sort: sortValue || '+dateAdded',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useForm } from '@mantine/form';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import clone from 'lodash/clone';
|
||||
import get from 'lodash/get';
|
||||
@@ -10,11 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { QueryBuilder } from '/@/renderer/components/query-builder';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import {
|
||||
convertNDQueryToQueryGroup,
|
||||
convertQueryGroupToNDQuery,
|
||||
} from '/@/renderer/features/playlists/utils';
|
||||
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
|
||||
import { convertNDQueryToQueryGroup } from '/@/renderer/features/playlists/utils';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import {
|
||||
NDSongQueryBooleanOperators,
|
||||
@@ -25,15 +20,12 @@ import {
|
||||
NDSongQueryStringOperators,
|
||||
} from '/@/shared/api/navidrome/navidrome-types';
|
||||
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 { 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 { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { PlaylistListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
|
||||
|
||||
@@ -49,22 +41,18 @@ type DeleteArgs = {
|
||||
};
|
||||
|
||||
interface PlaylistQueryBuilderProps {
|
||||
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;
|
||||
playlistId?: string;
|
||||
query: any;
|
||||
sortBy: SongListSort | SongListSort[];
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
type SortEntry = {
|
||||
field: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY = {
|
||||
group: [],
|
||||
rules: [
|
||||
@@ -92,16 +80,7 @@ export type PlaylistQueryBuilderRef = {
|
||||
|
||||
export const PlaylistQueryBuilder = forwardRef(
|
||||
(
|
||||
{
|
||||
isSaving,
|
||||
limit,
|
||||
onSave,
|
||||
onSaveAs,
|
||||
playlistId,
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: PlaylistQueryBuilderProps,
|
||||
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
|
||||
ref: Ref<PlaylistQueryBuilderRef>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -131,19 +110,91 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
}));
|
||||
}, [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({
|
||||
initialValues: {
|
||||
limit,
|
||||
sortBy: Array.isArray(sortBy) ? sortBy : sortBy ? [sortBy] : [],
|
||||
sortOrder,
|
||||
sortEntries: parseSortEntries(),
|
||||
},
|
||||
});
|
||||
|
||||
// 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, () => ({
|
||||
getFilters: () => ({
|
||||
extraFilters: extraFiltersForm.values,
|
||||
filters,
|
||||
}),
|
||||
getFilters: () => {
|
||||
const sortString = convertSortEntriesToSortString(
|
||||
extraFiltersForm.values.sortEntries,
|
||||
);
|
||||
return {
|
||||
extraFilters: {
|
||||
limit: extraFiltersForm.values.limit,
|
||||
sortBy: sortString ? [sortString] : undefined,
|
||||
// sortOrder is now optional and embedded in sortBy
|
||||
},
|
||||
filters,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const handleResetFilters = () => {
|
||||
@@ -162,24 +213,6 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
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 { groupIndex, level } = args;
|
||||
const filtersCopy = clone(filters);
|
||||
@@ -413,96 +446,127 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
...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 (
|
||||
<Flex direction="column" h="calc(100% - 2rem)" justify="space-between">
|
||||
<ScrollArea>
|
||||
<QueryBuilder
|
||||
data={filters}
|
||||
filters={NDSongQueryFields}
|
||||
groupIndex={[]}
|
||||
level={0}
|
||||
onAddRule={handleAddRule}
|
||||
onAddRuleGroup={handleAddRuleGroup}
|
||||
onChangeField={handleChangeField}
|
||||
onChangeOperator={handleChangeOperator}
|
||||
onChangeType={handleChangeType}
|
||||
onChangeValue={handleChangeValue}
|
||||
onClearFilters={handleClearFilters}
|
||||
onDeleteRule={handleDeleteRule}
|
||||
onDeleteRuleGroup={handleDeleteRuleGroup}
|
||||
onResetFilters={handleResetFilters}
|
||||
operators={{
|
||||
boolean: NDSongQueryBooleanOperators,
|
||||
date: NDSongQueryDateOperators,
|
||||
number: NDSongQueryNumberOperators,
|
||||
playlist: NDSongQueryPlaylistOperators,
|
||||
string: NDSongQueryStringOperators,
|
||||
}}
|
||||
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')}
|
||||
<Stack gap="md" p="1rem">
|
||||
<QueryBuilder
|
||||
data={filters}
|
||||
filters={NDSongQueryFields}
|
||||
groupIndex={[]}
|
||||
level={0}
|
||||
onAddRule={handleAddRule}
|
||||
onAddRuleGroup={handleAddRuleGroup}
|
||||
onChangeField={handleChangeField}
|
||||
onChangeOperator={handleChangeOperator}
|
||||
onChangeType={handleChangeType}
|
||||
onChangeValue={handleChangeValue}
|
||||
onClearFilters={handleClearFilters}
|
||||
onDeleteRule={handleDeleteRule}
|
||||
onDeleteRuleGroup={handleDeleteRuleGroup}
|
||||
onResetFilters={handleResetFilters}
|
||||
operators={{
|
||||
boolean: NDSongQueryBooleanOperators,
|
||||
date: NDSongQueryDateOperators,
|
||||
number: NDSongQueryNumberOperators,
|
||||
playlist: NDSongQueryPlaylistOperators,
|
||||
string: NDSongQueryStringOperators,
|
||||
}}
|
||||
playlists={playlistData}
|
||||
uniqueId={filters.uniqueId}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{
|
||||
label: t('common.ascending', { postProcess: 'sentenceCase' }),
|
||||
value: 'asc',
|
||||
},
|
||||
{
|
||||
label: t('common.descending', { postProcess: 'sentenceCase' }),
|
||||
value: 'desc',
|
||||
},
|
||||
]}
|
||||
label={t('common.sortOrder', { postProcess: 'titleCase' })}
|
||||
maxWidth="20%"
|
||||
width={125}
|
||||
{...extraFiltersForm.getInputProps('sortOrder')}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('common.limit', { postProcess: 'titleCase' })}
|
||||
maxWidth="20%"
|
||||
width={75}
|
||||
{...extraFiltersForm.getInputProps('limit')}
|
||||
/>
|
||||
</Group>
|
||||
{onSave && onSaveAs && (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button loading={isSaving} onClick={handleSaveAs}>
|
||||
{t('common.saveAs', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button onClick={openPreviewModal} variant="subtle">
|
||||
{t('common.preview', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<ActionIcon
|
||||
disabled={isSaving}
|
||||
icon="ellipsisHorizontal"
|
||||
variant="subtle"
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
isDanger
|
||||
leftSection={<Icon color="error" icon="save" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('common.saveAndReplace', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Group align="flex-end" gap="sm" w="100%" wrap="nowrap">
|
||||
<Stack gap="xs" w="100%">
|
||||
{extraFiltersForm.values.sortEntries.map((entry, index) => (
|
||||
<Group align="flex-end" gap="sm" key={index} wrap="nowrap">
|
||||
<Select
|
||||
data={sortOptions}
|
||||
label={
|
||||
index === 0
|
||||
? t('common.sort', { postProcess: 'titleCase' })
|
||||
: ''
|
||||
}
|
||||
onChange={(value) =>
|
||||
handleSortFieldChange(index, value || '')
|
||||
}
|
||||
searchable
|
||||
value={entry.field}
|
||||
width={200}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{
|
||||
label: t('common.ascending', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'asc',
|
||||
},
|
||||
{
|
||||
label: t('common.descending', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'desc',
|
||||
},
|
||||
]}
|
||||
label={
|
||||
index === 0
|
||||
? t('common.sortOrder', {
|
||||
postProcess: 'titleCase',
|
||||
})
|
||||
: ''
|
||||
}
|
||||
onChange={(value) =>
|
||||
handleSortOrderChange(
|
||||
index,
|
||||
(value as 'asc' | 'desc') || 'asc',
|
||||
)
|
||||
}
|
||||
value={entry.order}
|
||||
width={125}
|
||||
/>
|
||||
{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>
|
||||
</ScrollArea>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
|
||||
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 { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-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 { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
|
||||
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
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';
|
||||
|
||||
const PlaylistDetailSongListRoute = () => {
|
||||
@@ -45,15 +52,17 @@ const PlaylistDetailSongListRoute = () => {
|
||||
) => {
|
||||
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 =
|
||||
extraFilters.sortBy && extraFilters.sortBy.length > 0
|
||||
? extraFilters.sortBy.join(',')
|
||||
: 'dateAdded';
|
||||
? extraFilters.sortBy[0]
|
||||
: '+dateAdded';
|
||||
|
||||
const rules = {
|
||||
...filter,
|
||||
limit: extraFilters.limit || undefined,
|
||||
order: extraFilters.sortOrder || 'desc',
|
||||
// order field is now optional - sort direction is embedded in sort field
|
||||
sort: sortValue,
|
||||
};
|
||||
|
||||
@@ -100,13 +109,12 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
const sortValue =
|
||||
extraFilters.sortBy && extraFilters.sortBy.length > 0
|
||||
? extraFilters.sortBy.join(',')
|
||||
: 'dateAdded';
|
||||
? extraFilters.sortBy[0]
|
||||
: '+dateAdded';
|
||||
|
||||
const rules = {
|
||||
...filter,
|
||||
limit: extraFilters.limit || undefined,
|
||||
order: extraFilters.sortOrder || 'desc',
|
||||
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 =
|
||||
!detailQuery?.isLoading &&
|
||||
detailQuery?.data?.rules &&
|
||||
@@ -185,6 +222,7 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
|
||||
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
|
||||
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
|
||||
|
||||
const handleToggleExpand = () => {
|
||||
setIsQueryBuilderExpanded((prev) => !prev);
|
||||
@@ -195,6 +233,34 @@ const PlaylistDetailSongListRoute = () => {
|
||||
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(
|
||||
playlistsQueries.songList({
|
||||
query: {
|
||||
@@ -242,42 +308,120 @@ const PlaylistDetailSongListRoute = () => {
|
||||
/>
|
||||
{(isSmartPlaylist || showQueryBuilder) && (
|
||||
<motion.div>
|
||||
<Box h="100%" mah="35vh" p="md" w="100%">
|
||||
<Group pb="md">
|
||||
<ActionIcon
|
||||
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'}
|
||||
iconProps={{
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={handleToggleExpand}
|
||||
size="xs"
|
||||
/>
|
||||
<Text>
|
||||
{t('form.queryEditor.title', { postProcess: 'titleCase' })}
|
||||
</Text>
|
||||
<Box h="100%" mah="50dvh" p="md" w="100%">
|
||||
<Group justify="space-between" pb="md" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
icon={
|
||||
isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'
|
||||
}
|
||||
iconProps={{
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={handleToggleExpand}
|
||||
size="xs"
|
||||
/>
|
||||
<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>
|
||||
{isQueryBuilderExpanded && (
|
||||
<div style={{ display: isQueryBuilderExpanded ? 'block' : 'none' }}>
|
||||
<PlaylistQueryBuilder
|
||||
isSaving={createPlaylistMutation?.isPending}
|
||||
key={JSON.stringify(detailQuery?.data?.rules)}
|
||||
limit={detailQuery?.data?.rules?.limit}
|
||||
onSave={handleSave}
|
||||
onSaveAs={handleSaveAs}
|
||||
playlistId={playlistId}
|
||||
query={detailQuery?.data?.rules}
|
||||
ref={queryBuilderRef}
|
||||
sortBy={(() => {
|
||||
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)) {
|
||||
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 sort.split(',').map((s) => s.trim());
|
||||
}
|
||||
return sort ? [sort] : [SongListSort.ALBUM];
|
||||
return ['+dateAdded'];
|
||||
})()}
|
||||
sortOrder={(() => {
|
||||
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>
|
||||
</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 {
|
||||
value: Record<string, any> | string;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user