support custom smart playlist tags

This commit is contained in:
jeffvli
2025-11-29 15:56:18 -08:00
parent d22fee887c
commit 6094a520e2
7 changed files with 278 additions and 15 deletions
@@ -18,6 +18,7 @@ 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,
@@ -168,6 +169,7 @@ export const PlaylistQueryBuilder = forwardRef(
) => {
const { t } = useTranslation();
const server = useCurrentServer();
const queryBuilderSettings = useQueryBuilderSettings();
// Memoize initial filters to avoid recalculation
const initialFilters = useMemo(
@@ -412,6 +414,54 @@ export const PlaylistQueryBuilder = forwardRef(
});
}, []);
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(
() => [
@@ -483,7 +533,7 @@ export const PlaylistQueryBuilder = forwardRef(
<Stack gap="md" h="100%" p="1rem">
<QueryBuilder
data={filters}
filters={NDSongQueryFields}
filters={groupedFilters}
groupIndex={[]}
level={0}
onAddRule={handleAddRule}
@@ -1,24 +1,40 @@
import { useMemo } from 'react';
import { Fragment } from 'react/jsx-runtime';
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
import { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-settings';
import { QueryBuilderSettings } from '/@/renderer/features/settings/components/general/query-builder-settings';
import { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings';
import { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings';
import { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-settings';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Divider } from '/@/shared/components/divider/divider';
import { Stack } from '/@/shared/components/stack/stack';
const sections = [
{ component: ApplicationSettings, key: 'application' },
{ component: ThemeSettings, key: 'theme' },
{ component: ControlSettings, key: 'control' },
{ component: SidebarSettings, key: 'sidebar' },
{ component: ScrobbleSettings, key: 'scrobble' },
{ component: LyricSettings, key: 'lyrics' },
];
import { ServerFeature } from '/@/shared/types/features-types';
export const GeneralTab = () => {
const server = useCurrentServer();
const supportsSmartPlaylists = hasFeature(server, ServerFeature.PLAYLISTS_SMART);
const sections = useMemo(() => {
const baseSections = [
{ component: ApplicationSettings, key: 'application' },
{ component: ThemeSettings, key: 'theme' },
{ component: ControlSettings, key: 'control' },
{ component: SidebarSettings, key: 'sidebar' },
{ component: ScrobbleSettings, key: 'scrobble' },
{ component: LyricSettings, key: 'lyrics' },
];
if (supportsSmartPlaylists) {
baseSections.push({ component: QueryBuilderSettings, key: 'queryBuilder' });
}
return baseSections;
}, [supportsSmartPlaylists]);
return (
<Stack gap="md">
{sections.map(({ component: Section, key }, index) => (
@@ -0,0 +1,151 @@
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useQueryBuilderSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
const QUERY_VALUE_INPUT_TYPES = [
{ label: 'Boolean', value: 'boolean' },
{ label: 'Date', value: 'date' },
{ label: 'Date Range', value: 'dateRange' },
{ label: 'Number', value: 'number' },
{ label: 'Playlist', value: 'playlist' },
{ label: 'String', value: 'string' },
] as const;
export const QueryBuilderSettings = () => {
const { t } = useTranslation();
const queryBuilder = useQueryBuilderSettings();
const { setSettings } = useSettingsStoreActions();
const handleAddCustomField = () => {
setSettings({
queryBuilder: {
tag: [
...queryBuilder.tag,
{
label: '',
type: 'string',
value: '',
},
],
},
});
};
const handleRemoveCustomField = (index: number) => {
setSettings({
queryBuilder: {
tag: queryBuilder.tag.filter((_, i) => i !== index),
},
});
};
const handleUpdateCustomField = (
index: number,
field: 'label' | 'type' | 'value',
newValue: string,
) => {
setSettings({
queryBuilder: {
tag: queryBuilder.tag.map((item, i) =>
i === index ? { ...item, [field]: newValue } : item,
),
},
});
};
const customFieldsOptions: SettingOption[] = [
{
control: (
<Stack gap="md">
{queryBuilder.tag.length > 0 && (
<Stack gap="sm">
{queryBuilder.tag.map((field, index) => (
<Group gap="sm" key={index}>
<TextInput
onChange={(e) =>
handleUpdateCustomField(
index,
'label',
e.currentTarget.value,
)
}
placeholder={t(
'setting.queryBuilderCustomFields_inputLabel',
{
postProcess: 'sentenceCase',
},
)}
value={field.label}
width="30%"
/>
<Select
data={QUERY_VALUE_INPUT_TYPES}
onChange={(e) =>
handleUpdateCustomField(index, 'type', e || 'string')
}
value={field.type}
width="25%"
/>
<TextInput
onChange={(e) =>
handleUpdateCustomField(
index,
'value',
e.currentTarget.value,
)
}
placeholder={t(
'setting.queryBuilderCustomFields_inputTag',
{
postProcess: 'sentenceCase',
},
)}
value={field.value}
width="30%"
/>
<ActionIcon
icon="remove"
onClick={() => handleRemoveCustomField(index)}
size="sm"
variant="subtle"
/>
</Group>
))}
</Stack>
)}
<Group grow>
<Button onClick={handleAddCustomField} variant="filled">
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
),
description: t('setting.queryBuilderCustomFields', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.queryBuilderCustomFields', {
postProcess: 'sentenceCase',
}),
},
];
return (
<SettingsSection
options={customFieldsOptions}
title={t('page.setting.queryBuilder', {
postProcess: 'sentenceCase',
})}
/>
);
};