Migrate to Mantine v8 and Design Changes (#961)

* mantine v8 migration

* various design changes and improvements
This commit is contained in:
Jeff 2025-06-24 00:04:36 -07:00 committed by GitHub
parent bea55d48a8
commit c1330d92b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
473 changed files with 12469 additions and 11607 deletions

View file

@ -1,9 +1,9 @@
.animated-page {
container-type: inline-size;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
container-type: inline-size;
overflow: hidden;
}

View file

@ -1,9 +1,9 @@
import type { ReactNode, Ref } from 'react';
import { motion } from 'framer-motion';
import { motion } from 'motion/react';
import { forwardRef } from 'react';
import styles from './animated-page.module.scss';
import styles from './animated-page.module.css';
interface AnimatedPageProps {
children: ReactNode;

View file

@ -0,0 +1,5 @@
.filter-bar {
z-index: 1;
padding: var(--theme-spacing-md) var(--theme-spacing-sm);
box-shadow: 0 5px 15px rgb(0 0 0 / 65%);
}

View file

@ -1,14 +1,12 @@
import { PaperProps } from '@mantine/core';
import styled from 'styled-components';
import styles from './filter-bar.module.css';
import { Paper } from '/@/renderer/components';
const StyledFilterBar = styled(Paper)`
z-index: 1;
padding: 1rem;
box-shadow: 0 5px 15px rgb(0 0 0 / 65%);
`;
export const FilterBar = ({ children, ...props }: PaperProps) => {
return <StyledFilterBar {...props}>{children}</StyledFilterBar>;
export const FilterBar = ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={styles.filterBar}
{...props}
>
{children}
</div>
);
};

View file

@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
interface FilterButtonProps extends ActionIconProps {
isActive?: boolean;
}
export const FilterButton = ({ isActive, onClick, ...props }: FilterButtonProps) => {
const { t } = useTranslation();
return (
<ActionIcon
icon="filter"
iconProps={{
fill: isActive ? 'primary' : undefined,
size: 'lg',
...props.iconProps,
}}
onClick={onClick}
tooltip={{
label: t('common.filters', { count: 2, postProcess: 'sentenceCase' }),
...props.tooltip,
}}
variant="subtle"
{...props}
/>
);
};

View file

@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
interface FolderButtonProps extends ActionIconProps {
isActive?: boolean;
}
export const FolderButton = ({ isActive, ...props }: FolderButtonProps) => {
const { t } = useTranslation();
return (
<ActionIcon
icon="folder"
iconProps={{
fill: isActive ? 'primary' : undefined,
size: 'lg',
...props.iconProps,
}}
tooltip={{
label: t('entity.folder', { postProcess: 'sentenceCase' }),
...props.tooltip,
}}
variant="subtle"
{...props}
/>
);
};

View file

@ -1,12 +0,0 @@
.image-placeholder {
width: 100%;
height: 100%;
background-color: var(--placeholder-bg);
border-radius: var(--card-default-radius);
svg {
width: 35px;
height: 35px;
color: var(--placeholder-fg);
}
}

View file

@ -1,35 +0,0 @@
import { Center } from '@mantine/core';
import clsx from 'clsx';
import { memo } from 'react';
import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri';
import styles from './item-image-placeholder.module.css';
import { LibraryItem } from '/@/shared/types/domain-types';
interface ItemImagePlaceholderProps {
itemType?: LibraryItem;
}
const Image = memo(function Image(props: ItemImagePlaceholderProps) {
switch (props.itemType) {
case LibraryItem.ALBUM:
return <RiAlbumFill />;
case LibraryItem.ALBUM_ARTIST:
return <RiUserVoiceFill />;
case LibraryItem.ARTIST:
return <RiUserVoiceFill />;
case LibraryItem.PLAYLIST:
return <RiPlayListFill />;
default:
return <RiAlbumFill />;
}
});
export const ItemImagePlaceholder = ({ itemType }: ItemImagePlaceholderProps) => {
return (
<Center className={clsx(styles.imagePlaceholder, 'item-image-placeholder')}>
<Image itemType={itemType} />
</Center>
);
};

View file

@ -0,0 +1,11 @@
.root {
position: absolute;
z-index: -1;
width: 100%;
height: 20vh;
min-height: 200px;
pointer-events: none;
user-select: none;
background-image: var(--theme-overlay-subheader);
opacity: 0.3;
}

View file

@ -1,14 +1,14 @@
import styled from 'styled-components';
import styles from './library-background-overlay.module.css';
export const LibraryBackgroundOverlay = styled.div<{ $backgroundColor?: string }>`
position: absolute;
z-index: -1;
width: 100%;
height: 20vh;
min-height: 200px;
pointer-events: none;
user-select: none;
background: ${(props) => props.$backgroundColor};
background-image: var(--bg-subheader-overlay);
opacity: 0.3;
`;
interface LibraryBackgroundOverlayProps {
backgroundColor?: string;
}
export const LibraryBackgroundOverlay = ({ backgroundColor }: LibraryBackgroundOverlayProps) => {
return (
<div
className={styles.root}
style={{ backgroundColor }}
/>
);
};

View file

@ -0,0 +1,15 @@
.header-container {
display: flex;
flex-wrap: nowrap;
gap: 1rem;
align-items: center;
width: 100%;
height: 100%;
padding: 0 1rem;
}
.play-button-container {
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -1,67 +1,48 @@
import { Box } from '@mantine/core';
import { ReactNode } from 'react';
import styled from 'styled-components';
import { Paper, PaperProps, SpinnerIcon, TextTitle } from '/@/renderer/components';
import { PlayButton as PlayBtn } from '/@/renderer/features/shared/components/play-button';
import styles from './library-header-bar.module.css';
import { PlayButton, PlayButtonProps } from '/@/renderer/features/shared/components/play-button';
import { Badge, BadgeProps } from '/@/shared/components/badge/badge';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { TextTitle } from '/@/shared/components/text-title/text-title';
interface LibraryHeaderBarProps {
children: ReactNode;
}
const HeaderContainer = styled.div`
display: flex;
flex-wrap: nowrap;
gap: 1rem;
align-items: center;
width: 100%;
height: 100%;
padding: 0 1rem;
`;
export const LibraryHeaderBar = ({ children }: LibraryHeaderBarProps) => {
return <HeaderContainer>{children}</HeaderContainer>;
return <div className={styles.headerContainer}>{children}</div>;
};
interface TitleProps {
children: ReactNode;
}
const HeaderPlayButton = ({ className, ...props }: PlayButtonProps) => {
return (
<div className={styles.playButtonContainer}>
<PlayButton
className={className}
{...props}
/>
</div>
);
};
const Title = ({ children }: TitleProps) => {
return (
<TextTitle
fw={700}
order={1}
overflow="hidden"
weight={700}
>
{children}
</TextTitle>
);
};
interface PlayButtonProps {
onClick: (args: any) => void;
}
const PlayButton = ({ onClick }: PlayButtonProps) => {
return (
<Box>
<PlayBtn
h="45px"
onClick={onClick}
w="45px"
/>
</Box>
);
};
const Badge = styled(Paper)`
padding: 0.3rem 1rem;
font-weight: 600;
border-radius: 0.3rem;
`;
interface HeaderBadgeProps extends PaperProps {
interface HeaderBadgeProps extends BadgeProps {
isLoading?: boolean;
}
@ -70,5 +51,5 @@ const HeaderBadge = ({ children, isLoading, ...props }: HeaderBadgeProps) => {
};
LibraryHeaderBar.Title = Title;
LibraryHeaderBar.PlayButton = PlayButton;
LibraryHeaderBar.PlayButton = HeaderPlayButton;
LibraryHeaderBar.Badge = HeaderBadge;

View file

@ -5,6 +5,7 @@
grid-template-rows: 100%;
grid-template-columns: 175px minmax(0, 1fr);
gap: 1rem;
align-items: flex-end;
width: 100%;
max-width: 100%;
height: 30vh;
@ -79,7 +80,8 @@
@container (min-width: 1200px) {
grid-template-columns: 250px minmax(0, 1fr);
.image {
.image,
.image-section {
width: 250px !important;
height: 250px;
}
@ -98,12 +100,11 @@
align-items: flex-end;
justify-content: center;
height: 100%;
filter: drop-shadow(0 0 8px rgb(0, 0, 0, 50%));
filter: drop-shadow(0 0 8px rgb(0 0 0 / 50%));
}
.metadata-section {
z-index: 15;
font-size: 1.15rem;
display: flex;
flex-direction: column;
grid-area: info;
@ -112,7 +113,7 @@
}
.image {
object-fit: var(--image-fit);
object-fit: var(--theme-image-fit);
border-radius: 5px;
}
@ -122,9 +123,9 @@
z-index: 0;
width: 100%;
height: 100%;
opacity: 0.9;
background-size: cover !important;
background-position: center !important;
background-size: cover !important;
opacity: 0.9;
}
.background-overlay {
@ -134,7 +135,7 @@
z-index: 0;
width: 100%;
height: 100%;
background: var(--bg-header-overlay);
background: var(--theme-overlay-header);
}
.opaque-overlay {
@ -142,12 +143,14 @@
}
.title {
display: -webkit-box;
display: flex;
align-items: center !important;
margin: var(--theme-spacing-sm) 0;
overflow: hidden;
color: var(--main-fg);
-webkit-line-clamp: 2;
line-clamp: 2;
font-size: 2rem;
line-height: 1.2;
line-clamp: 2;
-webkit-line-clamp: 2;
color: var(--theme-colors-foreground);
-webkit-box-orient: vertical;
}

View file

@ -1,4 +1,3 @@
import { Center, Group } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { AutoTextSize } from 'auto-text-size';
import clsx from 'clsx';
@ -8,9 +7,10 @@ import { Link } from 'react-router-dom';
import styles from './library-header.module.css';
import { Text } from '/@/renderer/components';
import { ItemImagePlaceholder } from '/@/renderer/features/shared/components/item-image-placeholder';
import { useGeneralSettings } from '/@/renderer/store';
import { Center } from '/@/shared/components/center/center';
import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem } from '/@/shared/types/domain-types';
interface LibraryHeaderProps {
@ -107,37 +107,34 @@ export const LibraryHeader = forwardRef(
style={{ cursor: 'pointer' }}
tabIndex={0}
>
{!loading &&
(imageUrl && !isImageError ? (
<img
alt="cover"
className={styles.image}
onError={onImageError}
// placeholder={imagePlaceholderUrl || 'var(--placeholder-bg)'}
src={imageUrl}
style={{ height: '' }}
/>
) : (
<ItemImagePlaceholder itemType={item.type} />
))}
{!loading && imageUrl && !isImageError && (
<Image
alt="cover"
className={styles.image}
onError={onImageError}
src={imageUrl || ''}
/>
)}
</div>
{title && (
<div className={styles.metadataSection}>
<Group>
<h2>
<Text
$link
component={Link}
to={item.route}
tt="uppercase"
weight={600}
>
{itemTypeString()}
</Text>
</h2>
</Group>
<Text
component={Link}
fw={600}
isLink
size="md"
to={item.route}
tt="uppercase"
>
{itemTypeString()}
</Text>
<h1 className={styles.title}>
<AutoTextSize mode="box">{title}</AutoTextSize>
<AutoTextSize
maxFontSizePx={80}
mode="box"
>
{title}
</AutoTextSize>
</h1>
{children}
</div>

View file

@ -0,0 +1,271 @@
import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
import { SettingsButton } from '/@/renderer/features/shared/components/settings-button';
import { CheckboxSelect } from '/@/shared/components/checkbox-select/checkbox-select';
import { Icon } from '/@/shared/components/icon/icon';
import { Popover } from '/@/shared/components/popover/popover';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Slider } from '/@/shared/components/slider/slider';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Table } from '/@/shared/components/table/table';
import { ListDisplayType } from '/@/shared/types/types';
const DISPLAY_TYPES = [
{
label: (
<Stack
align="center"
p="sm"
>
<Icon
icon="layoutTable"
size="lg"
/>
{i18n.t('table.config.view.table', { postProcess: 'sentenceCase' }) as string}
</Stack>
),
value: ListDisplayType.TABLE,
},
{
label: (
<Stack
align="center"
p="sm"
>
<Icon
icon="layoutGrid"
size="lg"
/>
{i18n.t('table.config.view.card', { postProcess: 'sentenceCase' }) as string}
</Stack>
),
value: ListDisplayType.GRID,
},
{
disabled: true,
label: (
<Stack
align="center"
p="sm"
>
<Icon
icon="layoutList"
size="lg"
/>
{i18n.t('table.config.view.list', { postProcess: 'sentenceCase' }) as string}
</Stack>
),
value: ListDisplayType.LIST,
},
];
interface ListConfigMenuProps {
autoFitColumns?: boolean;
disabledViewTypes?: ListDisplayType[];
displayType: ListDisplayType;
itemGap?: number;
itemSize?: number;
onChangeAutoFitColumns?: (autoFitColumns: boolean) => void;
onChangeDisplayType?: (displayType: ListDisplayType) => void;
onChangeItemGap?: (itemGap: number) => void;
onChangeItemSize?: (itemSize: number) => void;
onChangeTableColumns?: (tableColumns: string[]) => void;
tableColumns?: string[];
tableColumnsData?: { label: string; value: string }[];
}
export const ListConfigMenu = (props: ListConfigMenuProps) => {
return (
<Popover
position="bottom-end"
width={300}
>
<Popover.Target>
<SettingsButton />
</Popover.Target>
<Popover.Dropdown p="md">
<Stack>
<SegmentedControl
data={DISPLAY_TYPES.map((type) => ({
...type,
disabled: props.disabledViewTypes?.includes(type.value),
}))}
onChange={(value) => props.onChangeDisplayType?.(value as ListDisplayType)}
value={props.displayType}
w="100%"
withItemsBorders={false}
/>
<Config {...props} />
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const Config = (props: ListConfigMenuProps) => {
switch (props.displayType) {
case ListDisplayType.GRID:
return <GridConfig {...props} />;
case ListDisplayType.TABLE:
return <TableConfig {...props} />;
default:
return null;
}
};
type TableConfigProps = Pick<
ListConfigMenuProps,
| 'autoFitColumns'
| 'itemSize'
| 'onChangeAutoFitColumns'
| 'onChangeItemSize'
| 'onChangeTableColumns'
| 'tableColumns'
| 'tableColumnsData'
>;
const TableConfig = ({
autoFitColumns,
itemSize,
onChangeAutoFitColumns,
onChangeItemSize,
onChangeTableColumns,
tableColumns,
tableColumnsData,
}: TableConfigProps) => {
const { t } = useTranslation();
if (
!tableColumnsData ||
!onChangeTableColumns ||
!tableColumns ||
!onChangeItemSize ||
autoFitColumns === undefined ||
!onChangeAutoFitColumns ||
itemSize === undefined
) {
console.error('TableConfig: Missing required props', {
itemSize,
onChangeItemSize,
onChangeTableColumns,
tableColumns,
tableColumnsData,
});
return null;
}
return (
<>
<Table
variant="vertical"
withColumnBorders
withRowBorders
withTableBorder
>
<Table.Tbody>
<Table.Tr>
<Table.Th>
{t('table.config.general.size', {
postProcess: 'sentenceCase',
})}
</Table.Th>
<Table.Td>
<Slider
defaultValue={itemSize}
max={100}
min={30}
onChangeEnd={onChangeItemSize}
/>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th w="50%">
{t('table.config.general.autoFitColumns', {
postProcess: 'sentenceCase',
})}
</Table.Th>
<Table.Td style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Switch
defaultChecked={autoFitColumns}
onChange={(e) => onChangeAutoFitColumns?.(e.target.checked)}
size="xs"
/>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
<ScrollArea
allowDragScroll
style={{ maxHeight: '200px' }}
>
<CheckboxSelect
data={tableColumnsData}
onChange={onChangeTableColumns}
value={tableColumns}
/>
</ScrollArea>
</>
);
};
type GridConfigProps = Pick<
ListConfigMenuProps,
'itemGap' | 'itemSize' | 'onChangeItemGap' | 'onChangeItemSize'
>;
const GridConfig = ({ itemSize, onChangeItemGap, onChangeItemSize }: GridConfigProps) => {
const { t } = useTranslation();
if (!onChangeItemGap || !onChangeItemSize || !itemSize) {
return null;
}
return (
<>
<Table
variant="vertical"
withColumnBorders
withRowBorders
withTableBorder
>
<Table.Tbody>
<Table.Tr>
<Table.Th w="50%">
{t('table.config.general.gap', {
postProcess: 'sentenceCase',
})}
</Table.Th>
<Table.Td>
<Slider
defaultValue={itemSize}
max={30}
min={0}
onChangeEnd={onChangeItemGap}
/>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th w="50%">
{t('table.config.general.size', {
postProcess: 'sentenceCase',
})}
</Table.Th>
<Table.Td>
<Slider
defaultValue={itemSize}
max={300}
min={135}
onChangeEnd={onChangeItemSize}
/>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</>
);
};

View file

@ -0,0 +1,17 @@
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
interface MoreButtonProps extends ActionIconProps {}
export const MoreButton = ({ ...props }: MoreButtonProps) => {
return (
<ActionIcon
icon="ellipsisHorizontal"
iconProps={{
size: 'lg',
...props.iconProps,
}}
variant="subtle"
{...props}
/>
);
};

View file

@ -1,42 +1,32 @@
import { ButtonProps } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { RiSortAsc, RiSortDesc } from 'react-icons/ri';
import { Button, Tooltip } from '/@/renderer/components';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
import { SortOrder } from '/@/shared/types/domain-types';
interface OrderToggleButtonProps {
buttonProps?: Partial<ButtonProps>;
buttonProps?: Partial<ActionIconProps>;
onToggle: () => void;
sortOrder: SortOrder;
}
export const OrderToggleButton = ({ buttonProps, onToggle, sortOrder }: OrderToggleButtonProps) => {
const { t } = useTranslation();
return (
<Tooltip
label={
sortOrder === SortOrder.ASC
? t('common.ascending', { postProcess: 'sentenceCase' })
: t('common.descending', { postProcess: 'sentenceCase' })
}
>
<Button
compact
fw="600"
onClick={onToggle}
size="md"
variant="subtle"
{...buttonProps}
>
<>
{sortOrder === SortOrder.ASC ? (
<RiSortAsc size="1.3rem" />
) : (
<RiSortDesc size="1.3rem" />
)}
</>
</Button>
</Tooltip>
<ActionIcon
icon={sortOrder === SortOrder.ASC ? 'sortAsc' : 'sortDesc'}
iconProps={{
size: 'lg',
}}
onClick={onToggle}
tooltip={{
label:
sortOrder === SortOrder.ASC
? t('common.ascending', { postProcess: 'sentenceCase' })
: t('common.descending', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
{...buttonProps}
/>
);
};

View file

@ -0,0 +1,16 @@
.button {
width: 3rem;
height: 3rem;
border-radius: 50%;
opacity: 0.8;
transition: background-color 0.2s ease-in-out;
transition: transform 0.2s ease-in-out;
&:active {
transform: scale(0.95);
}
&:disabled {
opacity: 0.6;
}
}

View file

@ -1,49 +1,24 @@
import { UnstyledButton } from '@mantine/core';
import { RiPlayFill } from 'react-icons/ri';
import styled from 'styled-components';
import clsx from 'clsx';
const MotionButton = styled(UnstyledButton)`
display: flex;
align-items: center;
justify-content: center;
background: var(--btn-filled-bg);
border: none;
border-radius: 50%;
opacity: 0.8;
import styles from './play-button.module.css';
svg {
fill: var(--btn-filled-fg);
}
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
&:hover:not([disabled]) {
background: var(--btn-filled-bg);
transform: scale(1.1);
export interface PlayButtonProps extends ActionIconProps {
size?: number | string;
}
svg {
fill: var(--btn-filled-fg-hover);
}
}
&:active {
transform: scale(0.95);
}
&:disabled {
opacity: 0.6;
}
transition: background-color 0.2s ease-in-out;
transition: transform 0.2s ease-in-out;
`;
export const PlayButton = ({ ...props }: any) => {
export const PlayButton = ({ className, ...props }: PlayButtonProps) => {
return (
<MotionButton
<ActionIcon
className={clsx(styles.button, className)}
icon="mediaPlay"
iconProps={{
fill: 'default',
size: 'lg',
}}
variant="filled"
{...props}
h="45px"
w="45px"
>
<RiPlayFill size={20} />
</MotionButton>
/>
);
};

View file

@ -0,0 +1,26 @@
import { useTranslation } from 'react-i18next';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
interface RefreshButtonProps extends ActionIconProps {}
export const RefreshButton = ({ onClick, ...props }: RefreshButtonProps) => {
const { t } = useTranslation();
return (
<ActionIcon
icon="refresh"
iconProps={{
size: 'lg',
...props.iconProps,
}}
onClick={onClick}
tooltip={{
label: t('common.refresh', { postProcess: 'sentenceCase' }),
...props.tooltip,
}}
variant="subtle"
{...props}
/>
);
};

View file

@ -0,0 +1,40 @@
.handle {
position: absolute;
z-index: 90;
width: 4px;
height: 100%;
cursor: ew-resize;
background-color: var(--theme-colors-border);
opacity: 0;
&:hover {
opacity: 0.6;
}
&::before {
position: absolute;
width: 1px;
height: 100%;
content: '';
}
}
.handle-top {
top: 0;
}
.handle-right {
right: 0;
}
.handle-bottom {
bottom: 0;
}
.handle-left {
left: 0;
}
.handle.resizing {
opacity: 1;
}

View file

@ -1,33 +1,25 @@
import styled from 'styled-components';
import clsx from 'clsx';
import { forwardRef, HTMLAttributes } from 'react';
export const ResizeHandle = styled.div<{
$isResizing: boolean;
$placement: 'bottom' | 'left' | 'right' | 'top';
}>`
position: absolute;
top: ${(props) => props.$placement === 'top' && 0};
right: ${(props) => props.$placement === 'right' && 0};
bottom: ${(props) => props.$placement === 'bottom' && 0};
left: ${(props) => props.$placement === 'left' && 0};
z-index: 90;
width: 4px;
height: 100%;
cursor: ew-resize;
opacity: ${(props) => (props.$isResizing ? 1 : 0)};
import styles from './resize-handle.module.css';
&:hover {
opacity: 0.7;
}
interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {
isResizing: boolean;
placement: 'bottom' | 'left' | 'right' | 'top';
}
&::before {
position: absolute;
top: ${(props) => props.$placement === 'top' && 0};
right: ${(props) => props.$placement === 'right' && 0};
bottom: ${(props) => props.$placement === 'bottom' && 0};
left: ${(props) => props.$placement === 'left' && 0};
width: 1px;
height: 100%;
content: '';
background-color: var(--sidebar-handle-bg);
}
`;
export const ResizeHandle = forwardRef<HTMLDivElement, ResizeHandleProps>(
({ isResizing, placement, ...props }: ResizeHandleProps, ref) => {
return (
<div
className={clsx({
[styles.handle]: true,
[styles.resizing]: isResizing,
[styles[`handle-${placement}`]]: true,
})}
ref={ref}
{...props}
/>
);
},
);

View file

@ -0,0 +1,58 @@
import { useHotkeys } from '@mantine/hooks';
import { ChangeEvent, KeyboardEvent, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import { useSettingsStore } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Icon } from '/@/shared/components/icon/icon';
import { TextInput, TextInputProps } from '/@/shared/components/text-input/text-input';
interface SearchInputProps extends TextInputProps {
value?: string;
}
export const SearchInput = ({ onChange, ...props }: SearchInputProps) => {
const ref = useRef<HTMLInputElement>(null);
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
useHotkeys([[binding.hotkey, () => ref?.current?.select()]]);
const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.code === 'Escape') {
onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);
if (ref.current) {
ref.current.value = '';
ref.current.blur();
}
}
};
const handleClear = () => {
if (ref.current) {
ref.current.value = '';
ref.current.focus();
onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);
}
};
return (
<TextInput
leftSection={<Icon icon="search" />}
onChange={onChange}
onKeyDown={handleEscape}
ref={ref}
size="sm"
width={200}
{...props}
rightSection={
ref.current?.value ? (
<ActionIcon
icon="x"
onClick={handleClear}
variant="transparent"
/>
) : null
}
/>
);
};

View file

@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
interface SettingsButtonProps extends ActionIconProps {}
export const SettingsButton = ({ ...props }: SettingsButtonProps) => {
const { t } = useTranslation();
return (
<ActionIcon
icon="settings"
iconProps={{
size: 'lg',
...props.iconProps,
}}
tooltip={{
label: t('common.configure', { postProcess: 'sentenceCase' }),
...props.tooltip,
}}
variant="subtle"
{...props}
/>
);
};