mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-15 21:16:17 +02:00
623 lines
22 KiB
TypeScript
623 lines
22 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import clone from 'lodash/clone';
|
|
import get from 'lodash/get';
|
|
import setWith from 'lodash/setWith';
|
|
import { nanoid } from 'nanoid';
|
|
import {
|
|
forwardRef,
|
|
Ref,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useState,
|
|
} from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { QueryBuilder } from '/@/renderer/components/query-builder';
|
|
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
|
import { convertNDQueryToQueryGroup } from '/@/renderer/features/playlists/utils';
|
|
import { useCurrentServer } from '/@/renderer/store';
|
|
import { useQueryBuilderSettings } from '/@/renderer/store/settings.store';
|
|
import {
|
|
NDSongQueryBooleanOperators,
|
|
NDSongQueryDateOperators,
|
|
NDSongQueryFields,
|
|
NDSongQueryNumberOperators,
|
|
NDSongQueryPlaylistOperators,
|
|
NDSongQueryStringOperators,
|
|
} from '/@/shared/api/navidrome/navidrome-types';
|
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
|
import { Flex } from '/@/shared/components/flex/flex';
|
|
import { Group } from '/@/shared/components/group/group';
|
|
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 { useForm } from '/@/shared/hooks/use-form';
|
|
import { PlaylistListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
|
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
|
|
|
|
type AddArgs = {
|
|
groupIndex: number[];
|
|
level: number;
|
|
};
|
|
|
|
type DeleteArgs = {
|
|
groupIndex: number[];
|
|
level: number;
|
|
uniqueId: string;
|
|
};
|
|
|
|
interface PlaylistQueryBuilderProps {
|
|
limit?: number;
|
|
playlistId?: string;
|
|
query: any;
|
|
sortBy: SongListSort | SongListSort[];
|
|
sortOrder: 'asc' | 'desc';
|
|
}
|
|
|
|
type SortEntry = {
|
|
field: string;
|
|
order: 'asc' | 'desc';
|
|
};
|
|
|
|
const DEFAULT_QUERY: QueryBuilderGroup = {
|
|
group: [],
|
|
rules: [
|
|
{
|
|
field: '',
|
|
operator: '',
|
|
uniqueId: nanoid(),
|
|
value: '',
|
|
},
|
|
],
|
|
type: 'all',
|
|
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 = {
|
|
getFilters: () => {
|
|
extraFilters: {
|
|
limit?: number;
|
|
sortBy?: string[];
|
|
sortOrder?: string;
|
|
};
|
|
filters: QueryBuilderGroup;
|
|
};
|
|
};
|
|
|
|
export const PlaylistQueryBuilder = forwardRef(
|
|
(
|
|
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
|
|
ref: Ref<PlaylistQueryBuilderRef>,
|
|
) => {
|
|
const { t } = useTranslation();
|
|
const server = useCurrentServer();
|
|
const queryBuilderSettings = useQueryBuilderSettings();
|
|
|
|
// 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(
|
|
playlistsQueries.list({
|
|
query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },
|
|
serverId: server?.id,
|
|
}),
|
|
);
|
|
|
|
const playlistData = useMemo(() => {
|
|
if (!playlists) return [];
|
|
|
|
return playlists.items
|
|
.filter((p) => !playlistId || p.id !== playlistId)
|
|
.map((p) => ({
|
|
label: p.name,
|
|
value: p.id,
|
|
}));
|
|
}, [playlistId, playlists]);
|
|
|
|
// Memoize parsed sort entries
|
|
const initialSortEntries = useMemo(
|
|
() => parseSortEntries(sortBy, sortOrder),
|
|
[sortBy, sortOrder],
|
|
);
|
|
|
|
const extraFiltersForm = useForm({
|
|
initialValues: {
|
|
limit,
|
|
sortEntries: initialSortEntries,
|
|
},
|
|
});
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
getFilters: () => {
|
|
const sortString = convertSortEntriesToSortString(
|
|
extraFiltersForm.values.sortEntries,
|
|
);
|
|
return {
|
|
extraFilters: {
|
|
limit: extraFiltersForm.values.limit,
|
|
sortBy: sortString ? [sortString] : undefined,
|
|
},
|
|
filters,
|
|
};
|
|
},
|
|
}),
|
|
[extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters],
|
|
);
|
|
|
|
const handleResetFilters = useCallback(() => {
|
|
setFilters(query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY);
|
|
}, [query]);
|
|
|
|
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' : getGroupPath(level - 1, groupIndex.slice(0, -1));
|
|
|
|
setFilters((prev) => {
|
|
const currentGroups = get(prev, path);
|
|
if (!Array.isArray(currentGroups)) {
|
|
return prev;
|
|
}
|
|
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;
|
|
|
|
if (level === 0) {
|
|
setFilters((prev) => ({ ...prev, type: value }));
|
|
return;
|
|
}
|
|
|
|
const path = getTypePath(groupIndex);
|
|
setFilters((prev) =>
|
|
setWith(
|
|
clone(prev),
|
|
path,
|
|
{
|
|
...get(prev, path),
|
|
type: value,
|
|
},
|
|
clone,
|
|
),
|
|
);
|
|
}, []);
|
|
|
|
const handleChangeOperator = 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,
|
|
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,
|
|
);
|
|
});
|
|
}, []);
|
|
|
|
const customFields = useMemo(() => {
|
|
return queryBuilderSettings.tag
|
|
.filter((field) => field.value && field.value.trim() !== '')
|
|
.map((field) => ({
|
|
label: field.label,
|
|
type: field.type,
|
|
value: field.value,
|
|
}));
|
|
}, [queryBuilderSettings.tag]);
|
|
|
|
const groupedFilters = useMemo(() => {
|
|
type FilterGroup = {
|
|
group: string;
|
|
items: Array<{ label: string; type: string; value: string }>;
|
|
};
|
|
const groups: FilterGroup[] = [];
|
|
|
|
// Custom Fields group
|
|
if (customFields.length > 0) {
|
|
groups.push({
|
|
group: t('queryBuilder.customTags', {
|
|
postProcess: 'titleCase',
|
|
}),
|
|
items: customFields,
|
|
});
|
|
}
|
|
|
|
// Standard Fields group
|
|
if (NDSongQueryFields.length > 0) {
|
|
groups.push({
|
|
group: t('queryBuilder.standardTags', {
|
|
postProcess: 'titleCase',
|
|
}),
|
|
items: NDSongQueryFields,
|
|
});
|
|
}
|
|
|
|
if (groups.length === 0) {
|
|
return NDSongQueryFields;
|
|
}
|
|
|
|
if (groups.length === 1) {
|
|
return groups[0].items;
|
|
}
|
|
|
|
return groups;
|
|
}, [customFields, t]);
|
|
|
|
// Memoize sort options
|
|
const sortOptions = useMemo(
|
|
() => [
|
|
{
|
|
label: t('filter.random', { postProcess: 'titleCase' }),
|
|
type: 'string',
|
|
value: 'random',
|
|
},
|
|
...NDSongQueryFields,
|
|
],
|
|
[t],
|
|
);
|
|
|
|
// Memoize order select data
|
|
const orderSelectData = useMemo(
|
|
() => [
|
|
{
|
|
label: t('common.ascending', { postProcess: 'sentenceCase' }),
|
|
value: 'asc',
|
|
},
|
|
{
|
|
label: t('common.descending', { postProcess: 'sentenceCase' }),
|
|
value: 'desc',
|
|
},
|
|
],
|
|
[t],
|
|
);
|
|
|
|
// Memoize operators object
|
|
const operators = useMemo(
|
|
() => ({
|
|
boolean: NDSongQueryBooleanOperators,
|
|
date: NDSongQueryDateOperators,
|
|
number: NDSongQueryNumberOperators,
|
|
playlist: NDSongQueryPlaylistOperators,
|
|
string: NDSongQueryStringOperators,
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const handleAddSortEntry = useCallback(() => {
|
|
extraFiltersForm.insertListItem('sortEntries', { field: '', order: 'asc' });
|
|
}, [extraFiltersForm]);
|
|
|
|
const handleRemoveSortEntry = useCallback(
|
|
(index: number) => {
|
|
extraFiltersForm.removeListItem('sortEntries', index);
|
|
},
|
|
[extraFiltersForm],
|
|
);
|
|
|
|
const handleSortFieldChange = useCallback(
|
|
(index: number, value: string) => {
|
|
extraFiltersForm.setFieldValue(`sortEntries.${index}.field`, value);
|
|
},
|
|
[extraFiltersForm],
|
|
);
|
|
|
|
const handleSortOrderChange = useCallback(
|
|
(index: number, value: 'asc' | 'desc') => {
|
|
extraFiltersForm.setFieldValue(`sortEntries.${index}.order`, value);
|
|
},
|
|
[extraFiltersForm],
|
|
);
|
|
|
|
return (
|
|
<Flex direction="column" h="100%" w="100%">
|
|
<ScrollArea style={{ height: '100%' }}>
|
|
<Stack gap="md" h="100%" p="1rem">
|
|
<QueryBuilder
|
|
data={filters}
|
|
filters={groupedFilters}
|
|
groupIndex={[]}
|
|
level={0}
|
|
onAddRule={handleAddRule}
|
|
onAddRuleGroup={handleAddRuleGroup}
|
|
onChangeField={handleChangeField}
|
|
onChangeOperator={handleChangeOperator}
|
|
onChangeType={handleChangeType}
|
|
onChangeValue={handleChangeValue}
|
|
onClearFilters={handleClearFilters}
|
|
onDeleteRule={handleDeleteRule}
|
|
onDeleteRuleGroup={handleDeleteRuleGroup}
|
|
onResetFilters={handleResetFilters}
|
|
operators={operators}
|
|
playlists={playlistData}
|
|
uniqueId={filters.uniqueId}
|
|
/>
|
|
<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={orderSelectData}
|
|
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>
|
|
</Stack>
|
|
</ScrollArea>
|
|
</Flex>
|
|
);
|
|
},
|
|
);
|