From c39ddc3b4564582c0329024518477b39a9a6e120 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 13 Feb 2026 21:05:37 -0800 Subject: [PATCH] refactor PlaylistQueryEditor to new file --- .../components/playlist-query-editor.tsx | 393 ++++++++++++++++++ .../playlist-detail-song-list-route.tsx | 382 +---------------- 2 files changed, 397 insertions(+), 378 deletions(-) create mode 100644 src/renderer/features/playlists/components/playlist-query-editor.tsx diff --git a/src/renderer/features/playlists/components/playlist-query-editor.tsx b/src/renderer/features/playlists/components/playlist-query-editor.tsx new file mode 100644 index 000000000..8bcec2cb8 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-query-editor.tsx @@ -0,0 +1,393 @@ +import { closeAllModals, openModal } from '@mantine/modals'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + PlaylistQueryBuilder, + PlaylistQueryBuilderRef, +} from '/@/renderer/features/playlists/components/playlist-query-builder'; +import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; +import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils'; +import { JsonPreview } from '/@/renderer/features/shared/components/json-preview'; +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 { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; +import { SongListSort } from '/@/shared/types/domain-types'; + +export interface PlaylistQueryEditorProps { + createPlaylistMutation: ReturnType; + detailQuery: ReturnType>; + handleSave: ( + filter: Record, + extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, + ) => void; + handleSaveAs: ( + filter: Record, + extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, + ) => void; + isQueryBuilderExpanded: boolean; + onToggleExpand: () => void; + playlistId: string; + queryBuilderRef: React.RefObject; +} + +type AppliedJsonState = { + limit?: number; + query: Record; + sort?: string; +}; + +type EditorMode = 'builder' | 'json'; + +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, + }; +}; + +export const PlaylistQueryEditor = ({ + createPlaylistMutation, + detailQuery, + handleSave, + handleSaveAs, + isQueryBuilderExpanded, + onToggleExpand, + playlistId, + queryBuilderRef, +}: PlaylistQueryEditorProps) => { + const { t } = useTranslation(); + + const [editorMode, setEditorMode] = useState('builder'); + const [jsonText, setJsonText] = useState(''); + const [appliedJsonState, setAppliedJsonState] = useState(null); + + 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 previewValue = { + ...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' }), + }); + }, [editorMode, getFiltersForSave, t]); + + const openSaveAndReplaceModal = useCallback(() => { + if (!isQueryBuilderExpanded) return; + const payload = getFiltersForSave(); + if (!payload) { + if (editorMode === 'json') { + toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) }); + } + return; + } + openModal({ + children: ( + { + handleSave(payload.filter, payload.extraFilters); + closeAllModals(); + }} + > + {t('common.areYouSure', { postProcess: 'sentenceCase' })} + + ), + title: t('common.saveAndReplace', { postProcess: 'titleCase' }), + }); + }, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]); + + const parseSortBy = useCallback((): string[] => { + const sort = detailQuery?.data?.rules?.sort; + // Handle new syntax: comma-separated with +/- prefix + // e.g., "+album,-year" -> return as single string in array + if (typeof sort === 'string') { + // Check if it's new syntax (has +/- prefix or commas) + if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) { + return [sort]; + } + // Old syntax: single field, convert to new format with default order + const order = detailQuery?.data?.rules?.order || 'asc'; + const prefix = order === 'desc' ? '-' : '+'; + return [`${prefix}${sort}`]; + } + if (Array.isArray(sort)) { + // If array, check if first item has +/- prefix + if ( + sort.length > 0 && + typeof sort[0] === 'string' && + (sort[0].startsWith('+') || sort[0].startsWith('-')) + ) { + return sort; + } + // Old array format, convert to new format + const order = detailQuery?.data?.rules?.order || 'asc'; + const prefix = order === 'desc' ? '-' : '+'; + return sort.map((s) => `${prefix}${s}`); + } + return ['+dateAdded']; + }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]); + + const parseSortOrder = useCallback((): 'asc' | 'desc' => { + const sort = detailQuery?.data?.rules?.sort; + if (typeof sort === 'string' && sort.startsWith('-')) { + return 'desc'; + } + // Fall back to old order field or default + 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" }' + spellCheck={false} + style={{ flex: 1, minHeight: 0 }} + value={jsonText} + /> + + )} + + +
+ ); +}; 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 18dc0093c..18750aa66 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 @@ -1,6 +1,6 @@ import { closeAllModals, openModal } from '@mantine/modals'; import { useQuery } from '@tanstack/react-query'; -import { Suspense, useCallback, useMemo, useRef, useState } from 'react'; +import { Suspense, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, useLocation, useNavigate, useParams } from 'react-router'; @@ -9,17 +9,13 @@ import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-a import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters'; import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content'; import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header'; -import { - PlaylistQueryBuilder, - PlaylistQueryBuilderRef, -} from '/@/renderer/features/playlists/components/playlist-query-builder'; +import { PlaylistQueryBuilderRef } from '/@/renderer/features/playlists/components/playlist-query-builder'; +import { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor'; import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form'; import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters'; import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; -import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; -import { JsonPreview } from '/@/renderer/features/shared/components/json-preview'; import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { AppRoute } from '/@/renderer/router/routes'; @@ -30,387 +26,17 @@ 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'; import { toast } from '/@/shared/components/toast/toast'; -import { LibraryItem, ServerType, SongListSort } from '/@/shared/types/domain-types'; +import { LibraryItem, ServerType } 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>; - handleSave: ( - filter: Record, - extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, - ) => void; - handleSaveAs: ( - filter: Record, - extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, - ) => void; - isQueryBuilderExpanded: boolean; - onToggleExpand: () => void; - playlistId: string; - 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, - handleSave, - handleSaveAs, - isQueryBuilderExpanded, - onToggleExpand, - playlistId, - queryBuilderRef, -}: PlaylistQueryEditorProps) => { - const { t } = useTranslation(); - - const [editorMode, setEditorMode] = useState('builder'); - const [jsonText, setJsonText] = useState(''); - const [appliedJsonState, setAppliedJsonState] = useState(null); - - 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 previewValue = { - ...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' }), - }); - }, [editorMode, getFiltersForSave, t]); - - const openSaveAndReplaceModal = useCallback(() => { - if (!isQueryBuilderExpanded) return; - const payload = getFiltersForSave(); - if (!payload) { - if (editorMode === 'json') { - toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) }); - } - return; - } - openModal({ - children: ( - { - handleSave(payload.filter, payload.extraFilters); - closeAllModals(); - }} - > - {t('common.areYouSure', { postProcess: 'sentenceCase' })} - - ), - title: t('common.saveAndReplace', { postProcess: 'titleCase' }), - }); - }, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]); - - const parseSortBy = useCallback((): string[] => { - const sort = detailQuery?.data?.rules?.sort; - // Handle new syntax: comma-separated with +/- prefix - // e.g., "+album,-year" -> return as single string in array - if (typeof sort === 'string') { - // Check if it's new syntax (has +/- prefix or commas) - if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) { - return [sort]; - } - // Old syntax: single field, convert to new format with default order - const order = detailQuery?.data?.rules?.order || 'asc'; - const prefix = order === 'desc' ? '-' : '+'; - return [`${prefix}${sort}`]; - } - if (Array.isArray(sort)) { - // If array, check if first item has +/- prefix - if ( - sort.length > 0 && - typeof sort[0] === 'string' && - (sort[0].startsWith('+') || sort[0].startsWith('-')) - ) { - return sort; - } - // Old array format, convert to new format - const order = detailQuery?.data?.rules?.order || 'asc'; - const prefix = order === 'desc' ? '-' : '+'; - return sort.map((s) => `${prefix}${s}`); - } - return ['+dateAdded']; - }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]); - - const parseSortOrder = useCallback((): 'asc' | 'desc' => { - const sort = detailQuery?.data?.rules?.sort; - if (typeof sort === 'string' && sort.startsWith('-')) { - return 'desc'; - } - // Fall back to old order field or default - 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} - /> - )} - - -
- ); -}; - const PlaylistSongListFiltersSidebar = () => { const { t } = useTranslation(); const { setIsSidebarOpen } = useListContext();