optimize various base components

This commit is contained in:
jeffvli
2026-01-02 12:46:35 -08:00
parent a66c67e86d
commit d06d1674d1
31 changed files with 669 additions and 393 deletions
+10 -5
View File
@@ -72,16 +72,21 @@ export const App = () => {
} }
}, [language]); }, [language]);
const notificationStyles = useMemo(
() => ({
root: {
marginBottom: 90,
},
}),
[],
);
return ( return (
<MantineProvider forceColorScheme={mode} theme={theme}> <MantineProvider forceColorScheme={mode} theme={theme}>
<Notifications <Notifications
containerWidth="300px" containerWidth="300px"
position="bottom-center" position="bottom-center"
styles={{ styles={notificationStyles}
root: {
marginBottom: 90,
},
}}
zIndex={50000} zIndex={50000}
/> />
<WebAudioContext.Provider value={webAudioProvider}> <WebAudioContext.Provider value={webAudioProvider}>
+171 -115
View File
@@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence } from 'motion/react'; 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 { generatePath, Link } from 'react-router';
import styles from './item-card.module.css'; import styles from './item-card.module.css';
@@ -84,7 +84,7 @@ export const ItemCard = ({
switch (type) { switch (type) {
case 'compact': case 'compact':
return ( return (
<CompactItemCard <MemoizedCompactItemCard
controls={controls} controls={controls}
data={data} data={data}
enableDrag={enableDrag} enableDrag={enableDrag}
@@ -101,7 +101,7 @@ export const ItemCard = ({
); );
case 'poster': case 'poster':
return ( return (
<PosterItemCard <MemoizedPosterItemCard
controls={controls} controls={controls}
data={data} data={data}
enableDrag={enableDrag} enableDrag={enableDrag}
@@ -119,7 +119,7 @@ export const ItemCard = ({
case 'default': case 'default':
default: default:
return ( return (
<DefaultItemCard <MemoizedDefaultItemCard
controls={controls} controls={controls}
data={data} data={data}
enableDrag={enableDrag} enableDrag={enableDrag}
@@ -167,46 +167,64 @@ const CompactItemCard = ({
: undefined; : undefined;
const isSelected = useItemSelectionState(internalState, itemRowId || undefined); const isSelected = useItemSelectionState(internalState, itemRowId || undefined);
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({ const getId = useCallback(() => {
drag: { if (!data) {
getId: () => { return [];
if (!data) { }
return [];
}
const draggedItems = getDraggedItems(data, internalState); const draggedItems = getDraggedItems(data, internalState);
return draggedItems.map((item) => item.id); return draggedItems.map((item) => item.id);
}, }, [data, internalState]);
getItem: () => {
if (!data) {
return [];
}
const draggedItems = getDraggedItems(data, internalState); const getItem = useCallback(() => {
return draggedItems; 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, itemType,
onDragStart: () => { onDragStart,
if (!data) { onDrop,
return; operation: dragOperation,
}
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],
target: DragTarget.ALBUM, target: DragTarget.ALBUM,
}, }),
[getId, getItem, itemType, onDragStart, onDrop, dragOperation],
);
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({
drag,
isEnabled: !!enableDrag && !!data, isEnabled: !!enableDrag && !!data,
}); });
@@ -649,46 +667,64 @@ const PosterItemCard = ({
: undefined; : undefined;
const isSelected = useItemSelectionState(internalState, itemRowId || undefined); const isSelected = useItemSelectionState(internalState, itemRowId || undefined);
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({ const getId = useCallback(() => {
drag: { if (!data) {
getId: () => { return [];
if (!data) { }
return [];
}
const draggedItems = getDraggedItems(data, internalState); const draggedItems = getDraggedItems(data, internalState);
return draggedItems.map((item) => item.id); return draggedItems.map((item) => item.id);
}, }, [data, internalState]);
getItem: () => {
if (!data) {
return [];
}
const draggedItems = getDraggedItems(data, internalState); const getItem = useCallback(() => {
return draggedItems; 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, itemType,
onDragStart: () => { onDragStart,
if (!data) { onDrop,
return; operation: dragOperation,
}
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],
target: DragTarget.ALBUM, target: DragTarget.ALBUM,
}, }),
[getId, getItem, itemType, onDragStart, onDrop, dragOperation],
);
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({
drag,
isEnabled: !!enableDrag && !!data, 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[] => { export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[] => {
return [ return [
{ {
@@ -1160,56 +1205,67 @@ const getItemNavigationPath = (
return getTitlePath(effectiveItemType, data.id); return getTitlePath(effectiveItemType, data.id);
}; };
const ItemCardRow = ({ const ItemCardRow = memo(
data, ({
index, data,
row, index,
type, row,
}: { type,
data: Album | AlbumArtist | Artist | Playlist | Song | undefined; }: {
index: number; data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
row: DataRow; index: number;
type?: 'compact' | 'default' | 'poster'; row: DataRow;
}) => { type?: 'compact' | 'default' | 'poster';
const alignmentClass = }) => {
row.align === 'center' const alignmentClass =
? styles['align-center'] row.align === 'center'
: row.align === 'end' ? styles['align-center']
? styles['align-end'] : row.align === 'end'
: styles['align-start']; ? styles['align-end']
: styles['align-start'];
// All rows except the first one (index 0) should be muted // All rows except the first one (index 0) should be muted
const isMuted = index > 0 || row.isMuted; const isMuted = index > 0 || row.isMuted;
const formattedContent = useMemo(() => {
if (!data) {
return null;
}
return row.format(data);
}, [data, row]);
if (!data) {
return (
<div
className={clsx(styles.row, alignmentClass, {
[styles.compact]: type === 'compact',
[styles.default]: type === 'default',
[styles.muted]: isMuted,
[styles.poster]: type === 'poster',
})}
>
&nbsp;
</div>
);
}
if (!data) {
return ( return (
<div <Text
className={clsx(styles.row, alignmentClass, { className={clsx(styles.row, alignmentClass, {
[styles.bold]: index === 0,
[styles.compact]: type === 'compact', [styles.compact]: type === 'compact',
[styles.default]: type === 'default', [styles.default]: type === 'default',
[styles.muted]: isMuted, [styles.muted]: isMuted,
[styles.poster]: type === 'poster', [styles.poster]: type === 'poster',
})} })}
size={index > 0 ? 'sm' : 'md'}
> >
&nbsp; {formattedContent}
</div> </Text>
); );
} },
);
return ( ItemCardRow.displayName = 'ItemCardRow';
<Text
className={clsx(styles.row, alignmentClass, {
[styles.bold]: index === 0,
[styles.compact]: type === 'compact',
[styles.default]: type === 'default',
[styles.muted]: isMuted,
[styles.poster]: type === 'poster',
})}
size={index > 0 ? 'sm' : 'md'}
>
{row.format(data)}
</Text>
);
};
export const MemoizedItemCard = memo(ItemCard); export const MemoizedItemCard = memo(ItemCard);
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option'; import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
@@ -107,15 +108,17 @@ export const QueryBuilder = ({
onChangeType({ groupIndex, level, value }); 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 ( return (
<Box <Box p="md" style={boxStyle}>
p="md"
style={{
border: '1px solid var(--theme-colors-border)',
borderRadius: 'var(--theme-radius-md)',
marginLeft: level > 0 ? '20px' : '0px',
}}
>
<Stack gap="sm"> <Stack gap="sm">
<Group gap="sm" justify="space-between" wrap="nowrap"> <Group gap="sm" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
@@ -67,16 +67,13 @@ export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: Mul
return [data, []]; return [data, []];
}, [data, defaultValue]); }, [data, defaultValue]);
return ( const error = useMemo(
<MultiSelect () =>
data={fullData} missing.length
defaultValue={defaultValue} ? t('error.badValue', { postProcess: 'sentenceCase', value: missing })
error={ : undefined,
missing.length [missing, t],
? t('error.badValue', { postProcess: 'sentenceCase', value: missing })
: undefined
}
{...props}
/>
); );
return <MultiSelect data={fullData} defaultValue={defaultValue} error={error} {...props} />;
}; };
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -147,8 +147,8 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
[setMaxYear], [setMaxYear],
); );
const handleGenresFilter = useMemo( const handleGenresFilter = useCallback(
() => (e: string[] | undefined) => { (e: null | string[]) => {
setGenreId(e && e.length > 0 ? e : null); setGenreId(e && e.length > 0 ? e : null);
}, },
[setGenreId], [setGenreId],
@@ -178,13 +178,16 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
})); }));
}, [albumArtistListQuery.data?.items]); }, [albumArtistListQuery.data?.items]);
const handleAlbumArtistFilter = (e: null | string[]) => { const handleAlbumArtistFilter = useCallback(
setAlbumArtist(e ?? null); (e: null | string[]) => {
}; setAlbumArtist(e ?? null);
},
[setAlbumArtist],
);
const handleTagFilter = useMemo( const handleTagFilter = useCallback(
() => (e: string[] | undefined) => { (e: null | string[]) => {
setCustom({ Tags: e?.join('|') ?? null }); setCustom({ Tags: e && e.length > 0 ? e.join('|') : null });
}, },
[setCustom], [setCustom],
); );
@@ -1,5 +1,5 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { ChangeEvent, useMemo } from 'react'; import { ChangeEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -148,6 +148,28 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300); 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 ( return (
<Stack px="md" py="md"> <Stack px="md" py="md">
{yesNoUndefinedFilters.map((filter) => ( {yesNoUndefinedFilters.map((filter) => (
@@ -180,7 +202,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
data={genreList} data={genreList}
defaultValue={query.genreIds || []} defaultValue={query.genreIds || []}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} onChange={handleGenreChange}
searchable searchable
/> />
)} )}
@@ -191,7 +213,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
disabled={disableArtistFilter} disabled={disableArtistFilter}
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })} label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
limit={300} limit={300}
onChange={(e) => (e && e.length > 0 ? setAlbumArtist(e) : setAlbumArtist(null))} onChange={handleAlbumArtistChange}
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined} rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchable searchable
/> />
@@ -224,6 +246,17 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI
return Array.isArray(value) ? value : [value]; return Array.isArray(value) ? value : [value];
}, [value]); }, [value]);
const handleChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
onChange(e);
} else {
onChange(null);
}
},
[onChange],
);
return ( return (
<MultiSelectWithInvalidData <MultiSelectWithInvalidData
clearable clearable
@@ -232,7 +265,7 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI
key={tagValue} key={tagValue}
label={label} label={label}
limit={100} limit={100}
onChange={(e) => (e && e.length > 0 ? onChange(e) : onChange(null))} onChange={handleChange}
searchable searchable
/> />
); );
@@ -1,5 +1,5 @@
import { useSuspenseQuery } from '@tanstack/react-query'; 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 { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -63,8 +63,8 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
})); }));
}, [items]); }, [items]);
const handleAlbumArtistFilter = useMemo( const handleAlbumArtistFilter = useCallback(
() => (e: null | string[]) => { (e: null | string[]) => {
setAlbumArtist(e ?? null); setAlbumArtist(e ?? null);
}, },
[setAlbumArtist], [setAlbumArtist],
@@ -80,8 +80,8 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
})); }));
}, [genreListQuery.data]); }, [genreListQuery.data]);
const handleGenresFilter = useMemo( const handleGenresFilter = useCallback(
() => (e: null | string) => { (e: null | string) => {
setGenreId(e ? [e] : null); setGenreId(e ? [e] : null);
}, },
[setGenreId], [setGenreId],
@@ -178,7 +178,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
defaultValue={query.genreIds?.[0] ?? undefined} defaultValue={query.genreIds?.[0] ?? undefined}
disabled={Boolean(query.minYear || query.maxYear)} disabled={Boolean(query.minYear || query.maxYear)}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })} label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={(e) => handleGenresFilter(e)} onChange={handleGenresFilter}
searchable searchable
/> />
)} )}
+23 -18
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { ComponentPropsWithoutRef } from 'react'; import { ComponentPropsWithoutRef, memo, useMemo } from 'react';
import styles from './lyric-line.module.css'; import styles from './lyric-line.module.css';
@@ -12,23 +12,28 @@ interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
text: string; text: string;
} }
export const LyricLine = ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => { export const LyricLine = memo(
const lines = text.split('_BREAK_'); ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => {
const lines = useMemo(() => text.split('_BREAK_'), [text]);
return ( const style = useMemo(
<Box () => ({
className={clsx(styles.lyricLine, className)}
style={{
fontSize, fontSize,
textAlign: alignment, textAlign: alignment,
}} }),
{...props} [fontSize, alignment],
> );
<Stack gap={0}>
{lines.map((line, index) => ( return (
<span key={index}>{line}</span> <Box className={clsx(styles.lyricLine, className)} style={style} {...props}>
))} <Stack gap={0}>
</Stack> {lines.map((line, index) => (
</Box> <span key={index}>{line}</span>
); ))}
}; </Stack>
</Box>
);
},
);
LyricLine.displayName = 'LyricLine';
@@ -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 { shallow } from 'zustand/shallow';
import { useSettingsStore } from '/@/renderer/store'; import { useSettingsStore } from '/@/renderer/store';
@@ -90,29 +98,38 @@ export const SearchInput = ({
const shouldShowInput = isInputMode || hasValue; const shouldShowInput = isInputMode || hasValue;
const shouldExpand = isInputMode || hasValue; const shouldExpand = isInputMode || hasValue;
const containerStyle: CSSProperties = { const containerStyle: CSSProperties = useMemo(
display: 'inline-flex', () => ({
overflow: 'hidden', display: 'inline-flex',
position: 'relative', overflow: 'hidden',
transition: 'width 0.3s ease-in-out', position: 'relative',
width: shouldExpand ? '200px' : '36px', transition: 'width 0.3s ease-in-out',
}; width: shouldExpand ? '200px' : '36px',
}),
[shouldExpand],
);
const buttonStyle: CSSProperties = { const buttonStyle: CSSProperties = useMemo(
left: 0, () => ({
opacity: shouldShowInput ? 0 : 1, left: 0,
pointerEvents: shouldShowInput ? 'none' : 'auto', opacity: shouldShowInput ? 0 : 1,
position: 'absolute', pointerEvents: shouldShowInput ? 'none' : 'auto',
top: 0, position: 'absolute',
transition: 'opacity 0.2s ease-in-out', top: 0,
zIndex: 10, transition: 'opacity 0.2s ease-in-out',
}; zIndex: 10,
}),
[shouldShowInput],
);
const inputStyle: CSSProperties = { const inputStyle: CSSProperties = useMemo(
opacity: shouldShowInput ? 1 : 0, () => ({
transition: 'opacity 0.2s ease-in-out', opacity: shouldShowInput ? 1 : 0,
width: '100%', transition: 'opacity 0.2s ease-in-out',
}; width: '100%',
}),
[shouldShowInput],
);
return ( return (
<Box style={containerStyle}> <Box style={containerStyle}>
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -105,8 +105,8 @@ export const JellyfinSongFilters = () => {
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300); const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);
const handleGenresFilter = useMemo( const handleGenresFilter = useCallback(
() => (e: string[] | undefined) => { (e: null | string[]) => {
setCustom((prev) => { setCustom((prev) => {
const current = prev ?? {}; const current = prev ?? {};
@@ -129,9 +129,9 @@ export const JellyfinSongFilters = () => {
[setCustom], [setCustom],
); );
const handleTagFilter = useMemo( const handleTagFilter = useCallback(
() => (e: string[] | undefined) => { (e: null | string[]) => {
setCustom({ Tags: e?.join('|') ?? null }); setCustom({ Tags: e && e.length > 0 ? e.join('|') : null });
}, },
[setCustom], [setCustom],
); );
@@ -173,7 +173,7 @@ export const JellyfinSongFilters = () => {
data={genreList} data={genreList}
defaultValue={selectedGenres} defaultValue={selectedGenres}
label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })} label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
onChange={(e) => handleGenresFilter(e)} onChange={handleGenresFilter}
searchable searchable
/> />
)} )}
@@ -183,7 +183,7 @@ export const JellyfinSongFilters = () => {
data={tagsQuery.data.boolTags} data={tagsQuery.data.boolTags}
defaultValue={selectedTags} defaultValue={selectedTags}
label={t('common.tags', { postProcess: 'sentenceCase' })} label={t('common.tags', { postProcess: 'sentenceCase' })}
onChange={(e) => handleTagFilter(e)} onChange={handleTagFilter}
searchable searchable
/> />
)} )}
@@ -1,5 +1,5 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -73,6 +73,17 @@ export const NavidromeSongFilters = () => {
const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300); const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300);
const handleGenreChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setGenreId(e);
} else {
setGenreId(null);
}
},
[setGenreId],
);
return ( return (
<Stack px="md" py="md"> <Stack px="md" py="md">
{yesNoUndefinedFilters.map((filter) => ( {yesNoUndefinedFilters.map((filter) => (
@@ -99,7 +110,7 @@ export const NavidromeSongFilters = () => {
data={genreList} data={genreList}
defaultValue={query.genreIds || []} defaultValue={query.genreIds || []}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} onChange={handleGenreChange}
searchable searchable
/> />
)} )}
@@ -132,6 +143,17 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI
return Array.isArray(value) ? value : [value]; return Array.isArray(value) ? value : [value];
}, [value]); }, [value]);
const handleChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
onChange(e);
} else {
onChange(null);
}
},
[onChange],
);
return ( return (
<MultiSelectWithInvalidData <MultiSelectWithInvalidData
clearable clearable
@@ -140,7 +162,7 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI
key={tagValue} key={tagValue}
label={label} label={label}
limit={100} limit={100}
onChange={(e) => (e && e.length > 0 ? onChange(e) : onChange(null))} onChange={handleChange}
searchable searchable
/> />
); );
@@ -1,4 +1,4 @@
import { ChangeEvent, useMemo } from 'react'; import { ChangeEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -29,8 +29,8 @@ export const SubsonicSongFilters = () => {
})); }));
}, [genreListQuery.data]); }, [genreListQuery.data]);
const handleGenresFilter = useMemo( const handleGenresFilter = useCallback(
() => (e: null | string) => { (e: null | string) => {
setGenreId(e ? [e] : null); setGenreId(e ? [e] : null);
}, },
[setGenreId], [setGenreId],
+6 -1
View File
@@ -32,12 +32,12 @@ const ResponsiveLayoutBase = ({ shell }: ResponsiveLayoutProps) => {
export const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => { export const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => {
useAppTracker(); useAppTracker();
useGarbageCollection();
return ( return (
<> <>
<ResponsiveLayoutBase shell={shell} /> <ResponsiveLayoutBase shell={shell} />
<LayoutHotkeys /> <LayoutHotkeys />
<GarbageCollection />
</> </>
); );
}; };
@@ -77,3 +77,8 @@ const LayoutHotkeys = () => {
return <CommandPalette modalProps={{ handlers, opened }} />; return <CommandPalette modalProps={{ handlers, opened }} />;
}; };
const GarbageCollection = () => {
useGarbageCollection();
return null;
};
+6 -1
View File
@@ -249,9 +249,14 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
} }
}, [colorVars, selectedTheme, themeVars]); }, [colorVars, selectedTheme, themeVars]);
const mantineTheme = useMemo(
() => createMantineTheme(appTheme as AppThemeConfiguration),
[appTheme],
);
return { return {
mode: appTheme?.mode || 'dark', mode: appTheme?.mode || 'dark',
theme: createMantineTheme(appTheme as AppThemeConfiguration), theme: mantineTheme,
}; };
}; };
@@ -3,7 +3,7 @@ import {
ActionIcon as MantineActionIcon, ActionIcon as MantineActionIcon,
ActionIconProps as MantineActionIconProps, ActionIconProps as MantineActionIconProps,
} from '@mantine/core'; } from '@mantine/core';
import { forwardRef } from 'react'; import { forwardRef, useMemo } from 'react';
import styles from './action-icon.module.css'; import styles from './action-icon.module.css';
@@ -41,11 +41,16 @@ const _ActionIcon = forwardRef<HTMLButtonElement, ActionIconProps>(
if (onClick) onClick(e); if (onClick) onClick(e);
}; };
const actionIconProps: ActionIconProps = { const memoizedClassNames = useMemo(
classNames: { () => ({
root: styles.root, root: styles.root,
...classNames, ...classNames,
}, }),
[classNames],
);
const actionIconProps: ActionIconProps = {
classNames: memoizedClassNames,
size, size,
variant, variant,
...props, ...props,
+12 -8
View File
@@ -3,6 +3,7 @@ import {
Badge as MantineBadge, Badge as MantineBadge,
BadgeProps as MantineBadgeProps, BadgeProps as MantineBadgeProps,
} from '@mantine/core'; } from '@mantine/core';
import { useMemo } from 'react';
import styles from './badge.module.css'; import styles from './badge.module.css';
@@ -12,17 +13,20 @@ export interface BadgeProps
extends ElementProps<'div', keyof MantineBadgeProps>, extends ElementProps<'div', keyof MantineBadgeProps>,
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 ( return (
<MantineBadge <MantineBadge classNames={memoizedClassNames} radius="md" variant={variant} {...props}>
classNames={{ root: styles.root, ...classNames }}
radius="md"
variant={variant}
{...props}
>
{children} {children}
</MantineBadge> </MantineBadge>
); );
}; };
export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge); export const Badge = createPolymorphicComponent<'button', BadgeProps>(BaseBadge);
+5 -2
View File
@@ -1,7 +1,10 @@
import { ElementProps, Box as MantineBox, BoxProps as MantineBoxProps } from '@mantine/core'; 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 interface BoxProps extends ElementProps<'div', keyof MantineBoxProps>, MantineBoxProps {}
export const Box = ({ children, ...props }: BoxProps) => { export const Box = memo(({ children, ...props }: BoxProps) => {
return <MantineBox {...props}>{children}</MantineBox>; return <MantineBox {...props}>{children}</MantineBox>;
}; });
Box.displayName = 'Box';
+17 -21
View File
@@ -2,7 +2,7 @@ import type { ButtonVariant, ButtonProps as MantineButtonProps } from '@mantine/
import { ElementProps, Button as MantineButton } from '@mantine/core'; import { ElementProps, Button as MantineButton } from '@mantine/core';
import clsx from 'clsx'; 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'; import styles from './button.module.css';
@@ -41,21 +41,26 @@ export const _Button = forwardRef<HTMLButtonElement, ButtonProps>(
}: ButtonProps, }: ButtonProps,
ref, 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) { if (tooltip) {
return ( return (
<Tooltip withinPortal {...tooltip}> <Tooltip withinPortal {...tooltip}>
<MantineButton <MantineButton
autoContrast autoContrast
classNames={{ classNames={memoizedClassNames}
inner: styles.inner,
label: clsx(styles.label, {
[styles.uppercase]: uppercase,
}),
loader: styles.loader,
root: styles.root,
section: styles.section,
...classNames,
}}
loading={loading} loading={loading}
ref={ref} ref={ref}
size={size} size={size}
@@ -71,16 +76,7 @@ export const _Button = forwardRef<HTMLButtonElement, ButtonProps>(
return ( return (
<MantineButton <MantineButton
classNames={{ classNames={memoizedClassNames}
inner: styles.inner,
label: clsx(styles.label, {
[styles.uppercase]: uppercase,
}),
loader: styles.loader,
root: styles.root,
section: styles.section,
...classNames,
}}
loading={loading} loading={loading}
ref={ref} ref={ref}
size={size} size={size}
+11 -4
View File
@@ -1,18 +1,21 @@
import { Center as MantineCenter, CenterProps as MantineCenterProps } from '@mantine/core'; import { Center as MantineCenter, CenterProps as MantineCenterProps } from '@mantine/core';
import { forwardRef, MouseEvent } from 'react'; import { forwardRef, memo, MouseEvent, useMemo } from 'react';
export interface CenterProps extends MantineCenterProps { export interface CenterProps extends MantineCenterProps {
onClick?: (e: MouseEvent<HTMLDivElement>) => void; onClick?: (e: MouseEvent<HTMLDivElement>) => void;
} }
export const Center = forwardRef<HTMLDivElement, CenterProps>( const _Center = forwardRef<HTMLDivElement, CenterProps>(
({ children, classNames, onClick, style, ...props }, ref) => { ({ children, classNames, onClick, style, ...props }, ref) => {
const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);
const memoizedStyle = useMemo(() => ({ ...style }), [style]);
return ( return (
<MantineCenter <MantineCenter
classNames={{ ...classNames }} classNames={memoizedClassNames}
onClick={onClick} onClick={onClick}
ref={ref} ref={ref}
style={{ ...style }} style={memoizedStyle}
{...props} {...props}
> >
{children} {children}
@@ -20,3 +23,7 @@ export const Center = forwardRef<HTMLDivElement, CenterProps>(
); );
}, },
); );
_Center.displayName = 'Center';
export const Center = memo(_Center);
+18 -4
View File
@@ -1,19 +1,33 @@
import { Divider as MantineDivider, DividerProps as MantineDividerProps } from '@mantine/core'; 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'; import styles from './divider.module.css';
export interface DividerProps extends MantineDividerProps {} export interface DividerProps extends MantineDividerProps {}
export const Divider = forwardRef<HTMLDivElement, DividerProps>( const _Divider = forwardRef<HTMLDivElement, DividerProps>(
({ classNames, style, ...props }, ref) => { ({ classNames, style, ...props }, ref) => {
const memoizedClassNames = useMemo(
() => ({
root: styles.root,
...classNames,
}),
[classNames],
);
const memoizedStyle = useMemo(() => ({ ...style }), [style]);
return ( return (
<MantineDivider <MantineDivider
classNames={{ root: styles.root, ...classNames }} classNames={memoizedClassNames}
ref={ref} ref={ref}
style={{ ...style }} style={memoizedStyle}
{...props} {...props}
/> />
); );
}, },
); );
_Divider.displayName = 'Divider';
export const Divider = memo(_Divider);
+17 -13
View File
@@ -1,17 +1,21 @@
import { Flex as MantineFlex, FlexProps as MantineFlexProps } from '@mantine/core'; 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 interface FlexProps extends MantineFlexProps {}
export const Flex = forwardRef<HTMLDivElement, FlexProps>(({ children, ...props }, ref) => { const _Flex = forwardRef<HTMLDivElement, FlexProps>(
return ( ({ children, classNames, style, ...props }, ref) => {
<MantineFlex const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);
classNames={{ ...props.classNames }} const memoizedStyle = useMemo(() => ({ ...style }), [style]);
ref={ref}
style={{ ...props.style }} return (
{...props} <MantineFlex classNames={memoizedClassNames} ref={ref} style={memoizedStyle} {...props}>
> {children}
{children} </MantineFlex>
</MantineFlex> );
); },
}); );
_Flex.displayName = 'Flex';
export const Flex = memo(_Flex);
+11 -3
View File
@@ -1,9 +1,17 @@
import { Grid as MantineGrid, GridProps as MantineGridProps } from '@mantine/core'; import { Grid as MantineGrid, GridProps as MantineGridProps } from '@mantine/core';
import { memo, useMemo } from 'react';
export interface GridProps extends MantineGridProps {} export interface GridProps extends MantineGridProps {}
export const Grid = ({ classNames, style, ...props }: GridProps) => { const BaseGrid = ({ classNames, style, ...props }: GridProps) => {
return <MantineGrid classNames={{ ...classNames }} style={{ ...style }} {...props} />; const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);
const memoizedStyle = useMemo(() => ({ ...style }), [style]);
return <MantineGrid classNames={memoizedClassNames} style={memoizedStyle} {...props} />;
}; };
Grid.Col = MantineGrid.Col; BaseGrid.displayName = 'Grid';
export const Grid = memo(BaseGrid);
(Grid as typeof Grid & { Col: typeof MantineGrid.Col }).Col = MantineGrid.Col;
+22 -13
View File
@@ -1,17 +1,26 @@
import { Group as MantineGroup, GroupProps as MantineGroupProps } from '@mantine/core'; 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 interface GroupProps extends MantineGroupProps {}
export const Group = forwardRef<HTMLDivElement, GroupProps>(({ children, ...props }, ref) => { const _Group = forwardRef<HTMLDivElement, GroupProps>(
return ( ({ children, classNames, style, ...props }, ref) => {
<MantineGroup const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);
classNames={{ ...props.classNames }} const memoizedStyle = useMemo(() => ({ ...style }), [style]);
ref={ref}
style={{ ...props.style }} return (
{...props} <MantineGroup
> classNames={memoizedClassNames}
{children} ref={ref}
</MantineGroup> style={memoizedStyle}
); {...props}
}); >
{children}
</MantineGroup>
);
},
);
_Group.displayName = 'Group';
export const Group = memo(_Group);
+18 -10
View File
@@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'motion/react'; 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 { IconBaseProps } from 'react-icons';
import { FaLastfmSquare } from 'react-icons/fa'; import { FaLastfmSquare } from 'react-icons/fa';
import { import {
@@ -278,19 +278,23 @@ type IconColor =
| 'success' | 'success'
| 'warn'; | 'warn';
export const Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => { const _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
const { animate, className, color, fill, icon, size = 'md' } = props; const { animate, className, color, fill, icon, size = 'md' } = props;
const IconComponent: ComponentType<any> = AppIcon[icon]; const IconComponent: ComponentType<any> = AppIcon[icon];
const classNames = clsx(className, { const classNames = useMemo(
[styles.fill]: true, () =>
[styles.pulse]: animate === 'pulse', clsx(className, {
[styles.spin]: animate === 'spin', [styles.fill]: true,
[styles[`color-${color || fill}`]]: color || fill, [styles.pulse]: animate === 'pulse',
[styles[`fill-${fill}`]]: fill, [styles.spin]: animate === 'spin',
[styles[`size-${size}`]]: true, [styles[`color-${color || fill}`]]: color || fill,
}); [styles[`fill-${fill}`]]: fill,
[styles[`size-${size}`]]: true,
}),
[animate, className, color, fill, size],
);
return ( return (
<IconComponent <IconComponent
@@ -302,6 +306,10 @@ export const Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
); );
}); });
_Icon.displayName = 'Icon';
export const Icon = memo(_Icon);
Icon.displayName = 'Icon'; Icon.displayName = 'Icon';
export const MotionIcon: ComponentType = motion.create(Icon); export const MotionIcon: ComponentType = motion.create(Icon);
@@ -2,7 +2,7 @@ import {
MultiSelect as MantineMultiSelect, MultiSelect as MantineMultiSelect,
MultiSelectProps as MantineMultiSelectProps, MultiSelectProps as MantineMultiSelectProps,
} from '@mantine/core'; } from '@mantine/core';
import { CSSProperties } from 'react'; import { CSSProperties, useMemo } from 'react';
import styles from './multi-select.module.css'; import styles from './multi-select.module.css';
@@ -11,6 +11,23 @@ export interface MultiSelectProps extends MantineMultiSelectProps {
width?: CSSProperties['width']; 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 = ({ export const MultiSelect = ({
classNames, classNames,
maxWidth, maxWidth,
@@ -18,25 +35,21 @@ export const MultiSelect = ({
width, width,
...props ...props
}: MultiSelectProps) => { }: MultiSelectProps) => {
const mergedClassNames = useMemo(
() => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames),
[classNames],
);
const style = useMemo(
() => (maxWidth || width ? { maxWidth, width } : undefined),
[maxWidth, width],
);
return ( return (
<MantineMultiSelect <MantineMultiSelect
classNames={{ classNames={mergedClassNames}
dropdown: styles.dropdown, clearButtonProps={defaultClearButtonProps}
input: styles.input, style={style}
label: styles.label,
option: styles.option,
pill: styles.pill,
pillsList: styles.pillsList,
root: styles.root,
...classNames,
}}
clearButtonProps={{
classNames: {
root: styles.clearButton,
},
variant: 'transparent',
}}
style={{ maxWidth, width }}
variant={variant} variant={variant}
withCheckIcon={false} withCheckIcon={false}
{...props} {...props}
+15 -6
View File
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'; import { memo, ReactNode, useMemo } from 'react';
import styles from './option.module.css'; import styles from './option.module.css';
@@ -10,13 +10,22 @@ interface OptionProps extends GroupProps {
children: ReactNode; children: ReactNode;
} }
export const Option = ({ children, ...props }: OptionProps) => { const defaultClassNames = { root: styles.root };
export const Option = memo(({ children, classNames, ...props }: OptionProps) => {
const mergedClassNames = useMemo(
() => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames),
[classNames],
);
return ( return (
<Group classNames={{ root: styles.root }} grow {...props}> <Group classNames={mergedClassNames} grow {...props}>
{children} {children}
</Group> </Group>
); );
}; });
Option.displayName = 'Option';
interface LabelProps { interface LabelProps {
children: ReactNode; children: ReactNode;
@@ -34,5 +43,5 @@ const Control = ({ children }: ControlProps) => {
return <Flex justify="flex-end">{children}</Flex>; return <Flex justify="flex-end">{children}</Flex>;
}; };
Option.Label = Label; (Option as typeof Option & { Label: typeof Label }).Label = Label;
Option.Control = Control; (Option as typeof Option & { Control: typeof Control }).Control = Control;
+17 -12
View File
@@ -1,7 +1,7 @@
import type { PaperProps as MantinePaperProps } from '@mantine/core'; import type { PaperProps as MantinePaperProps } from '@mantine/core';
import { Paper as MantinePaper } 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'; import styles from './paper.module.css';
@@ -9,19 +9,24 @@ export interface PaperProps extends MantinePaperProps {
children?: ReactNode; 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 ( return (
<MantinePaper <MantinePaper classNames={memoizedClassNames} style={memoizedStyle} {...props}>
classNames={{
root: styles.root,
...classNames,
}}
style={{
...style,
}}
{...props}
>
{children} {children}
</MantinePaper> </MantinePaper>
); );
}; };
BasePaper.displayName = 'Paper';
export const Paper = memo(BasePaper);
+6 -1
View File
@@ -1,4 +1,5 @@
import { Center } from '@mantine/core'; import { Center } from '@mantine/core';
import { memo } from 'react';
import { IconBaseProps } from 'react-icons'; import { IconBaseProps } from 'react-icons';
import { CgSpinnerTwo } from 'react-icons/cg'; import { CgSpinnerTwo } from 'react-icons/cg';
@@ -12,7 +13,7 @@ interface SpinnerProps extends IconBaseProps {
export const SpinnerIcon = CgSpinnerTwo; export const SpinnerIcon = CgSpinnerTwo;
export const Spinner = ({ ...props }: SpinnerProps) => { const _Spinner = ({ ...props }: SpinnerProps) => {
if (props.container) { if (props.container) {
return ( return (
<Center className={styles.container}> <Center className={styles.container}>
@@ -23,3 +24,7 @@ export const Spinner = ({ ...props }: SpinnerProps) => {
return <SpinnerIcon className={styles.icon} color={props.color} size={props.size} />; return <SpinnerIcon className={styles.icon} color={props.color} size={props.size} />;
}; };
_Spinner.displayName = 'Spinner';
export const Spinner = memo(_Spinner);
+22 -13
View File
@@ -1,17 +1,26 @@
import { Stack as MantineStack, StackProps as MantineStackProps } from '@mantine/core'; 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 interface StackProps extends MantineStackProps {}
export const Stack = forwardRef<HTMLDivElement, StackProps>(({ children, ...props }, ref) => { const _Stack = forwardRef<HTMLDivElement, StackProps>(
return ( ({ children, classNames, style, ...props }, ref) => {
<MantineStack const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);
classNames={{ ...props.classNames }} const memoizedStyle = useMemo(() => ({ ...style }), [style]);
ref={ref}
style={{ ...props.style }} return (
{...props} <MantineStack
> classNames={memoizedClassNames}
{children} ref={ref}
</MantineStack> style={memoizedStyle}
); {...props}
}); >
{children}
</MantineStack>
);
},
);
_Stack.displayName = 'Stack';
export const Stack = memo(_Stack);
+24 -21
View File
@@ -1,6 +1,6 @@
import { Text as MantineText, TextProps as MantineTextProps } from '@mantine/core'; import { Text as MantineText, TextProps as MantineTextProps } from '@mantine/core';
import clsx from 'clsx'; import clsx from 'clsx';
import { ComponentPropsWithoutRef, ReactNode } from 'react'; import { ComponentPropsWithoutRef, ReactNode, useMemo } from 'react';
import styles from './text.module.css'; import styles from './text.module.css';
@@ -21,7 +21,7 @@ type Font = 'Epilogue' | 'Gotham' | 'Inter' | 'Poppins';
type MantineTextDivProps = ComponentPropsWithoutRef<'div'> & MantineTextProps; type MantineTextDivProps = ComponentPropsWithoutRef<'div'> & MantineTextProps;
export const _Text = ({ export const BaseText = ({
children, children,
font, font,
isLink, isLink,
@@ -31,28 +31,31 @@ export const _Text = ({
weight, weight,
...rest ...rest
}: TextProps) => { }: 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 ( return (
<MantineText <MantineText classNames={classNames} component="div" fw={weight} style={style} {...rest}>
classNames={{
root: clsx(styles.root, {
[styles.link]: isLink,
[styles.muted]: isMuted,
[styles.noSelect]: isNoSelect,
[styles.overflowHidden]: overflow === 'hidden',
}),
}}
component="div"
fw={weight}
style={
{
'--font-family': font,
} as React.CSSProperties
}
{...rest}
>
{children} {children}
</MantineText> </MantineText>
); );
}; };
export const Text = createPolymorphicComponent<'div', TextProps>(_Text); export const Text = createPolymorphicComponent<'div', TextProps>(BaseText);
+49 -26
View File
@@ -1,38 +1,61 @@
import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core'; import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core';
import clsx from 'clsx'; import clsx from 'clsx';
import { memo, useMemo } from 'react';
import styles from './tooltip.module.css'; import styles from './tooltip.module.css';
export interface TooltipProps extends MantineTooltipProps {} export interface TooltipProps extends MantineTooltipProps {}
export const Tooltip = ({ const DEFAULT_TRANSITION_PROPS = {
children, duration: 250,
classNames, transition: 'fade',
openDelay = 500, } as const;
transitionProps = {
duration: 250, const TooltipComponent = memo(
transition: 'fade', ({
}, children,
withinPortal = true, classNames,
...props openDelay = 500,
}: TooltipProps) => { transitionProps = DEFAULT_TRANSITION_PROPS,
return ( withinPortal = true,
<MantineTooltip ...props
arrowSize={10} }: TooltipProps) => {
classNames={{ const memoizedClassNames = useMemo(
() => ({
...classNames, ...classNames,
tooltip: clsx(styles.tooltip, classNames?.['tooltip']), tooltip: clsx(styles.tooltip, classNames?.['tooltip']),
}} }),
multiline [classNames],
openDelay={openDelay} );
transitionProps={transitionProps}
withArrow const memoizedTransitionProps = useMemo(
withinPortal={withinPortal} () => transitionProps ?? DEFAULT_TRANSITION_PROPS,
{...props} [transitionProps],
> );
{children}
</MantineTooltip> return (
); <MantineTooltip
arrowSize={10}
classNames={memoizedClassNames}
multiline
openDelay={openDelay}
transitionProps={memoizedTransitionProps}
withArrow
withinPortal={withinPortal}
{...props}
>
{children}
</MantineTooltip>
);
},
);
TooltipComponent.displayName = 'Tooltip';
export const Tooltip = TooltipComponent as typeof TooltipComponent & {
Group: typeof MantineTooltip.Group;
}; };
Tooltip.Group = MantineTooltip.Group; Tooltip.Group = MantineTooltip.Group;
Tooltip.Group = MantineTooltip.Group;