diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c324e476e..5b9df0682 100755 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -211,6 +211,7 @@ "credentialsRequired": "credentials required", "endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}", "genericError": "an error occurred", + "invalidJson": "invalid JSON", "invalidServer": "invalid server", "localFontAccessDenied": "access denied to local fonts", "loginRateError": "too many login attempts, please try again in a few seconds", diff --git a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx index 8aa70aa26..18dc0093c 100644 --- a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx @@ -30,11 +30,15 @@ import { usePlaylistTarget, } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Box } from '/@/shared/components/box/box'; import { Button } from '/@/shared/components/button/button'; +import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; +import { JsonInput } from '/@/shared/components/json-input/json-input'; import { ConfirmModal } from '/@/shared/components/modal/modal'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; +import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { Spinner } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; @@ -42,6 +46,14 @@ import { toast } from '/@/shared/components/toast/toast'; import { LibraryItem, ServerType, SongListSort } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; +type AppliedJsonState = { + limit?: number; + query: Record; + sort?: string; +}; + +type EditorMode = 'builder' | 'json'; + interface PlaylistQueryEditorProps { createPlaylistMutation: ReturnType; detailQuery: ReturnType>; @@ -59,6 +71,33 @@ interface PlaylistQueryEditorProps { queryBuilderRef: React.RefObject; } +const serializeFiltersToRulesJson = (filters: { + extraFilters: { limit?: number; sortBy?: string[] }; + filters: any; +}): Record => { + const queryValue = convertQueryGroupToNDQuery(filters.filters); + const sortString = filters.extraFilters.sortBy?.[0]; + return { + ...queryValue, + ...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }), + ...(sortString && { sort: sortString }), + }; +}; + +const parseRulesJsonToSaveArgs = ( + parsed: Record, +): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record } => { + const rootKey = parsed.all ? 'all' : 'any'; + const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] }; + return { + extraFilters: { + ...(parsed.limit != null && { limit: parsed.limit }), + ...(parsed.sort != null && { sortBy: [parsed.sort] }), + }, + filter, + }; +}; + const PlaylistQueryEditor = ({ createPlaylistMutation, detailQuery, @@ -71,57 +110,74 @@ const PlaylistQueryEditor = ({ }: PlaylistQueryEditorProps) => { const { t } = useTranslation(); - const openPreviewModal = useCallback(() => { - const filters = queryBuilderRef.current?.getFilters(); + const [editorMode, setEditorMode] = useState('builder'); + const [jsonText, setJsonText] = useState(''); + const [appliedJsonState, setAppliedJsonState] = useState(null); - if (!filters) { + const getFiltersForSave = useCallback((): null | { + extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }; + filter: Record; + } => { + if (editorMode === 'json') { + try { + const parsed = JSON.parse(jsonText) as Record; + const { extraFilters, filter } = parseRulesJsonToSaveArgs(parsed); + return { extraFilters, filter }; + } catch { + return null; + } + } + const filters = queryBuilderRef.current?.getFilters(); + if (!filters) return null; + return { + extraFilters: filters.extraFilters, + filter: convertQueryGroupToNDQuery(filters.filters), + }; + }, [editorMode, jsonText, queryBuilderRef]); + + const openPreviewModal = useCallback(() => { + const payload = getFiltersForSave(); + if (!payload) { + if (editorMode === 'json') { + toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) }); + } 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 }), + ...payload.filter, + ...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }), + ...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }), }; - openModal({ children: , size: 'xl', title: t('common.preview', { postProcess: 'titleCase' }), }); - }, [queryBuilderRef, t]); + }, [editorMode, getFiltersForSave, t]); const openSaveAndReplaceModal = useCallback(() => { - if (!isQueryBuilderExpanded) { + if (!isQueryBuilderExpanded) return; + const payload = getFiltersForSave(); + if (!payload) { + if (editorMode === 'json') { + toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) }); + } return; } - - const filters = queryBuilderRef.current?.getFilters(); - - if (!filters) { - return; - } - openModal({ children: ( { - handleSave( - convertQueryGroupToNDQuery(filters.filters), - filters.extraFilters, - ); + handleSave(payload.filter, payload.extraFilters); closeAllModals(); }} > {t('common.areYouSure', { postProcess: 'sentenceCase' })} ), - title: t('common.saveAndReplace', { postProcess: 'sentenceCase' }), + title: t('common.saveAndReplace', { postProcess: 'titleCase' }), }); - }, [isQueryBuilderExpanded, queryBuilderRef, handleSave, t]); + }, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]); const parseSortBy = useCallback((): string[] => { const sort = detailQuery?.data?.rules?.sort; @@ -163,6 +219,75 @@ const PlaylistQueryEditor = ({ return detailQuery?.data?.rules?.order || 'asc'; }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]); + const effectiveQuery = useMemo( + () => + appliedJsonState?.query ?? + (detailQuery?.data?.rules?.all + ? { all: detailQuery.data.rules.all } + : detailQuery?.data?.rules?.any + ? { any: detailQuery.data.rules.any } + : detailQuery?.data?.rules), + [appliedJsonState?.query, detailQuery?.data?.rules], + ); + const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit; + const effectiveSortBy = useMemo( + () => + (appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as + | SongListSort + | SongListSort[], + [appliedJsonState?.sort, parseSortBy], + ); + const effectiveSortOrder = appliedJsonState?.sort + ? appliedJsonState.sort.startsWith('-') + ? 'desc' + : 'asc' + : parseSortOrder(); + + const handleEditorModeChange = useCallback( + (value: string) => { + const nextMode = value as EditorMode; + if (nextMode === 'json') { + const filters = queryBuilderRef.current?.getFilters(); + if (filters) { + setJsonText(JSON.stringify(serializeFiltersToRulesJson(filters), null, 2)); + } else { + const fallback: Record = effectiveQuery + ? { ...effectiveQuery } + : { all: [] }; + if (effectiveLimit != null) fallback.limit = effectiveLimit; + if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0]; + if (!fallback.sort) fallback.sort = '+dateAdded'; + setJsonText(JSON.stringify(fallback, null, 2)); + } + setEditorMode('json'); + } else { + if (editorMode === 'json') { + try { + const parsed = JSON.parse(jsonText) as Record; + const rootKey = parsed.all ? 'all' : 'any'; + if (!parsed[rootKey] || !Array.isArray(parsed[rootKey])) { + throw new Error('Invalid rules structure'); + } + setAppliedJsonState({ + limit: parsed.limit, + query: { [rootKey]: parsed[rootKey] }, + sort: parsed.sort, + }); + } catch { + toast.error({ + message: t('error.invalidJson', { + postProcess: 'sentenceCase', + }), + }); + return; + } + } + setEditorMode('builder'); + } + }, + [editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t], + ); + return (
+ {isQueryBuilderExpanded && ( + + + + ), + value: 'builder', + }, + { + label: ( + + + + ), + value: 'json', + }, + ]} + onChange={handleEditorModeChange} + size="xs" + value={editorMode} + /> + )} -
- -
+ {editorMode === 'builder' ? ( + + ) : ( + setJsonText(value)} + placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }' + style={{ flex: 1, minHeight: 0 }} + value={jsonText} + /> + )} +
); diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index f6727db3e..87d4f9c64 100755 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -21,6 +21,7 @@ import { LuArrowUpNarrowWide, LuArrowUpToLine, LuBookOpen, + LuBraces, LuCheck, LuChevronDown, LuChevronLast, @@ -117,6 +118,7 @@ import { LuVolumeX, LuWifi, LuWifiOff, + LuWrench, LuX, } from 'react-icons/lu'; import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md'; @@ -187,6 +189,7 @@ export const AppIcon = { info: LuInfo, itemAlbum: LuDisc3, itemSong: LuMusic, + json: LuBraces, keyboard: LuKeyboard, lastPlayed: LuHeadphones, layoutDetail: LuLayoutList, @@ -227,6 +230,7 @@ export const AppIcon = { playlistAdd: LuListPlus, playlistDelete: LuListMinus, plus: LuPlus, + queryBuilder: LuWrench, queue: LuList, radio: LuRadio, refresh: LuRotateCw,