mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
optimize query builder
This commit is contained in:
@@ -117,11 +117,10 @@ export const QueryBuilder = ({
|
|||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Select
|
<Select
|
||||||
data={FILTER_GROUP_OPTIONS_DATA}
|
data={FILTER_GROUP_OPTIONS_DATA}
|
||||||
maxWidth={250}
|
maxWidth={170}
|
||||||
onChange={handleChangeType}
|
onChange={handleChangeType}
|
||||||
size="sm"
|
size="sm"
|
||||||
value={data.type}
|
value={data.type}
|
||||||
width={200}
|
|
||||||
/>
|
/>
|
||||||
<ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" />
|
<ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" />
|
||||||
<DropdownMenu position="bottom-start">
|
<DropdownMenu position="bottom-start">
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const PlaylistDetailSongListHeader = ({
|
|||||||
return (
|
return (
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<LibraryHeaderBar>
|
<LibraryHeaderBar ignoreMaxWidth>
|
||||||
<LibraryHeaderBar.PlayButton
|
<LibraryHeaderBar.PlayButton
|
||||||
ids={[playlistId]}
|
ids={[playlistId]}
|
||||||
itemType={LibraryItem.PLAYLIST}
|
itemType={LibraryItem.PLAYLIST}
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import clone from 'lodash/clone';
|
|||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import setWith from 'lodash/setWith';
|
import setWith from 'lodash/setWith';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { forwardRef, Ref, useImperativeHandle, useMemo, useState } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
Ref,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { QueryBuilder } from '/@/renderer/components/query-builder';
|
import { QueryBuilder } from '/@/renderer/components/query-builder';
|
||||||
@@ -53,7 +61,7 @@ type SortEntry = {
|
|||||||
order: 'asc' | 'desc';
|
order: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_QUERY = {
|
const DEFAULT_QUERY: QueryBuilderGroup = {
|
||||||
group: [],
|
group: [],
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
@@ -63,10 +71,85 @@ const DEFAULT_QUERY = {
|
|||||||
value: '',
|
value: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
type: 'all' as 'all' | 'any',
|
type: 'all',
|
||||||
uniqueId: nanoid(),
|
uniqueId: nanoid(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Utility functions for path building
|
||||||
|
const getGroupPath = (level: number, groupIndex: number[]): string => {
|
||||||
|
if (level === 0) return 'group';
|
||||||
|
return `${groupIndex.map((idx) => `group[${idx}]`).join('.')}.group`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypePath = (groupIndex: number[]): string => {
|
||||||
|
return groupIndex.map((idx) => `group[${idx}]`).join('.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRulePath = (level: number, groupIndex: number[]): string => {
|
||||||
|
if (level === 0) return 'rules';
|
||||||
|
return `${groupIndex.map((idx) => `group[${idx}]`).join('.')}.rules`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse sortBy and sortOrder into array of sort entries
|
||||||
|
const parseSortEntries = (
|
||||||
|
sortBy: SongListSort | SongListSort[],
|
||||||
|
sortOrder: 'asc' | 'desc',
|
||||||
|
): 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(',')) {
|
||||||
|
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' }];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert sort entries to new syntax: comma-separated with +/- prefix
|
||||||
|
const convertSortEntriesToSortString = (entries: SortEntry[]): string => {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.field)
|
||||||
|
.map((entry) => {
|
||||||
|
const prefix = entry.order === 'desc' ? '-' : '+';
|
||||||
|
return `${prefix}${entry.field}`;
|
||||||
|
})
|
||||||
|
.join(',');
|
||||||
|
};
|
||||||
|
|
||||||
export type PlaylistQueryBuilderRef = {
|
export type PlaylistQueryBuilderRef = {
|
||||||
getFilters: () => {
|
getFilters: () => {
|
||||||
extraFilters: {
|
extraFilters: {
|
||||||
@@ -85,10 +168,22 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||||||
) => {
|
) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const [filters, setFilters] = useState<QueryBuilderGroup>(
|
|
||||||
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
|
// Memoize initial filters to avoid recalculation
|
||||||
|
const initialFilters = useMemo(
|
||||||
|
() => (query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY),
|
||||||
|
[query],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<QueryBuilderGroup>(initialFilters);
|
||||||
|
|
||||||
|
// Update filters when query changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (query) {
|
||||||
|
setFilters(convertNDQueryToQueryGroup(query));
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
const { data: playlists } = useQuery(
|
const { data: playlists } = useQuery(
|
||||||
playlistsQueries.list({
|
playlistsQueries.list({
|
||||||
query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },
|
query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },
|
||||||
@@ -100,372 +195,292 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||||||
if (!playlists) return [];
|
if (!playlists) return [];
|
||||||
|
|
||||||
return playlists.items
|
return playlists.items
|
||||||
.filter((p) => {
|
.filter((p) => !playlistId || p.id !== playlistId)
|
||||||
if (!playlistId) return true;
|
|
||||||
return p.id !== playlistId;
|
|
||||||
})
|
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
label: p.name,
|
label: p.name,
|
||||||
value: p.id,
|
value: p.id,
|
||||||
}));
|
}));
|
||||||
}, [playlistId, playlists]);
|
}, [playlistId, playlists]);
|
||||||
|
|
||||||
// Parse sortBy and sortOrder into array of sort entries
|
// Memoize parsed sort entries
|
||||||
// Handle new syntax: comma-separated fields with +/- prefix (e.g., "+album,-year")
|
const initialSortEntries = useMemo(
|
||||||
// Or old syntax: sortBy array + single sortOrder
|
() => parseSortEntries(sortBy, sortOrder),
|
||||||
const parseSortEntries = (): SortEntry[] => {
|
[sortBy, sortOrder],
|
||||||
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,
|
||||||
sortEntries: parseSortEntries(),
|
sortEntries: initialSortEntries,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert sort entries to new syntax: comma-separated with +/- prefix
|
useImperativeHandle(
|
||||||
const convertSortEntriesToSortString = (entries: SortEntry[]): string => {
|
ref,
|
||||||
return entries
|
() => ({
|
||||||
.filter((entry) => entry.field) // Filter out empty fields
|
getFilters: () => {
|
||||||
.map((entry) => {
|
const sortString = convertSortEntriesToSortString(
|
||||||
const prefix = entry.order === 'desc' ? '-' : '+';
|
extraFiltersForm.values.sortEntries,
|
||||||
return `${prefix}${entry.field}`;
|
);
|
||||||
})
|
|
||||||
.join(',');
|
|
||||||
};
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
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 = () => {
|
|
||||||
if (query) {
|
|
||||||
setFilters(convertNDQueryToQueryGroup(query));
|
|
||||||
} else {
|
|
||||||
setFilters(DEFAULT_QUERY);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
|
||||||
setFilters(DEFAULT_QUERY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setFilterHandler = (newFilters: QueryBuilderGroup) => {
|
|
||||||
setFilters(newFilters);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddRuleGroup = (args: AddArgs) => {
|
|
||||||
const { groupIndex, level } = args;
|
|
||||||
const filtersCopy = clone(filters);
|
|
||||||
|
|
||||||
const getPath = (level: number) => {
|
|
||||||
if (level === 0) return 'group';
|
|
||||||
|
|
||||||
const str: string[] = [];
|
|
||||||
for (const index of groupIndex) {
|
|
||||||
str.push(`group[${index}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${str.join('.')}.group`;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 { groupIndex, level, uniqueId } = args;
|
|
||||||
const filtersCopy = clone(filters);
|
|
||||||
|
|
||||||
const getPath = (level: number) => {
|
|
||||||
if (level === 0) return 'group';
|
|
||||||
|
|
||||||
const str: string[] = [];
|
|
||||||
for (let i = 0; i < groupIndex.length; i += 1) {
|
|
||||||
if (i !== groupIndex.length - 1) {
|
|
||||||
str.push(`group[${groupIndex[i]}]`);
|
|
||||||
} else {
|
|
||||||
str.push(`group`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${str.join('.')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const path = getPath(level);
|
|
||||||
|
|
||||||
const updatedFilters = setWith(
|
|
||||||
filtersCopy,
|
|
||||||
path,
|
|
||||||
[
|
|
||||||
...get(filtersCopy, path).filter(
|
|
||||||
(group: QueryBuilderGroup) => group.uniqueId !== uniqueId,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
clone,
|
|
||||||
);
|
|
||||||
|
|
||||||
setFilterHandler(updatedFilters);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRulePath = (level: number, groupIndex: number[]) => {
|
|
||||||
if (level === 0) return 'rules';
|
|
||||||
|
|
||||||
const str: string[] = [];
|
|
||||||
for (const index of groupIndex) {
|
|
||||||
str.push(`group[${index}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${str.join('.')}.rules`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddRule = (args: AddArgs) => {
|
|
||||||
const { groupIndex, level } = 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 { groupIndex, level, uniqueId } = 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 { groupIndex, level, uniqueId, 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 {
|
return {
|
||||||
...rule,
|
extraFilters: {
|
||||||
field: value,
|
limit: extraFiltersForm.values.limit,
|
||||||
operator: '',
|
sortBy: sortString ? [sortString] : undefined,
|
||||||
value: '',
|
},
|
||||||
|
filters,
|
||||||
};
|
};
|
||||||
}),
|
},
|
||||||
clone,
|
}),
|
||||||
);
|
[extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters],
|
||||||
|
);
|
||||||
|
|
||||||
setFilterHandler(updatedFilters);
|
const handleResetFilters = useCallback(() => {
|
||||||
};
|
setFilters(query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
const handleChangeType = (args: any) => {
|
const handleClearFilters = useCallback(() => {
|
||||||
|
setFilters(DEFAULT_QUERY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddRuleGroup = useCallback((args: AddArgs) => {
|
||||||
|
const { groupIndex, level } = args;
|
||||||
|
const path = getGroupPath(level, groupIndex);
|
||||||
|
|
||||||
|
setFilters((prev) => {
|
||||||
|
const currentGroups = get(prev, path) || [];
|
||||||
|
return setWith(
|
||||||
|
clone(prev),
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
...currentGroups,
|
||||||
|
{
|
||||||
|
group: [],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
field: '',
|
||||||
|
operator: '',
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'any',
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
clone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteRuleGroup = useCallback((args: DeleteArgs) => {
|
||||||
|
const { groupIndex, level, uniqueId } = args;
|
||||||
|
const path = level === 0 ? 'group' : getTypePath(groupIndex);
|
||||||
|
|
||||||
|
setFilters((prev) => {
|
||||||
|
const currentGroups = get(prev, path) || [];
|
||||||
|
return setWith(
|
||||||
|
clone(prev),
|
||||||
|
path,
|
||||||
|
currentGroups.filter((group: QueryBuilderGroup) => group.uniqueId !== uniqueId),
|
||||||
|
clone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddRule = useCallback((args: AddArgs) => {
|
||||||
|
const { groupIndex, level } = args;
|
||||||
|
const path = getRulePath(level, groupIndex);
|
||||||
|
|
||||||
|
setFilters((prev) => {
|
||||||
|
const currentRules = get(prev, path) || [];
|
||||||
|
return setWith(
|
||||||
|
clone(prev),
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
...currentRules,
|
||||||
|
{
|
||||||
|
field: '',
|
||||||
|
operator: '',
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
clone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteRule = useCallback((args: DeleteArgs) => {
|
||||||
|
const { groupIndex, level, uniqueId } = args;
|
||||||
|
const path = getRulePath(level, groupIndex);
|
||||||
|
|
||||||
|
setFilters((prev) => {
|
||||||
|
const currentRules = get(prev, path) || [];
|
||||||
|
return setWith(
|
||||||
|
clone(prev),
|
||||||
|
path,
|
||||||
|
currentRules.filter((rule: QueryBuilderRule) => rule.uniqueId !== uniqueId),
|
||||||
|
clone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangeField = useCallback((args: any) => {
|
||||||
|
const { groupIndex, level, uniqueId, value } = args;
|
||||||
|
const path = getRulePath(level, groupIndex);
|
||||||
|
|
||||||
|
setFilters((prev) => {
|
||||||
|
const currentRules = get(prev, path) || [];
|
||||||
|
return setWith(
|
||||||
|
clone(prev),
|
||||||
|
path,
|
||||||
|
currentRules.map((rule: QueryBuilderRule) => {
|
||||||
|
if (rule.uniqueId !== uniqueId) return rule;
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
field: value,
|
||||||
|
operator: '',
|
||||||
|
value: '',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
clone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangeType = useCallback((args: any) => {
|
||||||
const { groupIndex, level, value } = args;
|
const { groupIndex, level, value } = args;
|
||||||
|
|
||||||
const filtersCopy = clone(filters);
|
|
||||||
|
|
||||||
if (level === 0) {
|
if (level === 0) {
|
||||||
return setFilterHandler({ ...filtersCopy, type: value });
|
setFilters((prev) => ({ ...prev, type: value }));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypePath = () => {
|
const path = getTypePath(groupIndex);
|
||||||
const str: string[] = [];
|
setFilters((prev) =>
|
||||||
for (let i = 0; i < groupIndex.length; i += 1) {
|
setWith(
|
||||||
str.push(`group[${groupIndex[i]}]`);
|
clone(prev),
|
||||||
}
|
path,
|
||||||
|
{
|
||||||
|
...get(prev, path),
|
||||||
|
type: value,
|
||||||
|
},
|
||||||
|
clone,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return `${str.join('.')}`;
|
const handleChangeOperator = useCallback((args: any) => {
|
||||||
};
|
const { groupIndex, level, uniqueId, value } = args;
|
||||||
|
const path = getRulePath(level, groupIndex);
|
||||||
|
|
||||||
const path = getTypePath();
|
setFilters((prev) => {
|
||||||
const updatedFilters = setWith(
|
const currentRules = get(prev, path) || [];
|
||||||
filtersCopy,
|
return setWith(
|
||||||
path,
|
clone(prev),
|
||||||
|
path,
|
||||||
|
currentRules.map((rule: QueryBuilderRule) => {
|
||||||
|
if (rule.uniqueId !== uniqueId) return rule;
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
operator: value,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
clone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangeValue = useCallback((args: any) => {
|
||||||
|
const { groupIndex, level, uniqueId, value } = args;
|
||||||
|
const path = getRulePath(level, groupIndex);
|
||||||
|
|
||||||
|
setFilters((prev) => {
|
||||||
|
const currentRules = get(prev, path) || [];
|
||||||
|
return setWith(
|
||||||
|
clone(prev),
|
||||||
|
path,
|
||||||
|
currentRules.map((rule: QueryBuilderRule) => {
|
||||||
|
if (rule.uniqueId !== uniqueId) return rule;
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
clone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoize sort options
|
||||||
|
const sortOptions = useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
...get(filtersCopy, path),
|
label: t('filter.random', { postProcess: 'titleCase' }),
|
||||||
type: value,
|
type: 'string',
|
||||||
|
value: 'random',
|
||||||
},
|
},
|
||||||
clone,
|
...NDSongQueryFields,
|
||||||
);
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
return setFilterHandler(updatedFilters);
|
// Memoize order select data
|
||||||
};
|
const orderSelectData = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t('common.ascending', { postProcess: 'sentenceCase' }),
|
||||||
|
value: 'asc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.descending', { postProcess: 'sentenceCase' }),
|
||||||
|
value: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
const handleChangeOperator = (args: any) => {
|
// Memoize operators object
|
||||||
const { groupIndex, level, uniqueId, value } = args;
|
const operators = useMemo(
|
||||||
const filtersCopy = clone(filters);
|
() => ({
|
||||||
|
boolean: NDSongQueryBooleanOperators,
|
||||||
|
date: NDSongQueryDateOperators,
|
||||||
|
number: NDSongQueryNumberOperators,
|
||||||
|
playlist: NDSongQueryPlaylistOperators,
|
||||||
|
string: NDSongQueryStringOperators,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const path = getRulePath(level, groupIndex);
|
const handleAddSortEntry = useCallback(() => {
|
||||||
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 { groupIndex, level, uniqueId, 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: t('filter.random', { postProcess: 'titleCase' }),
|
|
||||||
type: 'string',
|
|
||||||
value: 'random',
|
|
||||||
},
|
|
||||||
...NDSongQueryFields,
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleAddSortEntry = () => {
|
|
||||||
extraFiltersForm.insertListItem('sortEntries', { field: '', order: 'asc' });
|
extraFiltersForm.insertListItem('sortEntries', { field: '', order: 'asc' });
|
||||||
};
|
}, [extraFiltersForm]);
|
||||||
|
|
||||||
const handleRemoveSortEntry = (index: number) => {
|
const handleRemoveSortEntry = useCallback(
|
||||||
extraFiltersForm.removeListItem('sortEntries', index);
|
(index: number) => {
|
||||||
};
|
extraFiltersForm.removeListItem('sortEntries', index);
|
||||||
|
},
|
||||||
|
[extraFiltersForm],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSortFieldChange = (index: number, value: string) => {
|
const handleSortFieldChange = useCallback(
|
||||||
extraFiltersForm.setFieldValue(`sortEntries.${index}.field`, value);
|
(index: number, value: string) => {
|
||||||
};
|
extraFiltersForm.setFieldValue(`sortEntries.${index}.field`, value);
|
||||||
|
},
|
||||||
|
[extraFiltersForm],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSortOrderChange = (index: number, value: 'asc' | 'desc') => {
|
const handleSortOrderChange = useCallback(
|
||||||
extraFiltersForm.setFieldValue(`sortEntries.${index}.order`, value);
|
(index: number, value: 'asc' | 'desc') => {
|
||||||
};
|
extraFiltersForm.setFieldValue(`sortEntries.${index}.order`, value);
|
||||||
|
},
|
||||||
|
[extraFiltersForm],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" h="calc(100% - 2rem)" justify="space-between">
|
<Flex direction="column" h="100%" w="100%">
|
||||||
<ScrollArea>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
<Stack gap="md" p="1rem">
|
<Stack gap="md" h="100%" p="1rem">
|
||||||
<QueryBuilder
|
<QueryBuilder
|
||||||
data={filters}
|
data={filters}
|
||||||
filters={NDSongQueryFields}
|
filters={NDSongQueryFields}
|
||||||
@@ -481,13 +496,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||||||
onDeleteRule={handleDeleteRule}
|
onDeleteRule={handleDeleteRule}
|
||||||
onDeleteRuleGroup={handleDeleteRuleGroup}
|
onDeleteRuleGroup={handleDeleteRuleGroup}
|
||||||
onResetFilters={handleResetFilters}
|
onResetFilters={handleResetFilters}
|
||||||
operators={{
|
operators={operators}
|
||||||
boolean: NDSongQueryBooleanOperators,
|
|
||||||
date: NDSongQueryDateOperators,
|
|
||||||
number: NDSongQueryNumberOperators,
|
|
||||||
playlist: NDSongQueryPlaylistOperators,
|
|
||||||
string: NDSongQueryStringOperators,
|
|
||||||
}}
|
|
||||||
playlists={playlistData}
|
playlists={playlistData}
|
||||||
uniqueId={filters.uniqueId}
|
uniqueId={filters.uniqueId}
|
||||||
/>
|
/>
|
||||||
@@ -510,20 +519,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||||||
width={200}
|
width={200}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
data={[
|
data={orderSelectData}
|
||||||
{
|
|
||||||
label: t('common.ascending', {
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
value: 'asc',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('common.descending', {
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
value: 'desc',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
label={
|
label={
|
||||||
index === 0
|
index === 0
|
||||||
? t('common.sortOrder', {
|
? t('common.sortOrder', {
|
||||||
|
|||||||
@@ -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 { AnimatePresence, motion } from 'motion/react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, 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';
|
||||||
|
|
||||||
@@ -19,21 +19,228 @@ import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/dele
|
|||||||
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
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 { 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 { 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 { Button } from '/@/shared/components/button/button';
|
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 { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||||
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
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 } from '/@/shared/types/domain-types';
|
import { ServerType, SongListSort } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
interface PlaylistQueryEditorProps {
|
||||||
|
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
|
||||||
|
detailQuery: ReturnType<typeof useQuery<any>>;
|
||||||
|
handleSave: (
|
||||||
|
filter: Record<string, any>,
|
||||||
|
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||||
|
) => void;
|
||||||
|
handleSaveAs: (
|
||||||
|
filter: Record<string, any>,
|
||||||
|
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||||
|
) => void;
|
||||||
|
isQueryBuilderExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
playlistId: string;
|
||||||
|
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaylistQueryEditor = ({
|
||||||
|
createPlaylistMutation,
|
||||||
|
detailQuery,
|
||||||
|
handleSave,
|
||||||
|
handleSaveAs,
|
||||||
|
isQueryBuilderExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
playlistId,
|
||||||
|
queryBuilderRef,
|
||||||
|
}: PlaylistQueryEditorProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const openPreviewModal = useCallback(() => {
|
||||||
|
if (!isQueryBuilderExpanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = queryBuilderRef.current?.getFilters();
|
||||||
|
|
||||||
|
if (!filters) {
|
||||||
|
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' }),
|
||||||
|
});
|
||||||
|
}, [isQueryBuilderExpanded, queryBuilderRef, t]);
|
||||||
|
|
||||||
|
const openSaveAndReplaceModal = useCallback(() => {
|
||||||
|
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' }),
|
||||||
|
});
|
||||||
|
}, [isQueryBuilderExpanded, queryBuilderRef, handleSave, t]);
|
||||||
|
|
||||||
|
const parseSortBy = useCallback((): string[] => {
|
||||||
|
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, 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}`);
|
||||||
|
}
|
||||||
|
return ['+dateAdded'];
|
||||||
|
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
|
||||||
|
|
||||||
|
const parseSortOrder = useCallback((): 'asc' | 'desc' => {
|
||||||
|
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';
|
||||||
|
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div>
|
||||||
|
<Stack gap={0} 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={onToggleExpand}
|
||||||
|
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"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{t('common.saveAs', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!isQueryBuilderExpanded}
|
||||||
|
leftSection={<Icon color="error" icon="save" />}
|
||||||
|
onClick={openSaveAndReplaceModal}
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{t('common.saveAndReplace', {
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: isQueryBuilderExpanded ? 'flex' : 'none',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlaylistQueryBuilder
|
||||||
|
key={JSON.stringify(detailQuery?.data?.rules)}
|
||||||
|
limit={detailQuery?.data?.rules?.limit}
|
||||||
|
playlistId={playlistId}
|
||||||
|
query={detailQuery?.data?.rules}
|
||||||
|
ref={queryBuilderRef}
|
||||||
|
sortBy={parseSortBy() as SongListSort | SongListSort[]}
|
||||||
|
sortOrder={parseSortOrder()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const PlaylistDetailSongListRoute = () => {
|
const PlaylistDetailSongListRoute = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -186,35 +393,6 @@ 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 &&
|
||||||
@@ -233,34 +411,6 @@ 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: {
|
||||||
@@ -294,139 +444,32 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
return (
|
return (
|
||||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||||
<ListContext.Provider value={providerValue}>
|
<ListContext.Provider value={providerValue}>
|
||||||
<LibraryContainer>
|
<PlaylistDetailSongListHeader
|
||||||
<PlaylistDetailSongListHeader
|
isSmartPlaylist={!!isSmartPlaylist}
|
||||||
isSmartPlaylist={!!isSmartPlaylist}
|
onConvertToSmart={() => {
|
||||||
onConvertToSmart={() => {
|
if (!isSmartPlaylist) {
|
||||||
if (!isSmartPlaylist) {
|
setShowQueryBuilder(true);
|
||||||
setShowQueryBuilder(true);
|
setIsQueryBuilderExpanded(true);
|
||||||
setIsQueryBuilderExpanded(true);
|
}
|
||||||
}
|
}}
|
||||||
}}
|
onDelete={() => openDeletePlaylistModal()}
|
||||||
onDelete={() => openDeletePlaylistModal()}
|
onToggleQueryBuilder={handleToggleShowQueryBuilder}
|
||||||
onToggleQueryBuilder={handleToggleShowQueryBuilder}
|
/>
|
||||||
/>
|
{(isSmartPlaylist || showQueryBuilder) && (
|
||||||
{(isSmartPlaylist || showQueryBuilder) && (
|
<AnimatePresence>
|
||||||
<motion.div>
|
<PlaylistQueryEditor
|
||||||
<Box h="100%" mah="50dvh" p="md" w="100%">
|
createPlaylistMutation={createPlaylistMutation}
|
||||||
<Group justify="space-between" pb="md" wrap="nowrap">
|
detailQuery={detailQuery}
|
||||||
<Group gap="sm" wrap="nowrap">
|
handleSave={handleSave}
|
||||||
<ActionIcon
|
handleSaveAs={handleSaveAs}
|
||||||
icon={
|
isQueryBuilderExpanded={isQueryBuilderExpanded}
|
||||||
isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'
|
onToggleExpand={handleToggleExpand}
|
||||||
}
|
playlistId={playlistId}
|
||||||
iconProps={{
|
queryBuilderRef={queryBuilderRef}
|
||||||
size: 'md',
|
/>
|
||||||
}}
|
</AnimatePresence>
|
||||||
onClick={handleToggleExpand}
|
)}
|
||||||
size="xs"
|
<PlaylistDetailSongListContent />
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
<div style={{ display: isQueryBuilderExpanded ? 'block' : 'none' }}>
|
|
||||||
<PlaylistQueryBuilder
|
|
||||||
key={JSON.stringify(detailQuery?.data?.rules)}
|
|
||||||
limit={detailQuery?.data?.rules?.limit}
|
|
||||||
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)) {
|
|
||||||
// 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}`);
|
|
||||||
}
|
|
||||||
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';
|
|
||||||
})()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
<PlaylistDetailSongListContent />
|
|
||||||
</LibraryContainer>
|
|
||||||
</ListContext.Provider>
|
</ListContext.Provider>
|
||||||
</AnimatedPage>
|
</AnimatedPage>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user