diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 11095a747..3ee95a8f1 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -72,16 +72,21 @@ export const App = () => { } }, [language]); + const notificationStyles = useMemo( + () => ({ + root: { + marginBottom: 90, + }, + }), + [], + ); + return ( diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 33e3bc7bc..3972d5e89 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { AnimatePresence } from 'motion/react'; -import { Fragment, memo, ReactNode, useState } from 'react'; +import { Fragment, memo, ReactNode, useCallback, useMemo, useState } from 'react'; import { generatePath, Link } from 'react-router'; import styles from './item-card.module.css'; @@ -84,7 +84,7 @@ export const ItemCard = ({ switch (type) { case 'compact': return ( - ({ - drag: { - getId: () => { - if (!data) { - return []; - } + const getId = useCallback(() => { + if (!data) { + return []; + } - const draggedItems = getDraggedItems(data, internalState); - return draggedItems.map((item) => item.id); - }, - getItem: () => { - if (!data) { - return []; - } + const draggedItems = getDraggedItems(data, internalState); + return draggedItems.map((item) => item.id); + }, [data, internalState]); - const draggedItems = getDraggedItems(data, internalState); - return draggedItems; - }, + const getItem = useCallback(() => { + if (!data) { + return []; + } + + const draggedItems = getDraggedItems(data, internalState); + return draggedItems; + }, [data, internalState]); + + const onDragStart = useCallback(() => { + if (!data) { + return; + } + + const draggedItems = getDraggedItems(data, internalState); + if (internalState) { + internalState.setDragging(draggedItems); + } + }, [data, internalState]); + + const onDrop = useCallback(() => { + if (internalState) { + internalState.setDragging([]); + } + }, [internalState]); + + const dragOperation = useMemo( + () => + itemType === LibraryItem.QUEUE_SONG + ? [DragOperation.REORDER, DragOperation.ADD] + : [DragOperation.ADD], + [itemType], + ); + + const drag = useMemo( + () => ({ + getId, + getItem, itemType, - onDragStart: () => { - if (!data) { - return; - } - - const draggedItems = getDraggedItems(data, internalState); - if (internalState) { - internalState.setDragging(draggedItems); - } - }, - onDrop: () => { - if (internalState) { - internalState.setDragging([]); - } - }, - operation: - itemType === LibraryItem.QUEUE_SONG - ? [DragOperation.REORDER, DragOperation.ADD] - : [DragOperation.ADD], + onDragStart, + onDrop, + operation: dragOperation, target: DragTarget.ALBUM, - }, + }), + [getId, getItem, itemType, onDragStart, onDrop, dragOperation], + ); + + const { isDragging: isDraggingLocal, ref } = useDragDrop({ + drag, isEnabled: !!enableDrag && !!data, }); @@ -649,46 +667,64 @@ const PosterItemCard = ({ : undefined; const isSelected = useItemSelectionState(internalState, itemRowId || undefined); - const { isDragging: isDraggingLocal, ref } = useDragDrop({ - drag: { - getId: () => { - if (!data) { - return []; - } + const getId = useCallback(() => { + if (!data) { + return []; + } - const draggedItems = getDraggedItems(data, internalState); - return draggedItems.map((item) => item.id); - }, - getItem: () => { - if (!data) { - return []; - } + const draggedItems = getDraggedItems(data, internalState); + return draggedItems.map((item) => item.id); + }, [data, internalState]); - const draggedItems = getDraggedItems(data, internalState); - return draggedItems; - }, + const getItem = useCallback(() => { + if (!data) { + return []; + } + + const draggedItems = getDraggedItems(data, internalState); + return draggedItems; + }, [data, internalState]); + + const onDragStart = useCallback(() => { + if (!data) { + return; + } + + const draggedItems = getDraggedItems(data, internalState); + if (internalState) { + internalState.setDragging(draggedItems); + } + }, [data, internalState]); + + const onDrop = useCallback(() => { + if (internalState) { + internalState.setDragging([]); + } + }, [internalState]); + + const dragOperation = useMemo( + () => + itemType === LibraryItem.QUEUE_SONG + ? [DragOperation.REORDER, DragOperation.ADD] + : [DragOperation.ADD], + [itemType], + ); + + const drag = useMemo( + () => ({ + getId, + getItem, itemType, - onDragStart: () => { - if (!data) { - return; - } - - const draggedItems = getDraggedItems(data, internalState); - if (internalState) { - internalState.setDragging(draggedItems); - } - }, - onDrop: () => { - if (internalState) { - internalState.setDragging([]); - } - }, - operation: - itemType === LibraryItem.QUEUE_SONG - ? [DragOperation.REORDER, DragOperation.ADD] - : [DragOperation.ADD], + onDragStart, + onDrop, + operation: dragOperation, target: DragTarget.ALBUM, - }, + }), + [getId, getItem, itemType, onDragStart, onDrop, dragOperation], + ); + + const { isDragging: isDraggingLocal, ref } = useDragDrop({ + drag, isEnabled: !!enableDrag && !!data, }); @@ -896,6 +932,15 @@ const PosterItemCard = ({ ); }; +const MemoizedPosterItemCard = memo(PosterItemCard); +MemoizedPosterItemCard.displayName = 'MemoizedPosterItemCard'; + +const MemoizedCompactItemCard = memo(CompactItemCard); +MemoizedCompactItemCard.displayName = 'MemoizedCompactItemCard'; + +const MemoizedDefaultItemCard = memo(DefaultItemCard); +MemoizedDefaultItemCard.displayName = 'MemoizedDefaultItemCard'; + export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[] => { return [ { @@ -1160,56 +1205,67 @@ const getItemNavigationPath = ( return getTitlePath(effectiveItemType, data.id); }; -const ItemCardRow = ({ - data, - index, - row, - type, -}: { - data: Album | AlbumArtist | Artist | Playlist | Song | undefined; - index: number; - row: DataRow; - type?: 'compact' | 'default' | 'poster'; -}) => { - const alignmentClass = - row.align === 'center' - ? styles['align-center'] - : row.align === 'end' - ? styles['align-end'] - : styles['align-start']; +const ItemCardRow = memo( + ({ + data, + index, + row, + type, + }: { + data: Album | AlbumArtist | Artist | Playlist | Song | undefined; + index: number; + row: DataRow; + type?: 'compact' | 'default' | 'poster'; + }) => { + const alignmentClass = + row.align === 'center' + ? styles['align-center'] + : row.align === 'end' + ? styles['align-end'] + : styles['align-start']; - // All rows except the first one (index 0) should be muted - const isMuted = index > 0 || row.isMuted; + // All rows except the first one (index 0) should be muted + const isMuted = index > 0 || row.isMuted; + + const formattedContent = useMemo(() => { + if (!data) { + return null; + } + return row.format(data); + }, [data, row]); + + if (!data) { + return ( +
+   +
+ ); + } - if (!data) { return ( -
0 ? 'sm' : 'md'} > -   -
+ {formattedContent} + ); - } + }, +); - return ( - 0 ? 'sm' : 'md'} - > - {row.format(data)} - - ); -}; +ItemCardRow.displayName = 'ItemCardRow'; export const MemoizedItemCard = memo(ItemCard); diff --git a/src/renderer/components/query-builder/index.tsx b/src/renderer/components/query-builder/index.tsx index 8e822ab26..3dba3ad72 100644 --- a/src/renderer/components/query-builder/index.tsx +++ b/src/renderer/components/query-builder/index.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option'; @@ -107,15 +108,17 @@ export const QueryBuilder = ({ onChangeType({ groupIndex, level, value }); }; + const boxStyle = useMemo( + () => ({ + border: '1px solid var(--theme-colors-border)', + borderRadius: 'var(--theme-radius-md)', + marginLeft: level > 0 ? '20px' : '0px', + }), + [level], + ); + return ( - 0 ? '20px' : '0px', - }} - > + diff --git a/src/renderer/components/select-with-invalid-data/index.tsx b/src/renderer/components/select-with-invalid-data/index.tsx index 16000e651..07abbce1a 100644 --- a/src/renderer/components/select-with-invalid-data/index.tsx +++ b/src/renderer/components/select-with-invalid-data/index.tsx @@ -67,16 +67,13 @@ export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: Mul return [data, []]; }, [data, defaultValue]); - return ( - + const error = useMemo( + () => + missing.length + ? t('error.badValue', { postProcess: 'sentenceCase', value: missing }) + : undefined, + [missing, t], ); + + return ; }; diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index 535da84f9..45589eae7 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; @@ -147,8 +147,8 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte [setMaxYear], ); - const handleGenresFilter = useMemo( - () => (e: string[] | undefined) => { + const handleGenresFilter = useCallback( + (e: null | string[]) => { setGenreId(e && e.length > 0 ? e : null); }, [setGenreId], @@ -178,13 +178,16 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte })); }, [albumArtistListQuery.data?.items]); - const handleAlbumArtistFilter = (e: null | string[]) => { - setAlbumArtist(e ?? null); - }; + const handleAlbumArtistFilter = useCallback( + (e: null | string[]) => { + setAlbumArtist(e ?? null); + }, + [setAlbumArtist], + ); - const handleTagFilter = useMemo( - () => (e: string[] | undefined) => { - setCustom({ Tags: e?.join('|') ?? null }); + const handleTagFilter = useCallback( + (e: null | string[]) => { + setCustom({ Tags: e && e.length > 0 ? e.join('|') : null }); }, [setCustom], ); diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index ef2cee20a..69b6b1307 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -1,5 +1,5 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { ChangeEvent, useMemo } from 'react'; +import { ChangeEvent, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; @@ -148,6 +148,28 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300); + const handleGenreChange = useCallback( + (e: null | string[]) => { + if (e && e.length > 0) { + setGenreId(e); + } else { + setGenreId(null); + } + }, + [setGenreId], + ); + + const handleAlbumArtistChange = useCallback( + (e: null | string[]) => { + if (e && e.length > 0) { + setAlbumArtist(e); + } else { + setAlbumArtist(null); + } + }, + [setAlbumArtist], + ); + return ( {yesNoUndefinedFilters.map((filter) => ( @@ -180,7 +202,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil data={genreList} defaultValue={query.genreIds || []} label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} - onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} + onChange={handleGenreChange} searchable /> )} @@ -191,7 +213,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil disabled={disableArtistFilter} label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })} limit={300} - onChange={(e) => (e && e.length > 0 ? setAlbumArtist(e) : setAlbumArtist(null))} + onChange={handleAlbumArtistChange} rightSection={albumArtistListQuery.isFetching ? : undefined} searchable /> @@ -224,6 +246,17 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI return Array.isArray(value) ? value : [value]; }, [value]); + const handleChange = useCallback( + (e: null | string[]) => { + if (e && e.length > 0) { + onChange(e); + } else { + onChange(null); + } + }, + [onChange], + ); + return ( (e && e.length > 0 ? onChange(e) : onChange(null))} + onChange={handleChange} searchable /> ); diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index ca2dd3fc9..37e9060ae 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -1,5 +1,5 @@ import { useSuspenseQuery } from '@tanstack/react-query'; -import { ChangeEvent, useMemo, useState } from 'react'; +import { ChangeEvent, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; @@ -63,8 +63,8 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte })); }, [items]); - const handleAlbumArtistFilter = useMemo( - () => (e: null | string[]) => { + const handleAlbumArtistFilter = useCallback( + (e: null | string[]) => { setAlbumArtist(e ?? null); }, [setAlbumArtist], @@ -80,8 +80,8 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte })); }, [genreListQuery.data]); - const handleGenresFilter = useMemo( - () => (e: null | string) => { + const handleGenresFilter = useCallback( + (e: null | string) => { setGenreId(e ? [e] : null); }, [setGenreId], @@ -178,7 +178,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte defaultValue={query.genreIds?.[0] ?? undefined} disabled={Boolean(query.minYear || query.maxYear)} label={t('entity.genre', { count: 1, postProcess: 'titleCase' })} - onChange={(e) => handleGenresFilter(e)} + onChange={handleGenresFilter} searchable /> )} diff --git a/src/renderer/features/lyrics/lyric-line.tsx b/src/renderer/features/lyrics/lyric-line.tsx index 290d36931..75ccd11d6 100644 --- a/src/renderer/features/lyrics/lyric-line.tsx +++ b/src/renderer/features/lyrics/lyric-line.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { ComponentPropsWithoutRef } from 'react'; +import { ComponentPropsWithoutRef, memo, useMemo } from 'react'; import styles from './lyric-line.module.css'; @@ -12,23 +12,28 @@ interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { text: string; } -export const LyricLine = ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => { - const lines = text.split('_BREAK_'); +export const LyricLine = memo( + ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => { + const lines = useMemo(() => text.split('_BREAK_'), [text]); - return ( - ({ fontSize, textAlign: alignment, - }} - {...props} - > - - {lines.map((line, index) => ( - {line} - ))} - - - ); -}; + }), + [fontSize, alignment], + ); + + return ( + + + {lines.map((line, index) => ( + {line} + ))} + + + ); + }, +); + +LyricLine.displayName = 'LyricLine'; diff --git a/src/renderer/features/shared/components/search-input.tsx b/src/renderer/features/shared/components/search-input.tsx index a193a1986..ac5836af6 100644 --- a/src/renderer/features/shared/components/search-input.tsx +++ b/src/renderer/features/shared/components/search-input.tsx @@ -1,4 +1,12 @@ -import { ChangeEvent, CSSProperties, KeyboardEvent, useEffect, useRef, useState } from 'react'; +import { + ChangeEvent, + CSSProperties, + KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { shallow } from 'zustand/shallow'; import { useSettingsStore } from '/@/renderer/store'; @@ -90,29 +98,38 @@ export const SearchInput = ({ const shouldShowInput = isInputMode || hasValue; const shouldExpand = isInputMode || hasValue; - const containerStyle: CSSProperties = { - display: 'inline-flex', - overflow: 'hidden', - position: 'relative', - transition: 'width 0.3s ease-in-out', - width: shouldExpand ? '200px' : '36px', - }; + const containerStyle: CSSProperties = useMemo( + () => ({ + display: 'inline-flex', + overflow: 'hidden', + position: 'relative', + transition: 'width 0.3s ease-in-out', + width: shouldExpand ? '200px' : '36px', + }), + [shouldExpand], + ); - const buttonStyle: CSSProperties = { - left: 0, - opacity: shouldShowInput ? 0 : 1, - pointerEvents: shouldShowInput ? 'none' : 'auto', - position: 'absolute', - top: 0, - transition: 'opacity 0.2s ease-in-out', - zIndex: 10, - }; + const buttonStyle: CSSProperties = useMemo( + () => ({ + left: 0, + opacity: shouldShowInput ? 0 : 1, + pointerEvents: shouldShowInput ? 'none' : 'auto', + position: 'absolute', + top: 0, + transition: 'opacity 0.2s ease-in-out', + zIndex: 10, + }), + [shouldShowInput], + ); - const inputStyle: CSSProperties = { - opacity: shouldShowInput ? 1 : 0, - transition: 'opacity 0.2s ease-in-out', - width: '100%', - }; + const inputStyle: CSSProperties = useMemo( + () => ({ + opacity: shouldShowInput ? 1 : 0, + transition: 'opacity 0.2s ease-in-out', + width: '100%', + }), + [shouldShowInput], + ); return ( diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 2bff1735c..7e4400b11 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; @@ -105,8 +105,8 @@ export const JellyfinSongFilters = () => { const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300); - const handleGenresFilter = useMemo( - () => (e: string[] | undefined) => { + const handleGenresFilter = useCallback( + (e: null | string[]) => { setCustom((prev) => { const current = prev ?? {}; @@ -129,9 +129,9 @@ export const JellyfinSongFilters = () => { [setCustom], ); - const handleTagFilter = useMemo( - () => (e: string[] | undefined) => { - setCustom({ Tags: e?.join('|') ?? null }); + const handleTagFilter = useCallback( + (e: null | string[]) => { + setCustom({ Tags: e && e.length > 0 ? e.join('|') : null }); }, [setCustom], ); @@ -173,7 +173,7 @@ export const JellyfinSongFilters = () => { data={genreList} defaultValue={selectedGenres} label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })} - onChange={(e) => handleGenresFilter(e)} + onChange={handleGenresFilter} searchable /> )} @@ -183,7 +183,7 @@ export const JellyfinSongFilters = () => { data={tagsQuery.data.boolTags} defaultValue={selectedTags} label={t('common.tags', { postProcess: 'sentenceCase' })} - onChange={(e) => handleTagFilter(e)} + onChange={handleTagFilter} searchable /> )} diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index 98a5ca2df..9ee9ee676 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -1,5 +1,5 @@ import { useSuspenseQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; @@ -73,6 +73,17 @@ export const NavidromeSongFilters = () => { const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300); + const handleGenreChange = useCallback( + (e: null | string[]) => { + if (e && e.length > 0) { + setGenreId(e); + } else { + setGenreId(null); + } + }, + [setGenreId], + ); + return ( {yesNoUndefinedFilters.map((filter) => ( @@ -99,7 +110,7 @@ export const NavidromeSongFilters = () => { data={genreList} defaultValue={query.genreIds || []} label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} - onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} + onChange={handleGenreChange} searchable /> )} @@ -132,6 +143,17 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI return Array.isArray(value) ? value : [value]; }, [value]); + const handleChange = useCallback( + (e: null | string[]) => { + if (e && e.length > 0) { + onChange(e); + } else { + onChange(null); + } + }, + [onChange], + ); + return ( (e && e.length > 0 ? onChange(e) : onChange(null))} + onChange={handleChange} searchable /> ); diff --git a/src/renderer/features/songs/components/subsonic-song-filters.tsx b/src/renderer/features/songs/components/subsonic-song-filters.tsx index f1c94f1cf..9a8afe7dc 100644 --- a/src/renderer/features/songs/components/subsonic-song-filters.tsx +++ b/src/renderer/features/songs/components/subsonic-song-filters.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useMemo } from 'react'; +import { ChangeEvent, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; @@ -29,8 +29,8 @@ export const SubsonicSongFilters = () => { })); }, [genreListQuery.data]); - const handleGenresFilter = useMemo( - () => (e: null | string) => { + const handleGenresFilter = useCallback( + (e: null | string) => { setGenreId(e ? [e] : null); }, [setGenreId], diff --git a/src/renderer/layouts/responsive-layout.tsx b/src/renderer/layouts/responsive-layout.tsx index 02e8e4e53..f4207a15d 100644 --- a/src/renderer/layouts/responsive-layout.tsx +++ b/src/renderer/layouts/responsive-layout.tsx @@ -32,12 +32,12 @@ const ResponsiveLayoutBase = ({ shell }: ResponsiveLayoutProps) => { export const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => { useAppTracker(); - useGarbageCollection(); return ( <> + ); }; @@ -77,3 +77,8 @@ const LayoutHotkeys = () => { return ; }; + +const GarbageCollection = () => { + useGarbageCollection(); + return null; +}; diff --git a/src/renderer/themes/use-app-theme.ts b/src/renderer/themes/use-app-theme.ts index 481f59828..5159143ab 100644 --- a/src/renderer/themes/use-app-theme.ts +++ b/src/renderer/themes/use-app-theme.ts @@ -249,9 +249,14 @@ export const useAppTheme = (overrideTheme?: AppTheme) => { } }, [colorVars, selectedTheme, themeVars]); + const mantineTheme = useMemo( + () => createMantineTheme(appTheme as AppThemeConfiguration), + [appTheme], + ); + return { mode: appTheme?.mode || 'dark', - theme: createMantineTheme(appTheme as AppThemeConfiguration), + theme: mantineTheme, }; }; diff --git a/src/shared/components/action-icon/action-icon.tsx b/src/shared/components/action-icon/action-icon.tsx index 9622ed8ac..1972b25f3 100644 --- a/src/shared/components/action-icon/action-icon.tsx +++ b/src/shared/components/action-icon/action-icon.tsx @@ -3,7 +3,7 @@ import { ActionIcon as MantineActionIcon, ActionIconProps as MantineActionIconProps, } from '@mantine/core'; -import { forwardRef } from 'react'; +import { forwardRef, useMemo } from 'react'; import styles from './action-icon.module.css'; @@ -41,11 +41,16 @@ const _ActionIcon = forwardRef( if (onClick) onClick(e); }; - const actionIconProps: ActionIconProps = { - classNames: { + const memoizedClassNames = useMemo( + () => ({ root: styles.root, ...classNames, - }, + }), + [classNames], + ); + + const actionIconProps: ActionIconProps = { + classNames: memoizedClassNames, size, variant, ...props, diff --git a/src/shared/components/badge/badge.tsx b/src/shared/components/badge/badge.tsx index 74586be13..0fce456ca 100644 --- a/src/shared/components/badge/badge.tsx +++ b/src/shared/components/badge/badge.tsx @@ -3,6 +3,7 @@ import { Badge as MantineBadge, BadgeProps as MantineBadgeProps, } from '@mantine/core'; +import { useMemo } from 'react'; import styles from './badge.module.css'; @@ -12,17 +13,20 @@ export interface BadgeProps extends ElementProps<'div', keyof MantineBadgeProps>, MantineBadgeProps {} -const _Badge = ({ children, classNames, variant = 'default', ...props }: BadgeProps) => { +const BaseBadge = ({ children, classNames, variant = 'default', ...props }: BadgeProps) => { + const memoizedClassNames = useMemo( + () => ({ + root: styles.root, + ...classNames, + }), + [classNames], + ); + return ( - + {children} ); }; -export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge); +export const Badge = createPolymorphicComponent<'button', BadgeProps>(BaseBadge); diff --git a/src/shared/components/box/box.tsx b/src/shared/components/box/box.tsx index 0ad05a25c..e65f46fbb 100644 --- a/src/shared/components/box/box.tsx +++ b/src/shared/components/box/box.tsx @@ -1,7 +1,10 @@ import { ElementProps, Box as MantineBox, BoxProps as MantineBoxProps } from '@mantine/core'; +import { memo } from 'react'; export interface BoxProps extends ElementProps<'div', keyof MantineBoxProps>, MantineBoxProps {} -export const Box = ({ children, ...props }: BoxProps) => { +export const Box = memo(({ children, ...props }: BoxProps) => { return {children}; -}; +}); + +Box.displayName = 'Box'; diff --git a/src/shared/components/button/button.tsx b/src/shared/components/button/button.tsx index c4c4292d5..49ad3f757 100644 --- a/src/shared/components/button/button.tsx +++ b/src/shared/components/button/button.tsx @@ -2,7 +2,7 @@ import type { ButtonVariant, ButtonProps as MantineButtonProps } from '@mantine/ import { ElementProps, Button as MantineButton } from '@mantine/core'; import clsx from 'clsx'; -import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styles from './button.module.css'; @@ -41,21 +41,26 @@ export const _Button = forwardRef( }: ButtonProps, ref, ) => { + const memoizedClassNames = useMemo( + () => ({ + inner: styles.inner, + label: clsx(styles.label, { + [styles.uppercase]: uppercase, + }), + loader: styles.loader, + root: styles.root, + section: styles.section, + ...classNames, + }), + [classNames, uppercase], + ); + if (tooltip) { return ( ( return ( ) => void; } -export const Center = forwardRef( +const _Center = forwardRef( ({ children, classNames, onClick, style, ...props }, ref) => { + const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]); + const memoizedStyle = useMemo(() => ({ ...style }), [style]); + return ( {children} @@ -20,3 +23,7 @@ export const Center = forwardRef( ); }, ); + +_Center.displayName = 'Center'; + +export const Center = memo(_Center); diff --git a/src/shared/components/divider/divider.tsx b/src/shared/components/divider/divider.tsx index a16af891d..6399c875d 100644 --- a/src/shared/components/divider/divider.tsx +++ b/src/shared/components/divider/divider.tsx @@ -1,19 +1,33 @@ import { Divider as MantineDivider, DividerProps as MantineDividerProps } from '@mantine/core'; -import { forwardRef } from 'react'; +import { forwardRef, memo, useMemo } from 'react'; import styles from './divider.module.css'; export interface DividerProps extends MantineDividerProps {} -export const Divider = forwardRef( +const _Divider = forwardRef( ({ classNames, style, ...props }, ref) => { + const memoizedClassNames = useMemo( + () => ({ + root: styles.root, + ...classNames, + }), + [classNames], + ); + + const memoizedStyle = useMemo(() => ({ ...style }), [style]); + return ( ); }, ); + +_Divider.displayName = 'Divider'; + +export const Divider = memo(_Divider); diff --git a/src/shared/components/flex/flex.tsx b/src/shared/components/flex/flex.tsx index 8e6b78d18..7fe9ec57f 100644 --- a/src/shared/components/flex/flex.tsx +++ b/src/shared/components/flex/flex.tsx @@ -1,17 +1,21 @@ import { Flex as MantineFlex, FlexProps as MantineFlexProps } from '@mantine/core'; -import { forwardRef } from 'react'; +import { forwardRef, memo, useMemo } from 'react'; export interface FlexProps extends MantineFlexProps {} -export const Flex = forwardRef(({ children, ...props }, ref) => { - return ( - - {children} - - ); -}); +const _Flex = forwardRef( + ({ children, classNames, style, ...props }, ref) => { + const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]); + const memoizedStyle = useMemo(() => ({ ...style }), [style]); + + return ( + + {children} + + ); + }, +); + +_Flex.displayName = 'Flex'; + +export const Flex = memo(_Flex); diff --git a/src/shared/components/grid/grid.tsx b/src/shared/components/grid/grid.tsx index 0e63b6f1a..560c7ebe0 100644 --- a/src/shared/components/grid/grid.tsx +++ b/src/shared/components/grid/grid.tsx @@ -1,9 +1,17 @@ import { Grid as MantineGrid, GridProps as MantineGridProps } from '@mantine/core'; +import { memo, useMemo } from 'react'; export interface GridProps extends MantineGridProps {} -export const Grid = ({ classNames, style, ...props }: GridProps) => { - return ; +const BaseGrid = ({ classNames, style, ...props }: GridProps) => { + const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]); + const memoizedStyle = useMemo(() => ({ ...style }), [style]); + + return ; }; -Grid.Col = MantineGrid.Col; +BaseGrid.displayName = 'Grid'; + +export const Grid = memo(BaseGrid); + +(Grid as typeof Grid & { Col: typeof MantineGrid.Col }).Col = MantineGrid.Col; diff --git a/src/shared/components/group/group.tsx b/src/shared/components/group/group.tsx index eb2e73c3b..ba0b9f8e2 100644 --- a/src/shared/components/group/group.tsx +++ b/src/shared/components/group/group.tsx @@ -1,17 +1,26 @@ import { Group as MantineGroup, GroupProps as MantineGroupProps } from '@mantine/core'; -import { forwardRef } from 'react'; +import { forwardRef, memo, useMemo } from 'react'; export interface GroupProps extends MantineGroupProps {} -export const Group = forwardRef(({ children, ...props }, ref) => { - return ( - - {children} - - ); -}); +const _Group = forwardRef( + ({ children, classNames, style, ...props }, ref) => { + const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]); + const memoizedStyle = useMemo(() => ({ ...style }), [style]); + + return ( + + {children} + + ); + }, +); + +_Group.displayName = 'Group'; + +export const Group = memo(_Group); diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index d17cce019..16e316a93 100644 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { motion } from 'motion/react'; -import { type ComponentType, forwardRef } from 'react'; +import { type ComponentType, forwardRef, memo, useMemo } from 'react'; import { IconBaseProps } from 'react-icons'; import { FaLastfmSquare } from 'react-icons/fa'; import { @@ -278,19 +278,23 @@ type IconColor = | 'success' | 'warn'; -export const Icon = forwardRef((props, ref) => { +const _Icon = forwardRef((props, ref) => { const { animate, className, color, fill, icon, size = 'md' } = props; const IconComponent: ComponentType = AppIcon[icon]; - const classNames = clsx(className, { - [styles.fill]: true, - [styles.pulse]: animate === 'pulse', - [styles.spin]: animate === 'spin', - [styles[`color-${color || fill}`]]: color || fill, - [styles[`fill-${fill}`]]: fill, - [styles[`size-${size}`]]: true, - }); + const classNames = useMemo( + () => + clsx(className, { + [styles.fill]: true, + [styles.pulse]: animate === 'pulse', + [styles.spin]: animate === 'spin', + [styles[`color-${color || fill}`]]: color || fill, + [styles[`fill-${fill}`]]: fill, + [styles[`size-${size}`]]: true, + }), + [animate, className, color, fill, size], + ); return ( ((props, ref) => { ); }); +_Icon.displayName = 'Icon'; + +export const Icon = memo(_Icon); + Icon.displayName = 'Icon'; export const MotionIcon: ComponentType = motion.create(Icon); diff --git a/src/shared/components/multi-select/multi-select.tsx b/src/shared/components/multi-select/multi-select.tsx index d182c1ad2..87cd6452c 100644 --- a/src/shared/components/multi-select/multi-select.tsx +++ b/src/shared/components/multi-select/multi-select.tsx @@ -2,7 +2,7 @@ import { MultiSelect as MantineMultiSelect, MultiSelectProps as MantineMultiSelectProps, } from '@mantine/core'; -import { CSSProperties } from 'react'; +import { CSSProperties, useMemo } from 'react'; import styles from './multi-select.module.css'; @@ -11,6 +11,23 @@ export interface MultiSelectProps extends MantineMultiSelectProps { width?: CSSProperties['width']; } +const defaultClassNames = { + dropdown: styles.dropdown, + input: styles.input, + label: styles.label, + option: styles.option, + pill: styles.pill, + pillsList: styles.pillsList, + root: styles.root, +}; + +const defaultClearButtonProps = { + classNames: { + root: styles.clearButton, + }, + variant: 'transparent' as const, +}; + export const MultiSelect = ({ classNames, maxWidth, @@ -18,25 +35,21 @@ export const MultiSelect = ({ width, ...props }: MultiSelectProps) => { + const mergedClassNames = useMemo( + () => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames), + [classNames], + ); + + const style = useMemo( + () => (maxWidth || width ? { maxWidth, width } : undefined), + [maxWidth, width], + ); + return ( { +const defaultClassNames = { root: styles.root }; + +export const Option = memo(({ children, classNames, ...props }: OptionProps) => { + const mergedClassNames = useMemo( + () => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames), + [classNames], + ); + return ( - + {children} ); -}; +}); + +Option.displayName = 'Option'; interface LabelProps { children: ReactNode; @@ -34,5 +43,5 @@ const Control = ({ children }: ControlProps) => { return {children}; }; -Option.Label = Label; -Option.Control = Control; +(Option as typeof Option & { Label: typeof Label }).Label = Label; +(Option as typeof Option & { Control: typeof Control }).Control = Control; diff --git a/src/shared/components/paper/paper.tsx b/src/shared/components/paper/paper.tsx index f5a6b4e7c..f46792c8f 100644 --- a/src/shared/components/paper/paper.tsx +++ b/src/shared/components/paper/paper.tsx @@ -1,7 +1,7 @@ import type { PaperProps as MantinePaperProps } from '@mantine/core'; import { Paper as MantinePaper } from '@mantine/core'; -import { ReactNode } from 'react'; +import { memo, ReactNode, useMemo } from 'react'; import styles from './paper.module.css'; @@ -9,19 +9,24 @@ export interface PaperProps extends MantinePaperProps { children?: ReactNode; } -export const Paper = ({ children, classNames, style, ...props }: PaperProps) => { +const BasePaper = ({ children, classNames, style, ...props }: PaperProps) => { + const memoizedClassNames = useMemo( + () => ({ + root: styles.root, + ...classNames, + }), + [classNames], + ); + + const memoizedStyle = useMemo(() => ({ ...style }), [style]); + return ( - + {children} ); }; + +BasePaper.displayName = 'Paper'; + +export const Paper = memo(BasePaper); diff --git a/src/shared/components/spinner/spinner.tsx b/src/shared/components/spinner/spinner.tsx index 0c013e972..be88e63c6 100644 --- a/src/shared/components/spinner/spinner.tsx +++ b/src/shared/components/spinner/spinner.tsx @@ -1,4 +1,5 @@ import { Center } from '@mantine/core'; +import { memo } from 'react'; import { IconBaseProps } from 'react-icons'; import { CgSpinnerTwo } from 'react-icons/cg'; @@ -12,7 +13,7 @@ interface SpinnerProps extends IconBaseProps { export const SpinnerIcon = CgSpinnerTwo; -export const Spinner = ({ ...props }: SpinnerProps) => { +const _Spinner = ({ ...props }: SpinnerProps) => { if (props.container) { return (
@@ -23,3 +24,7 @@ export const Spinner = ({ ...props }: SpinnerProps) => { return ; }; + +_Spinner.displayName = 'Spinner'; + +export const Spinner = memo(_Spinner); diff --git a/src/shared/components/stack/stack.tsx b/src/shared/components/stack/stack.tsx index 7ed3ea5b9..4d1d9b739 100644 --- a/src/shared/components/stack/stack.tsx +++ b/src/shared/components/stack/stack.tsx @@ -1,17 +1,26 @@ import { Stack as MantineStack, StackProps as MantineStackProps } from '@mantine/core'; -import { forwardRef } from 'react'; +import { forwardRef, memo, useMemo } from 'react'; export interface StackProps extends MantineStackProps {} -export const Stack = forwardRef(({ children, ...props }, ref) => { - return ( - - {children} - - ); -}); +const _Stack = forwardRef( + ({ children, classNames, style, ...props }, ref) => { + const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]); + const memoizedStyle = useMemo(() => ({ ...style }), [style]); + + return ( + + {children} + + ); + }, +); + +_Stack.displayName = 'Stack'; + +export const Stack = memo(_Stack); diff --git a/src/shared/components/text/text.tsx b/src/shared/components/text/text.tsx index 8508e7073..1128ecbad 100644 --- a/src/shared/components/text/text.tsx +++ b/src/shared/components/text/text.tsx @@ -1,6 +1,6 @@ import { Text as MantineText, TextProps as MantineTextProps } from '@mantine/core'; import clsx from 'clsx'; -import { ComponentPropsWithoutRef, ReactNode } from 'react'; +import { ComponentPropsWithoutRef, ReactNode, useMemo } from 'react'; import styles from './text.module.css'; @@ -21,7 +21,7 @@ type Font = 'Epilogue' | 'Gotham' | 'Inter' | 'Poppins'; type MantineTextDivProps = ComponentPropsWithoutRef<'div'> & MantineTextProps; -export const _Text = ({ +export const BaseText = ({ children, font, isLink, @@ -31,28 +31,31 @@ export const _Text = ({ weight, ...rest }: TextProps) => { + const classNames = useMemo( + () => ({ + root: clsx(styles.root, { + [styles.link]: isLink, + [styles.muted]: isMuted, + [styles.noSelect]: isNoSelect, + [styles.overflowHidden]: overflow === 'hidden', + }), + }), + [isLink, isMuted, isNoSelect, overflow], + ); + + const style = useMemo( + () => + ({ + '--font-family': font, + }) as React.CSSProperties, + [font], + ); + return ( - + {children} ); }; -export const Text = createPolymorphicComponent<'div', TextProps>(_Text); +export const Text = createPolymorphicComponent<'div', TextProps>(BaseText); diff --git a/src/shared/components/tooltip/tooltip.tsx b/src/shared/components/tooltip/tooltip.tsx index d40b19f47..871fbcc2b 100644 --- a/src/shared/components/tooltip/tooltip.tsx +++ b/src/shared/components/tooltip/tooltip.tsx @@ -1,38 +1,61 @@ import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core'; import clsx from 'clsx'; +import { memo, useMemo } from 'react'; import styles from './tooltip.module.css'; export interface TooltipProps extends MantineTooltipProps {} -export const Tooltip = ({ - children, - classNames, - openDelay = 500, - transitionProps = { - duration: 250, - transition: 'fade', - }, - withinPortal = true, - ...props -}: TooltipProps) => { - return ( - { + const memoizedClassNames = useMemo( + () => ({ ...classNames, tooltip: clsx(styles.tooltip, classNames?.['tooltip']), - }} - multiline - openDelay={openDelay} - transitionProps={transitionProps} - withArrow - withinPortal={withinPortal} - {...props} - > - {children} - - ); + }), + [classNames], + ); + + const memoizedTransitionProps = useMemo( + () => transitionProps ?? DEFAULT_TRANSITION_PROPS, + [transitionProps], + ); + + return ( + + {children} + + ); + }, +); + +TooltipComponent.displayName = 'Tooltip'; + +export const Tooltip = TooltipComponent as typeof TooltipComponent & { + Group: typeof MantineTooltip.Group; }; Tooltip.Group = MantineTooltip.Group; + +Tooltip.Group = MantineTooltip.Group;