mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
support custom smart playlist tags
This commit is contained in:
@@ -481,6 +481,7 @@
|
|||||||
"updates": "update",
|
"updates": "update",
|
||||||
"cache": "cache",
|
"cache": "cache",
|
||||||
"application": "application",
|
"application": "application",
|
||||||
|
"queryBuilder": "query builder",
|
||||||
"theme": "theme",
|
"theme": "theme",
|
||||||
"controls": "controls",
|
"controls": "controls",
|
||||||
"sidebar": "sidebar",
|
"sidebar": "sidebar",
|
||||||
@@ -553,6 +554,10 @@
|
|||||||
"pause": "pause",
|
"pause": "pause",
|
||||||
"viewQueue": "view queue"
|
"viewQueue": "view queue"
|
||||||
},
|
},
|
||||||
|
"queryBuilder": {
|
||||||
|
"standardTags": "standard tags",
|
||||||
|
"customTags": "custom tags"
|
||||||
|
},
|
||||||
"releaseType": {
|
"releaseType": {
|
||||||
"primary": {
|
"primary": {
|
||||||
"album": "$t(entity.album_one)",
|
"album": "$t(entity.album_one)",
|
||||||
@@ -856,7 +861,12 @@
|
|||||||
"windowBarStyle_description": "select the style of the window bar",
|
"windowBarStyle_description": "select the style of the window bar",
|
||||||
"windowBarStyle": "window bar style",
|
"windowBarStyle": "window bar style",
|
||||||
"zoom_description": "sets the zoom percentage for the application",
|
"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": {
|
"table": {
|
||||||
"column": {
|
"column": {
|
||||||
|
|||||||
@@ -11,19 +11,24 @@ import { Select } from '/@/shared/components/select/select';
|
|||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
|
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 = {
|
type AddArgs = {
|
||||||
groupIndex: number[];
|
groupIndex: number[];
|
||||||
level: number;
|
level: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeleteArgs = {
|
type DeleteArgs = {
|
||||||
groupIndex: number[];
|
groupIndex: number[];
|
||||||
level: number;
|
level: number;
|
||||||
uniqueId: string;
|
uniqueId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface QueryBuilderProps {
|
interface QueryBuilderProps {
|
||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
filters: { label: string; type: string; value: string }[];
|
filters: Filters;
|
||||||
groupIndex: number[];
|
groupIndex: number[];
|
||||||
level: number;
|
level: number;
|
||||||
onAddRule: (args: AddArgs) => void;
|
onAddRule: (args: AddArgs) => void;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Filters } from '/@/renderer/components/query-builder';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
@@ -15,7 +16,7 @@ type DeleteArgs = {
|
|||||||
|
|
||||||
interface QueryOptionProps {
|
interface QueryOptionProps {
|
||||||
data: QueryBuilderRule;
|
data: QueryBuilderRule;
|
||||||
filters: { label: string; type: string; value: string }[];
|
filters: Filters;
|
||||||
groupIndex: number[];
|
groupIndex: number[];
|
||||||
level: number;
|
level: number;
|
||||||
noRemove: boolean;
|
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 operatorsByFieldType = operators[fieldType as keyof typeof operators];
|
||||||
const ml = 20;
|
const ml = 20;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { QueryBuilder } from '/@/renderer/components/query-builder';
|
|||||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||||
import { convertNDQueryToQueryGroup } from '/@/renderer/features/playlists/utils';
|
import { convertNDQueryToQueryGroup } from '/@/renderer/features/playlists/utils';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { useQueryBuilderSettings } from '/@/renderer/store/settings.store';
|
||||||
import {
|
import {
|
||||||
NDSongQueryBooleanOperators,
|
NDSongQueryBooleanOperators,
|
||||||
NDSongQueryDateOperators,
|
NDSongQueryDateOperators,
|
||||||
@@ -168,6 +169,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||||||
) => {
|
) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
const queryBuilderSettings = useQueryBuilderSettings();
|
||||||
|
|
||||||
// Memoize initial filters to avoid recalculation
|
// Memoize initial filters to avoid recalculation
|
||||||
const initialFilters = useMemo(
|
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
|
// Memoize sort options
|
||||||
const sortOptions = useMemo(
|
const sortOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -483,7 +533,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||||||
<Stack gap="md" h="100%" p="1rem">
|
<Stack gap="md" h="100%" p="1rem">
|
||||||
<QueryBuilder
|
<QueryBuilder
|
||||||
data={filters}
|
data={filters}
|
||||||
filters={NDSongQueryFields}
|
filters={groupedFilters}
|
||||||
groupIndex={[]}
|
groupIndex={[]}
|
||||||
level={0}
|
level={0}
|
||||||
onAddRule={handleAddRule}
|
onAddRule={handleAddRule}
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { Fragment } from 'react/jsx-runtime';
|
import { Fragment } from 'react/jsx-runtime';
|
||||||
|
|
||||||
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
|
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
|
||||||
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
|
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
|
||||||
import { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-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 { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings';
|
||||||
import { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings';
|
import { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings';
|
||||||
import { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-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 { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const GeneralTab = () => {
|
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 (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{sections.map(({ component: Section, key }, index) => (
|
{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),
|
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
|
* 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),
|
lists: z.record(z.nativeEnum(ItemListKey), ItemListConfigSchema),
|
||||||
lyrics: LyricsSettingsSchema,
|
lyrics: LyricsSettingsSchema,
|
||||||
playback: PlaybackSettingsSchema,
|
playback: PlaybackSettingsSchema,
|
||||||
|
queryBuilder: QueryBuilderSettingsSchema,
|
||||||
remote: RemoteSettingsSchema,
|
remote: RemoteSettingsSchema,
|
||||||
tab: z.union([
|
tab: z.union([
|
||||||
z.literal('general'),
|
z.literal('general'),
|
||||||
@@ -1105,6 +1125,9 @@ const initialState: SettingsState = {
|
|||||||
type: PlayerType.WEB,
|
type: PlayerType.WEB,
|
||||||
webAudio: true,
|
webAudio: true,
|
||||||
},
|
},
|
||||||
|
queryBuilder: {
|
||||||
|
tag: [],
|
||||||
|
},
|
||||||
remote: {
|
remote: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
password: randomString(8),
|
password: randomString(8),
|
||||||
@@ -1297,6 +1320,9 @@ export const useDiscordSettings = () => useSettingsStore((state) => state.discor
|
|||||||
|
|
||||||
export const useCssSettings = () => useSettingsStore((state) => state.css, shallow);
|
export const useCssSettings = () => useSettingsStore((state) => state.css, shallow);
|
||||||
|
|
||||||
|
export const useQueryBuilderSettings = () =>
|
||||||
|
useSettingsStore((state) => state.queryBuilder, shallow);
|
||||||
|
|
||||||
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
||||||
|
|
||||||
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
||||||
|
|||||||
Reference in New Issue
Block a user