mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
support custom smart playlist tags
This commit is contained in:
@@ -481,6 +481,7 @@
|
||||
"updates": "update",
|
||||
"cache": "cache",
|
||||
"application": "application",
|
||||
"queryBuilder": "query builder",
|
||||
"theme": "theme",
|
||||
"controls": "controls",
|
||||
"sidebar": "sidebar",
|
||||
@@ -553,6 +554,10 @@
|
||||
"pause": "pause",
|
||||
"viewQueue": "view queue"
|
||||
},
|
||||
"queryBuilder": {
|
||||
"standardTags": "standard tags",
|
||||
"customTags": "custom tags"
|
||||
},
|
||||
"releaseType": {
|
||||
"primary": {
|
||||
"album": "$t(entity.album_one)",
|
||||
@@ -856,7 +861,12 @@
|
||||
"windowBarStyle_description": "select the style of the window bar",
|
||||
"windowBarStyle": "window bar style",
|
||||
"zoom_description": "sets the zoom percentage for the application",
|
||||
"zoom": "zoom percentage"
|
||||
"zoom": "zoom percentage",
|
||||
"queryBuilder": "query builder",
|
||||
"queryBuilderCustomFields_inputLabel": "label",
|
||||
"queryBuilderCustomFields_inputTag": "tag",
|
||||
"queryBuilderCustomFields": "custom fields",
|
||||
"queryBuilderCustomFields_description": "add custom fields to use in query builders"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
|
||||
@@ -11,19 +11,24 @@ import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
|
||||
|
||||
export type FilterGroup = { group: string; items: FilterItem[] };
|
||||
|
||||
export type FilterItem = { label: string; type: string; value: string };
|
||||
|
||||
export type Filters = FilterGroup[] | FilterItem[];
|
||||
type AddArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
};
|
||||
|
||||
type DeleteArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
};
|
||||
|
||||
interface QueryBuilderProps {
|
||||
data: Record<string, any>;
|
||||
filters: { label: string; type: string; value: string }[];
|
||||
filters: Filters;
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
onAddRule: (args: AddArgs) => void;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Filters } from '/@/renderer/components/query-builder';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
@@ -15,7 +16,7 @@ type DeleteArgs = {
|
||||
|
||||
interface QueryOptionProps {
|
||||
data: QueryBuilderRule;
|
||||
filters: { label: string; type: string; value: string }[];
|
||||
filters: Filters;
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
noRemove: boolean;
|
||||
@@ -165,7 +166,11 @@ export const QueryBuilderOption = ({
|
||||
});
|
||||
};
|
||||
|
||||
const fieldType = filters.find((f) => f.value === field)?.type;
|
||||
// Handle both grouped and flat filter data
|
||||
const flatFilters = filters.some((f: any) => f.group && f.items)
|
||||
? filters.flatMap((group: any) => group.items || [])
|
||||
: filters;
|
||||
const fieldType = flatFilters.find((f: any) => f.value === field)?.type;
|
||||
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
|
||||
const ml = 20;
|
||||
|
||||
|
||||
@@ -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',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -330,6 +330,25 @@ const WindowSettingsSchema = z.object({
|
||||
windowBarStyle: z.nativeEnum(Platform),
|
||||
});
|
||||
|
||||
const QueryValueInputTypeSchema = z.enum([
|
||||
'boolean',
|
||||
'date',
|
||||
'dateRange',
|
||||
'number',
|
||||
'playlist',
|
||||
'string',
|
||||
]);
|
||||
|
||||
const QueryBuilderCustomFieldSchema = z.object({
|
||||
label: z.string(),
|
||||
type: QueryValueInputTypeSchema,
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const QueryBuilderSettingsSchema = z.object({
|
||||
tag: z.array(QueryBuilderCustomFieldSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* This schema is used for validation of the imported settings json
|
||||
*/
|
||||
@@ -342,6 +361,7 @@ export const ValidationSettingsStateSchema = z.object({
|
||||
lists: z.record(z.nativeEnum(ItemListKey), ItemListConfigSchema),
|
||||
lyrics: LyricsSettingsSchema,
|
||||
playback: PlaybackSettingsSchema,
|
||||
queryBuilder: QueryBuilderSettingsSchema,
|
||||
remote: RemoteSettingsSchema,
|
||||
tab: z.union([
|
||||
z.literal('general'),
|
||||
@@ -1105,6 +1125,9 @@ const initialState: SettingsState = {
|
||||
type: PlayerType.WEB,
|
||||
webAudio: true,
|
||||
},
|
||||
queryBuilder: {
|
||||
tag: [],
|
||||
},
|
||||
remote: {
|
||||
enabled: false,
|
||||
password: randomString(8),
|
||||
@@ -1297,6 +1320,9 @@ export const useDiscordSettings = () => useSettingsStore((state) => state.discor
|
||||
|
||||
export const useCssSettings = () => useSettingsStore((state) => state.css, shallow);
|
||||
|
||||
export const useQueryBuilderSettings = () =>
|
||||
useSettingsStore((state) => state.queryBuilder, shallow);
|
||||
|
||||
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
||||
|
||||
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
||||
|
||||
Reference in New Issue
Block a user