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

@ -0,0 +1,12 @@
.panel {
background: var(--theme-colors-background);
}
.control {
background: var(--theme-colors-background);
}
.chevron {
display: flex;
justify-content: center;
}

View file

@ -0,0 +1,41 @@
import {
Accordion as MantineAccordion,
AccordionProps as MantineAccordionProps,
} from '@mantine/core';
import styles from './accordion.module.css';
import { Icon } from '/@/shared/components/icon/icon';
export interface AccordionProps
extends Omit<MantineAccordionProps, 'defaultValue' | 'multiple' | 'onChange'> {
defaultValue?: string | string[];
multiple?: boolean;
onChange?: (value: null | string | string[]) => void;
}
export const Accordion = ({ children, classNames, ...props }: AccordionProps) => {
return (
<MantineAccordion
chevron={
<Icon
icon="arrowUpS"
size="lg"
/>
}
classNames={{
chevron: styles.chevron,
control: styles.control,
panel: styles.panel,
...classNames,
}}
{...props}
>
{children}
</MantineAccordion>
);
};
Accordion.Control = MantineAccordion.Control;
Accordion.Item = MantineAccordion.Item;
Accordion.Panel = MantineAccordion.Panel;

View file

@ -0,0 +1,92 @@
.root {
--ai-size-xs: calc(1.875rem * var(--mantine-scale));
--ai-size-sm: calc(2.25rem * var(--mantine-scale));
--ai-size-md: calc(2.625rem * var(--mantine-scale));
--ai-size-lg: calc(3.125rem * var(--mantine-scale));
--ai-size-xl: calc(3.75rem * var(--mantine-scale));
font-weight: 500;
transition:
background-color 0.2s ease-in-out,
border-color 0.2s ease-in-out;
&[data-disabled='true'] {
opacity: 0.6;
}
&[data-variant='default'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-surface);
border: 1px solid transparent;
&:hover {
background: lighten(var(--theme-colors-surface), 5%);
}
&:focus-visible {
background: lighten(var(--theme-colors-surface), 10%);
}
}
&[data-variant='outline'] {
--button-border: var(--theme-colors-border);
color: var(--theme-colors-foreground);
border: 1px solid var(--theme-colors-border);
&:hover {
border: 1px solid lighten(var(--theme-colors-border), 10%);
}
&:focus-visible {
border: 1px solid lighten(var(--theme-colors-border), 10%);
}
}
&[data-variant='filled'] {
color: var(--theme-colors-primary-contrast);
background: var(--theme-colors-primary-filled);
border: 1px solid transparent;
transition: background-color 0.2s ease-in-out;
&:hover,
&:focus-visible {
background: darken(var(--theme-colors-primary-filled), 10%);
}
}
&[data-variant='subtle'] {
color: var(--theme-colors-foreground);
background: transparent;
&:hover,
&:focus-visible {
background: lighten(var(--theme-colors-surface), 10%);
}
}
&[data-variant='secondary'] {
border: 1px solid transparent;
&:hover {
background: darken(var(--theme-colors-surface), 5%);
}
&:focus-visible {
background: darken(var(--theme-colors-surface), 10%);
}
}
&[data-variant='transparent'] {
color: var(--theme-colors-foreground);
border: 1px solid transparent;
&:hover {
background: transparent;
}
&:focus-visible {
border: 1px solid lighten(var(--theme-colors-border), 10%);
}
}
}

View file

@ -0,0 +1,110 @@
import {
ElementProps,
ActionIcon as MantineActionIcon,
ActionIconProps as MantineActionIconProps,
} from '@mantine/core';
import { forwardRef } from 'react';
import styles from './action-icon.module.css';
import { AppIcon, Icon, IconProps } from '/@/shared/components/icon/icon';
import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';
import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';
export interface ActionIconProps
extends ElementProps<'button', keyof MantineActionIconProps>,
MantineActionIconProps {
icon?: keyof typeof AppIcon;
iconProps?: Omit<IconProps, 'icon'>;
tooltip?: Omit<TooltipProps, 'children'>;
}
const _ActionIcon = forwardRef<HTMLButtonElement, ActionIconProps>(
(
{
children,
classNames,
icon,
iconProps,
size = 'sm',
tooltip,
variant = 'default',
...props
},
ref,
) => {
const actionIconProps: ActionIconProps = {
classNames: {
root: styles.root,
...classNames,
},
size,
variant,
...props,
};
if (tooltip && icon) {
return (
<Tooltip
withinPortal
{...tooltip}
>
<MantineActionIcon
ref={ref}
{...actionIconProps}
>
<Icon
icon={icon}
size={actionIconProps.size}
{...iconProps}
/>
</MantineActionIcon>
</Tooltip>
);
}
if (icon) {
return (
<MantineActionIcon
ref={ref}
{...actionIconProps}
>
<Icon
icon={icon}
size={actionIconProps.size}
{...iconProps}
/>
</MantineActionIcon>
);
}
if (tooltip) {
return (
<Tooltip
withinPortal
{...tooltip}
>
<MantineActionIcon
ref={ref}
{...actionIconProps}
>
{children}
</MantineActionIcon>
</Tooltip>
);
}
return (
<MantineActionIcon
ref={ref}
{...actionIconProps}
>
{children}
</MantineActionIcon>
);
},
);
export const ActionIcon = createPolymorphicComponent<'button', ActionIconProps>(_ActionIcon);
export const ActionIconGroup = MantineActionIcon.Group;
export const ActionIconSection = MantineActionIcon.GroupSection;

View file

@ -0,0 +1,13 @@
.root[data-variant='filled'] {
background: var(--theme-colors-primary-filled);
}
.root[data-variant='outline'] {
background: transparent;
}
.root {
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
font-weight: 500;
background: var(--theme-colors-surface);
}

View file

@ -0,0 +1,28 @@
import {
ElementProps,
Badge as MantineBadge,
BadgeProps as MantineBadgeProps,
} from '@mantine/core';
import styles from './badge.module.css';
import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';
export interface BadgeProps
extends ElementProps<'div', keyof MantineBadgeProps>,
MantineBadgeProps {}
const _Badge = ({ children, classNames, variant = 'default', ...props }: BadgeProps) => {
return (
<MantineBadge
classNames={{ root: styles.root, ...classNames }}
radius="md"
variant={variant}
{...props}
>
{children}
</MantineBadge>
);
};
export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge);

View file

@ -0,0 +1,7 @@
import { ElementProps, Box as MantineBox, BoxProps as MantineBoxProps } from '@mantine/core';
export interface BoxProps extends ElementProps<'div', keyof MantineBoxProps>, MantineBoxProps {}
export const Box = ({ children, ...props }: BoxProps) => {
return <MantineBox {...props}>{children}</MantineBox>;
};

View file

@ -0,0 +1,156 @@
.root {
font-weight: 500;
border: 1px solid transparent;
transition:
background-color 0.2s ease-in-out,
border-color 0.2s ease-in-out;
&[data-disabled='true'] {
opacity: 0.6;
}
&[data-variant='default'] {
color: var(--theme-colors-foreground);
background-color: var(--theme-colors-surface);
&:hover {
background: lighten(var(--theme-colors-surface), 5%);
}
&:focus-visible {
background: lighten(var(--theme-colors-surface), 10%);
}
}
&[data-variant='outline'] {
--button-border: var(--theme-colors-border);
color: var(--theme-colors-foreground);
border: 1px solid var(--theme-colors-border);
&:hover {
border: 1px solid lighten(var(--theme-colors-border), 10%);
}
&:focus-visible {
border: 1px solid lighten(var(--theme-colors-border), 10%);
}
}
&[data-variant='filled'] {
color: var(--theme-colors-primary-contrast);
background: var(--theme-colors-primary-filled);
border: 1px solid transparent;
transition: background-color 0.2s ease-in-out;
&:hover,
&:focus-visible {
background: darken(var(--theme-colors-primary-filled), 10%);
}
}
&[data-variant='state-error'] {
background: var(--theme-colors-state-error);
&:hover,
&:focus-visible {
background: darken(var(--theme-colors-state-error), 10%);
}
}
&[data-variant='state-info'] {
background: var(--theme-colors-state-info);
&:hover,
&:focus-visible {
background: darken(var(--theme-colors-state-info), 10%);
}
}
&[data-variant='state-success'] {
background: var(--theme-colors-state-success);
&:hover,
&:focus-visible {
background: darken(var(--theme-colors-state-success), 10%);
}
}
&[data-variant='state-warning'] {
background: var(--theme-colors-state-warning);
&:hover,
&:focus-visible {
background: darken(var(--theme-colors-state-warning), 10%);
}
}
&[data-variant='subtle'] {
color: var(--theme-colors-foreground);
background: transparent;
&:hover,
&:active,
&:focus-visible {
background-color: lighten(var(--button-bg), 10%);
}
}
&[data-variant='secondary'] {
border: 1px solid transparent;
&:hover {
background-color: darken(var(--button-bg), 5%);
}
&:focus-visible {
background-color: darken(var(--button-bg), 10%);
}
}
&[data-variant='transparent'] {
color: var(--theme-colors-foreground);
border: 1px solid transparent;
transition: color 0.2s ease-in-out;
&:hover {
background-color: transparent;
@mixin dark {
color: lighten(var(--theme-colors-foreground), 10%);
}
@mixin light {
color: darken(var(--theme-colors-foreground), 10%);
}
}
&:focus-visible {
border: 1px solid lighten(var(--theme-colors-border), 10%);
}
}
}
.loader {
display: none;
}
.section {
display: flex;
margin-inline-end: var(--theme-spacing-sm);
}
.button-inner.loading {
color: transparent;
}
.spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
}
.uppercase {
text-transform: uppercase;
}

View file

@ -0,0 +1,155 @@
import type { ButtonVariant, ButtonProps as MantineButtonProps } from '@mantine/core';
import { ElementProps, Button as MantineButton } from '@mantine/core';
import { useTimeout } from '@mantine/hooks';
import clsx from 'clsx';
import { forwardRef, useCallback, useRef, useState } from 'react';
import styles from './button.module.css';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';
import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';
export interface ButtonProps
extends ElementProps<'button', keyof MantineButtonProps>,
MantineButtonProps,
MantineButtonProps {
tooltip?: Omit<TooltipProps, 'children'>;
uppercase?: boolean;
variant?: ExtendedButtonVariant;
}
type ExtendedButtonVariant =
| 'state-error'
| 'state-info'
| 'state-success'
| 'state-warning'
| ButtonVariant;
export const _Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
classNames,
loading,
size = 'sm',
style,
tooltip,
uppercase,
variant = 'default',
...props
}: ButtonProps,
ref,
) => {
if (tooltip) {
return (
<Tooltip
withinPortal
{...tooltip}
>
<MantineButton
autoContrast
classNames={{
label: clsx(styles.label, {
[styles.uppercase]: uppercase,
}),
loader: styles.loader,
root: styles.root,
section: styles.section,
...classNames,
}}
ref={ref}
size={size}
style={style}
variant={variant}
{...props}
>
{children}
{loading && (
<div className={styles.spinner}>
<Spinner />
</div>
)}
</MantineButton>
</Tooltip>
);
}
return (
<MantineButton
classNames={{
label: clsx(styles.label, {
[styles.uppercase]: uppercase,
}),
loader: styles.loader,
root: styles.root,
section: styles.section,
...classNames,
}}
ref={ref}
size={size}
style={style}
variant={variant}
{...props}
>
{children}
{loading && (
<div className={styles.spinner}>
<Spinner />
</div>
)}
</MantineButton>
);
},
);
export const Button = createPolymorphicComponent<'button', ButtonProps>(_Button);
interface TimeoutButtonProps extends ButtonProps {
timeoutProps: {
callback: () => void;
duration: number;
};
}
export const TimeoutButton = ({ timeoutProps, ...props }: TimeoutButtonProps) => {
const [, setTimeoutRemaining] = useState(timeoutProps.duration);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(0);
const callback = () => {
timeoutProps.callback();
setTimeoutRemaining(timeoutProps.duration);
clearInterval(intervalRef.current);
setIsRunning(false);
};
const { clear, start } = useTimeout(callback, timeoutProps.duration);
const startTimeout = useCallback(() => {
if (isRunning) {
clearInterval(intervalRef.current);
setIsRunning(false);
clear();
} else {
setIsRunning(true);
start();
const intervalId = window.setInterval(() => {
setTimeoutRemaining((prev) => prev - 100);
}, 100);
intervalRef.current = intervalId;
}
}, [clear, isRunning, start]);
return (
<Button
onClick={startTimeout}
{...props}
>
{isRunning ? 'Cancel' : props.children}
</Button>
);
};

View file

@ -0,0 +1,22 @@
import { Center as MantineCenter, CenterProps as MantineCenterProps } from '@mantine/core';
import { forwardRef, MouseEvent } from 'react';
export interface CenterProps extends MantineCenterProps {
onClick?: (e: MouseEvent<HTMLDivElement>) => void;
}
export const Center = forwardRef<HTMLDivElement, CenterProps>(
({ children, classNames, onClick, style, ...props }, ref) => {
return (
<MantineCenter
classNames={{ ...classNames }}
onClick={onClick}
ref={ref}
style={{ ...style }}
{...props}
>
{children}
</MantineCenter>
);
},
);

View file

@ -0,0 +1,53 @@
.container {
display: flex;
flex-direction: column;
width: 100%;
}
.root {
padding: 0 var(--mantine-spacing-sm);
}
.body {
display: flex;
align-items: center;
width: 100%;
height: 100%;
}
.label-wrapper {
width: 100%;
height: 100%;
}
.label {
display: flex;
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--mantine-font-size-sm);
white-space: nowrap;
user-select: none;
}
.item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
width: 100%;
padding: var(--mantine-spacing-xs);
}
.dragging {
opacity: 0.5;
}
.dragged-over-top {
box-shadow: inset 0 2px 0 0 var(--mantine-color-secondary-7);
}
.dragged-over-bottom {
box-shadow: inset 0 -2px 0 0 var(--mantine-color-secondary-7);
}

View file

@ -0,0 +1,167 @@
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import {
attachClosestEdge,
extractClosestEdge,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import styles from './checkbox-select.module.css';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
interface CheckboxSelectProps {
data: { label: string; value: string }[];
enableDrag?: boolean;
onChange: (value: string[]) => void;
value: string[];
}
export const CheckboxSelect = ({ data, enableDrag, onChange, value }: CheckboxSelectProps) => {
const handleChange = (values: string[]) => {
onChange(values);
};
return (
<div className={styles.container}>
{data.map((option) => (
<CheckboxSelectItem
enableDrag={enableDrag}
key={option.value}
onChange={handleChange}
option={option}
values={value}
/>
))}
</div>
);
};
interface CheckboxSelectItemProps {
enableDrag?: boolean;
onChange: (values: string[]) => void;
option: { label: string; value: string };
values: string[];
}
function CheckboxSelectItem({ enableDrag, onChange, option, values }: CheckboxSelectItemProps) {
const ref = useRef<HTMLInputElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);
useEffect(() => {
if (!ref.current || !dragHandleRef.current || !enableDrag) {
return;
}
return combine(
draggable({
element: dragHandleRef.current,
getInitialData: () => {
const data = dndUtils.generateDragData({
id: [option.value],
operation: [DragOperation.REORDER],
type: DragTarget.GENERIC,
});
return data;
},
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: (data) => {
disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });
},
}),
dropTargetForElements({
canDrop: (args) => {
const data = args.source.data as unknown as DragData;
const isSelf = (args.source.data.id as string[])[0] === option.value;
return dndUtils.isDropTarget(data.type, [DragTarget.GENERIC]) && !isSelf;
},
element: ref.current,
getData: ({ element, input }) => {
const data = dndUtils.generateDragData({
id: [option.value],
operation: [DragOperation.REORDER],
type: DragTarget.GENERIC,
});
return attachClosestEdge(data, {
allowedEdges: ['top', 'bottom'],
element,
input,
});
},
onDrag: (args) => {
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);
setIsDraggedOver(closestEdgeOfTarget);
},
onDragLeave: () => {
setIsDraggedOver(null);
},
onDrop: (args) => {
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);
const from = args.source.data.id as string[];
const to = args.self.data.id as string[];
const newOrder = dndUtils.reorderById({
edge: closestEdgeOfTarget,
idFrom: from[0],
idTo: to[0],
list: values,
});
onChange(newOrder);
setIsDraggedOver(null);
},
}),
);
}, [values, enableDrag, onChange, option.value]);
return (
<div
className={clsx(styles.item, {
[styles.draggedOverBottom]: isDraggedOver === 'bottom',
[styles.draggedOverTop]: isDraggedOver === 'top',
[styles.dragging]: isDragging,
})}
ref={ref}
>
{enableDrag && (
<ActionIcon
className={styles.dragHandle}
icon="dragVertical"
ref={dragHandleRef}
size="xs"
variant="default"
/>
)}
<Checkbox
checked={values.includes(option.value)}
label={option.label}
onChange={(e) => {
onChange(
e.target.checked
? [...values, option.value]
: values.filter((v) => v !== option.value),
);
}}
variant="filled"
/>
</div>
);
}

View file

@ -0,0 +1,13 @@
.input {
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
&[data-variant='filled'] {
background: var(--theme-colors-background);
}
}
.input:disabled {
border: 1px solid var(--theme-colors-border);
opacity: 0.6;
}

View file

@ -0,0 +1,22 @@
import { Checkbox as MantineCheckbox, CheckboxProps as MantineCheckboxProps } from '@mantine/core';
import { forwardRef } from 'react';
import styles from './checkbox.module.css';
interface CheckboxProps extends MantineCheckboxProps {}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ classNames, ...props }: CheckboxProps, ref) => {
return (
<MantineCheckbox
classNames={{
input: styles.input,
label: styles.label,
...classNames,
}}
ref={ref}
{...props}
/>
);
},
);

View file

@ -0,0 +1,4 @@
.root {
background: var(--theme-colors-surface);
border-radius: var(--theme-radius-md);
}

View file

@ -0,0 +1,18 @@
import { Code as MantineCode, CodeProps as MantineCodeProps } from '@mantine/core';
import styles from './code.module.css';
export interface CodeProps extends MantineCodeProps {}
export const Code = ({ classNames, ...props }: CodeProps) => {
return (
<MantineCode
{...props}
classNames={{
...classNames,
root: styles.root,
}}
spellCheck={false}
/>
);
};

View file

@ -0,0 +1,37 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}
.dropdown {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid transparent;
border-radius: var(--theme-radius-md);
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
}

View file

@ -0,0 +1,30 @@
import {
ColorInput as MantineColorInput,
ColorInputProps as MantineColorInputProps,
} from '@mantine/core';
import styles from './color-input.module.css';
export interface ColorInputProps extends MantineColorInputProps {}
export const ColorInput = ({
classNames,
size = 'sm',
variant = 'default',
...props
}: ColorInputProps) => {
return (
<MantineColorInput
classNames={{
dropdown: styles.dropdown,
input: styles.input,
label: styles.label,
root: styles.root,
...classNames,
}}
size={size}
variant={variant}
{...props}
/>
);
};

View file

@ -0,0 +1,10 @@
import {
CopyButton as MantineCopyButton,
CopyButtonProps as MantineCopyButtonProps,
} from '@mantine/core';
export interface CopyButtonProps extends MantineCopyButtonProps {}
export const CopyButton = ({ children, ...props }: CopyButtonProps) => {
return <MantineCopyButton {...props}>{children}</MantineCopyButton>;
};

View file

@ -0,0 +1,19 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.input {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid transparent;
}
.section {
color: var(--theme-colors-foreground-muted);
}
.required {
color: var(--theme-colors-state-error);
}

View file

@ -0,0 +1,35 @@
import type { DateInputProps as MantineDateInputProps } from '@mantine/dates';
import { DateInput as MantineDateInput } from '@mantine/dates';
import styles from './date-picker.module.css';
interface DateInputProps extends MantineDateInputProps {
maxWidth?: number | string;
width?: number | string;
}
export const DateInput = ({
classNames,
maxWidth,
size = 'sm',
style,
width,
...props
}: DateInputProps) => {
return (
<MantineDateInput
classNames={{
input: styles.input,
label: styles.label,
required: styles.required,
root: styles.root,
section: styles.section,
...classNames,
}}
size={size}
style={{ maxWidth, width, ...style }}
{...props}
/>
);
};

View file

@ -0,0 +1,19 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.input {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid transparent;
}
.section {
color: var(--theme-colors-foreground-muted);
}
.required {
color: var(--theme-colors-state-error);
}

View file

@ -0,0 +1,37 @@
import type { DateTimePickerProps as MantineDateTimePickerProps } from '@mantine/dates';
import { DateTimePicker as MantineDateTimePicker } from '@mantine/dates';
import styles from './date-time-picker.module.css';
interface DateTimePickerProps extends MantineDateTimePickerProps {
maxWidth?: number | string;
width?: number | string;
}
export const DateTimePicker = ({
classNames,
maxWidth,
popoverProps,
size = 'sm',
style,
width,
...props
}: DateTimePickerProps) => {
return (
<MantineDateTimePicker
classNames={{
input: styles.input,
label: styles.label,
required: styles.required,
root: styles.root,
section: styles.section,
...classNames,
}}
popoverProps={{ withinPortal: true, ...popoverProps }}
size={size}
style={{ maxWidth, width, ...style }}
{...props}
/>
);
};

View file

@ -0,0 +1,5 @@
.root {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
}

View file

@ -0,0 +1,19 @@
import type { DialogProps as MantineDialogProps } from '@mantine/core';
import { Dialog as MantineDialog } from '@mantine/core';
import styles from './dialog.module.css';
interface DialogProps extends MantineDialogProps {}
export const Dialog = ({ classNames, style, ...props }: DialogProps) => {
return (
<MantineDialog
classNames={{ root: styles.root, ...classNames }}
style={{
...style,
}}
{...props}
/>
);
};

View file

@ -0,0 +1,3 @@
.root {
--divider-color: var(--theme-colors-border);
}

View file

@ -0,0 +1,19 @@
import { Divider as MantineDivider, DividerProps as MantineDividerProps } from '@mantine/core';
import { forwardRef } from 'react';
import styles from './divider.module.css';
export interface DividerProps extends MantineDividerProps {}
export const Divider = forwardRef<HTMLDivElement, DividerProps>(
({ classNames, style, ...props }, ref) => {
return (
<MantineDivider
classNames={{ root: styles.root, ...classNames }}
ref={ref}
style={{ ...style }}
{...props}
/>
);
},
);

View file

@ -0,0 +1,58 @@
.menu-item {
position: relative;
display: flex;
align-items: center;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
cursor: default;
}
.menu-item:disabled {
opacity: 0.6;
}
.menu-item-label {
margin-right: var(--theme-spacing-md);
margin-left: var(--theme-spacing-md);
font-size: var(--theme-font-size-sm);
color: var(--theme-colors-surface-foreground);
}
.selected {
&::before {
position: absolute;
top: 50%;
left: 2px;
width: 4px;
height: 50%;
content: '';
background-color: var(--theme-colors-primary-filled);
border-radius: var(--theme-border-radius-xl);
transform: translateY(-50%);
}
}
.menu-item-label-danger {
color: var(--theme-colors-state-error);
}
.menu-item-right-section {
display: flex;
}
.menu-dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
}
.menu-divider {
padding: 0;
margin: 0;
border-color: var(--theme-colors-border);
}
.menu-item-section svg {
font-size: var(--theme-font-size-sm);
}

View file

@ -0,0 +1,102 @@
import type {
MenuDividerProps as MantineMenuDividerProps,
MenuDropdownProps as MantineMenuDropdownProps,
MenuItemProps as MantineMenuItemProps,
MenuLabelProps as MantineMenuLabelProps,
MenuProps as MantineMenuProps,
} from '@mantine/core';
import { Menu as MantineMenu } from '@mantine/core';
import clsx from 'clsx';
import { ReactNode } from 'react';
import styles from './dropdown-menu.module.css';
import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';
type MenuDividerProps = MantineMenuDividerProps;
type MenuDropdownProps = MantineMenuDropdownProps;
interface MenuItemProps extends MantineMenuItemProps {
children: ReactNode;
isDanger?: boolean;
isSelected?: boolean;
}
type MenuLabelProps = MantineMenuLabelProps;
type MenuProps = MantineMenuProps;
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
return (
<MantineMenu
classNames={{
dropdown: styles['menu-dropdown'],
itemSection: styles['menu-item-section'],
}}
transitionProps={{
transition: 'fade',
}}
withinPortal
{...props}
>
{children}
</MantineMenu>
);
};
const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
return (
<MantineMenu.Label
className={styles['menu-label']}
{...props}
>
{children}
</MantineMenu.Label>
);
};
const pMenuItem = ({ children, isDanger, isSelected, ...props }: MenuItemProps) => {
return (
<MantineMenu.Item
className={clsx(styles['menu-item'], {
[styles.selected]: isSelected,
})}
{...props}
>
<span
className={clsx(styles['menu-item-label'], {
[styles['menu-item-label-danger']]: isDanger,
[styles['menu-item-label-normal']]: !isDanger,
})}
>
{children}
</span>
</MantineMenu.Item>
);
};
const MenuDropdown = ({ children, ...props }: MenuDropdownProps) => {
return (
<MantineMenu.Dropdown
className={styles['menu-dropdown']}
{...props}
>
{children}
</MantineMenu.Dropdown>
);
};
const MenuItem = createPolymorphicComponent<'button', MenuItemProps>(pMenuItem);
const MenuDivider = ({ ...props }: MenuDividerProps) => {
return (
<MantineMenu.Divider
className={styles['menu-divider']}
{...props}
/>
);
};
DropdownMenu.Label = MenuLabel;
DropdownMenu.Item = MenuItem;
DropdownMenu.Target = MantineMenu.Target;
DropdownMenu.Dropdown = MenuDropdown;
DropdownMenu.Divider = MenuDivider;

View file

@ -0,0 +1,40 @@
.root {
transition: width 0.3s ease-in-out;
&[data-disabled='true'] {
opacity: 0.6;
}
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.section {
color: var(--theme-colors-foreground-muted);
}
.required {
color: var(--theme-colors-state-error);
}
.label {
margin-bottom: var(--theme-spacing-sm);
font-family: var(--theme-label-font-family);
}

View file

@ -0,0 +1,49 @@
import {
FileInput as MantineFileInput,
FileInputProps as MantineFileInputProps,
} from '@mantine/core';
import { CSSProperties, forwardRef } from 'react';
import styles from './file-input.module.css';
export interface FileInputProps extends MantineFileInputProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const FileInput = forwardRef<HTMLButtonElement, FileInputProps>(
(
{
children,
classNames,
maxWidth,
size = 'sm',
style,
variant = 'default',
width,
...props
},
ref,
) => {
return (
<MantineFileInput
classNames={{
input: styles.input,
label: styles.label,
required: styles.required,
root: styles.root,
section: styles.section,
wrapper: styles.wrapper,
...classNames,
}}
ref={ref}
size={size}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
>
{children}
</MantineFileInput>
);
},
);

View file

@ -0,0 +1,17 @@
import { Flex as MantineFlex, FlexProps as MantineFlexProps } from '@mantine/core';
import { forwardRef } from 'react';
export interface FlexProps extends MantineFlexProps {}
export const Flex = forwardRef<HTMLDivElement, FlexProps>(({ children, ...props }, ref) => {
return (
<MantineFlex
classNames={{ ...props.classNames }}
ref={ref}
style={{ ...props.style }}
{...props}
>
{children}
</MantineFlex>
);
});

View file

@ -0,0 +1,15 @@
import { Grid as MantineGrid, GridProps as MantineGridProps } from '@mantine/core';
export interface GridProps extends MantineGridProps {}
export const Grid = ({ classNames, style, ...props }: GridProps) => {
return (
<MantineGrid
classNames={{ ...classNames }}
style={{ ...style }}
{...props}
/>
);
};
Grid.Col = MantineGrid.Col;

View file

@ -0,0 +1,17 @@
import { Group as MantineGroup, GroupProps as MantineGroupProps } from '@mantine/core';
import { forwardRef } from 'react';
export interface GroupProps extends MantineGroupProps {}
export const Group = forwardRef<HTMLDivElement, GroupProps>(({ children, ...props }, ref) => {
return (
<MantineGroup
classNames={{ ...props.classNames }}
ref={ref}
style={{ ...props.style }}
{...props}
>
{children}
</MantineGroup>
);
});

View file

@ -0,0 +1,7 @@
.dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
}

View file

@ -0,0 +1,25 @@
import {
HoverCard as MantineHoverCard,
HoverCardProps as MantineHoverCardProps,
} from '@mantine/core';
import styles from './hover-card.module.css';
interface HoverCardProps extends MantineHoverCardProps {}
export const HoverCard = ({ children, classNames, ...props }: HoverCardProps) => {
return (
<MantineHoverCard
classNames={{
dropdown: styles.dropdown,
...classNames,
}}
{...props}
>
{children}
</MantineHoverCard>
);
};
HoverCard.Target = MantineHoverCard.Target;
HoverCard.Dropdown = MantineHoverCard.Dropdown;

View file

@ -0,0 +1,123 @@
.size-xs {
font-size: var(--theme-font-size-xs);
}
.size-sm {
font-size: var(--theme-font-size-sm);
}
.size-md {
font-size: var(--theme-font-size-md);
}
.size-lg {
font-size: var(--theme-font-size-lg);
}
.size-xl {
font-size: var(--theme-font-size-xl);
}
.size-2xl {
font-size: var(--theme-font-size-2xl);
}
.size-3xl {
font-size: var(--theme-font-size-3xl);
}
.size-4xl {
font-size: var(--theme-font-size-4xl);
}
.size-5xl {
font-size: var(--theme-font-size-5xl);
}
.color-default {
color: var(--theme-colors-foreground);
}
.color-primary {
color: var(--theme-colors-primary-filled);
}
.color-muted {
color: var(--theme-colors-foreground-muted);
}
.color-success {
color: var(--theme-colors-state-success);
}
.color-error {
color: var(--theme-colors-state-error);
}
.color-info {
color: var(--theme-colors-state-info);
}
.color-warn {
color: var(--theme-colors-state-warn);
}
.fill {
fill: transparent;
}
.fill-default {
fill: var(--theme-colors-foreground);
}
.fill-inherit {
fill: inherit;
}
.fill-primary {
fill: var(--theme-colors-primary-filled);
}
.fill-muted {
fill: var(--theme-colors-foreground-muted);
}
.fill-success {
fill: var(--theme-colors-state-success);
}
.fill-error {
fill: var(--theme-colors-state-error);
}
.fill-info {
fill: var(--theme-colors-state-info);
}
.fill-warn {
fill: var(--theme-colors-state-warn);
}
.spin {
animation: spin 1s linear infinite;
}
.pulse {
animation: pulse 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0% {
opacity: 0.5;
}
}

View file

@ -0,0 +1,269 @@
import clsx from 'clsx';
import { motion } from 'motion/react';
import { type ComponentType, forwardRef } from 'react';
import { IconBaseProps } from 'react-icons';
import { FaLastfmSquare } from 'react-icons/fa';
import {
LuAppWindow,
LuArrowDown,
LuArrowDownToLine,
LuArrowDownWideNarrow,
LuArrowLeft,
LuArrowLeftToLine,
LuArrowRight,
LuArrowRightToLine,
LuArrowUp,
LuArrowUpDown,
LuArrowUpNarrowWide,
LuArrowUpToLine,
LuBookOpen,
LuCheck,
LuChevronDown,
LuChevronLast,
LuChevronLeft,
LuChevronRight,
LuChevronUp,
LuCircleCheck,
LuCircleX,
LuClipboardCopy,
LuClock3,
LuCloudDownload,
LuCornerUpRight,
LuDelete,
LuDisc3,
LuDownload,
LuEllipsis,
LuEllipsisVertical,
LuExternalLink,
LuFlag,
LuFolderOpen,
LuGauge,
LuGithub,
LuGripHorizontal,
LuGripVertical,
LuHardDrive,
LuHash,
LuHeart,
LuHeartCrack,
LuImage,
LuImageOff,
LuInfinity,
LuInfo,
LuKeyboard,
LuLayoutGrid,
LuLibrary,
LuList,
LuListFilter,
LuListMinus,
LuListMusic,
LuListPlus,
LuLoader,
LuLock,
LuLogIn,
LuLogOut,
LuMenu,
LuMinus,
LuMusic,
LuMusic2,
LuPanelRightClose,
LuPanelRightOpen,
LuPause,
LuPencilLine,
LuPlay,
LuPlus,
LuRadio,
LuRotateCw,
LuSave,
LuSearch,
LuSettings2,
LuShare2,
LuShieldAlert,
LuShuffle,
LuSkipBack,
LuSkipForward,
LuSlidersHorizontal,
LuSquare,
LuSquareCheck,
LuSquareMenu,
LuStar,
LuStepBack,
LuStepForward,
LuTable,
LuTriangleAlert,
LuUser,
LuUserPen,
LuUserRoundCog,
LuVolume1,
LuVolume2,
LuVolumeX,
LuX,
} from 'react-icons/lu';
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
import { RiPlayListAddLine, RiRepeat2Line, RiRepeatOneLine } from 'react-icons/ri';
import { SiMusicbrainz } from 'react-icons/si';
import styles from './icon.module.css';
export type AppIconSelection = keyof typeof AppIcon;
export const AppIcon = {
add: LuPlus,
album: LuDisc3,
appWindow: LuAppWindow,
arrowDown: LuArrowDown,
arrowDownS: LuChevronDown,
arrowDownToLine: LuArrowDownToLine,
arrowLeft: LuArrowLeft,
arrowLeftS: LuChevronLeft,
arrowLeftToLine: LuArrowLeftToLine,
arrowRight: LuArrowRight,
arrowRightLast: LuChevronLast,
arrowRightS: LuChevronRight,
arrowRightToLine: LuArrowRightToLine,
arrowUp: LuArrowUp,
arrowUpS: LuChevronUp,
arrowUpToLine: LuArrowUpToLine,
artist: LuUserPen,
brandGitHub: LuGithub,
brandLastfm: FaLastfmSquare,
brandMusicBrainz: SiMusicbrainz,
cache: LuCloudDownload,
check: LuCheck,
clipboardCopy: LuClipboardCopy,
delete: LuDelete,
download: LuDownload,
dragHorizontal: LuGripHorizontal,
dragVertical: LuGripVertical,
dropdown: LuChevronDown,
duration: LuClock3,
edit: LuPencilLine,
ellipsisHorizontal: LuEllipsis,
ellipsisVertical: LuEllipsisVertical,
emptyImage: LuImageOff,
error: LuShieldAlert,
externalLink: LuExternalLink,
favorite: LuHeart,
filter: LuListFilter,
folder: LuFolderOpen,
genre: LuFlag,
hash: LuHash,
home: LuSquareMenu,
image: LuImage,
info: LuInfo,
itemAlbum: LuDisc3,
itemSong: LuMusic,
keyboard: LuKeyboard,
layoutGrid: LuLayoutGrid,
layoutList: LuList,
layoutTable: LuTable,
library: LuLibrary,
list: LuList,
listInfinite: LuInfinity,
listPaginated: LuArrowRightToLine,
lock: LuLock,
mediaNext: LuSkipForward,
mediaPause: LuPause,
mediaPlay: LuPlay,
mediaPlayLast: LuChevronLast,
mediaPlayNext: LuCornerUpRight,
mediaPrevious: LuSkipBack,
mediaRandom: RiPlayListAddLine,
mediaRepeat: RiRepeat2Line,
mediaRepeatOne: RiRepeatOneLine,
mediaSettings: LuSlidersHorizontal,
mediaShuffle: LuShuffle,
mediaSpeed: LuGauge,
mediaStepBackward: LuStepBack,
mediaStepForward: LuStepForward,
mediaStop: LuSquare,
menu: LuMenu,
metadata: LuBookOpen,
minus: LuMinus,
panelRightClose: LuPanelRightClose,
panelRightOpen: LuPanelRightOpen,
playlist: LuListMusic,
playlistAdd: LuListPlus,
playlistDelete: LuListMinus,
plus: LuPlus,
queue: LuList,
radio: LuRadio,
refresh: LuRotateCw,
remove: LuMinus,
save: LuSave,
search: LuSearch,
server: LuHardDrive,
settings: LuSettings2,
share: LuShare2,
signIn: LuLogIn,
signOut: LuLogOut,
sort: LuArrowUpDown,
sortAsc: LuArrowUpNarrowWide,
sortDesc: LuArrowDownWideNarrow,
spinner: LuLoader,
square: LuSquare,
squareCheck: LuSquareCheck,
star: LuStar,
success: LuCircleCheck,
track: LuMusic2,
unfavorite: LuHeartCrack,
user: LuUser,
userManage: LuUserRoundCog,
visibility: MdOutlineVisibility,
visibilityOff: MdOutlineVisibilityOff,
volumeMax: LuVolume2,
volumeMute: LuVolumeX,
volumeNormal: LuVolume1,
warn: LuTriangleAlert,
x: LuX,
xCircle: LuCircleX,
} as const;
export interface IconProps extends Omit<IconBaseProps, 'color' | 'fill' | 'size'> {
animate?: 'pulse' | 'spin';
color?: 'default' | 'error' | 'info' | 'inherit' | 'muted' | 'primary' | 'success' | 'warn';
fill?: 'default' | 'error' | 'info' | 'inherit' | 'muted' | 'primary' | 'success' | 'warn';
icon: keyof typeof AppIcon;
size?: '2xl' | '3xl' | '4xl' | '5xl' | 'lg' | 'md' | 'sm' | 'xl' | 'xs' | number | string;
}
export const Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
const { animate, className, color, fill, icon, size = 'md' } = props;
const IconComponent: ComponentType<any> = 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,
});
return (
<IconComponent
className={classNames}
fill={fill}
ref={ref}
size={isPredefinedSize(size) ? undefined : size}
/>
);
});
Icon.displayName = 'Icon';
export const MotionIcon: ComponentType = motion.create(Icon);
function isPredefinedSize(size: IconProps['size']) {
return (
size === '2xl' ||
size === '3xl' ||
size === '4xl' ||
size === '5xl' ||
size === 'lg' ||
size === 'md' ||
size === 'sm' ||
size === 'xl' ||
size === 'xs'
);
}

View file

@ -0,0 +1,34 @@
.image {
width: 100%;
height: 100%;
object-fit: var(--theme-image-fit);
border-radius: var(--theme-radius-md);
}
.loader {
width: 100%;
height: 100%;
}
.image-container {
display: flex;
width: 100%;
max-height: 100%;
aspect-ratio: 1 / 1;
}
.unloader {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
max-height: 100%;
background-color: darken(var(--theme-colors-foreground), 40%);
opacity: 0.3;
}
.skeleton {
width: 100%;
height: 100%;
}

View file

@ -0,0 +1,93 @@
import type { ImgHTMLAttributes } from 'react';
import clsx from 'clsx';
import { Img } from 'react-image';
import styles from './image.module.css';
import { Icon } from '/@/shared/components/icon/icon';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
interface ImageContainerProps {
children: React.ReactNode;
className?: string;
}
interface ImageLoaderProps {
className?: string;
}
interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
containerClassName?: string;
includeLoader?: boolean;
includeUnloader?: boolean;
src: string | string[] | undefined;
thumbHash?: string;
}
interface ImageUnloaderProps {
className?: string;
}
export function Image({
className,
containerClassName,
includeLoader = true,
includeUnloader = true,
src,
}: ImageProps) {
if (src) {
return (
<Img
className={clsx(styles.image, className)}
container={(children) => (
<ImageContainer className={containerClassName}>{children}</ImageContainer>
)}
loader={
includeLoader ? (
<ImageContainer className={containerClassName}>
<ImageLoader className={className} />
</ImageContainer>
) : null
}
loading="eager"
src={src}
unloader={
includeUnloader ? (
<ImageContainer className={containerClassName}>
<ImageUnloader className={className} />
</ImageContainer>
) : null
}
/>
);
}
return <ImageUnloader />;
}
function ImageContainer({ children, className }: ImageContainerProps) {
return <div className={clsx(styles.imageContainer, className)}>{children}</div>;
}
function ImageLoader({ className }: ImageLoaderProps) {
return (
<div className={clsx(styles.loader, className)}>
<Skeleton
className={clsx(styles.skeleton, className)}
enableAnimation={true}
/>
</div>
);
}
function ImageUnloader({ className }: ImageUnloaderProps) {
return (
<div className={clsx(styles.unloader, className)}>
<Icon
icon="emptyImage"
size="xl"
/>
</div>
);
}

View file

@ -0,0 +1,39 @@
.root {
transition: width 0.3s ease-in-out;
&[data-disabled='true'] {
opacity: 0.6;
}
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.section {
color: var(--theme-colors-foreground-muted);
}
.required {
color: var(--theme-colors-state-error);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}

View file

@ -0,0 +1,49 @@
import {
JsonInput as MantineJsonInput,
JsonInputProps as MantineJsonInputProps,
} from '@mantine/core';
import { CSSProperties, forwardRef } from 'react';
import styles from './json-input.module.css';
export interface JsonInputProps extends MantineJsonInputProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>(
(
{
children,
classNames,
maxWidth,
size = 'sm',
style,
variant = 'default',
width,
...props
},
ref,
) => {
return (
<MantineJsonInput
classNames={{
input: styles.input,
label: styles.label,
required: styles.required,
root: styles.root,
section: styles.section,
wrapper: styles.wrapper,
...classNames,
}}
ref={ref}
size={size}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
>
{children}
</MantineJsonInput>
);
},
);

View file

@ -0,0 +1,7 @@
import { Kbd as MantineKbd, KbdProps as MantineKbdProps } from '@mantine/core';
export interface KbdProps extends MantineKbdProps {}
export const Kbd = (props: KbdProps) => {
return <MantineKbd {...props} />;
};

View file

@ -0,0 +1,17 @@
.title {
font-size: var(--theme-font-size-lg);
font-weight: 700;
}
.body {
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
}
.header {
background: var(--theme-colors-background);
border-bottom: none;
}
.content {
background: var(--theme-colors-background);
}

View file

@ -0,0 +1,145 @@
import { Modal as MantineModal, ModalProps as MantineModalProps } from '@mantine/core';
import { closeAllModals, ContextModalProps } from '@mantine/modals';
import {
ModalsProvider as MantineModalsProvider,
ModalsProviderProps as MantineModalsProviderProps,
} from '@mantine/modals';
import React, { ReactNode } from 'react';
import styles from './modal.module.css';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
children?: ReactNode;
handlers: {
close: () => void;
open: () => void;
toggle: () => void;
};
}
export const Modal = ({ children, classNames, handlers, ...rest }: ModalProps) => {
return (
<MantineModal
{...rest}
classNames={{
body: styles.body,
content: styles.content,
header: styles.header,
root: styles.root,
title: styles.title,
...classNames,
}}
onClose={handlers.close}
radius="lg"
transitionProps={{
duration: 300,
exitDuration: 300,
transition: 'fade',
}}
>
{children}
</MantineModal>
);
};
export type ContextModalVars = {
context: ContextModalProps['context'];
id: ContextModalProps['id'];
};
export const BaseContextModal = ({
context,
id,
innerProps,
}: ContextModalProps<{
modalBody: (vars: ContextModalVars) => React.ReactNode;
}>) => <>{innerProps.modalBody({ context, id })}</>;
interface ConfirmModalProps {
children: ReactNode;
disabled?: boolean;
labels?: {
cancel?: string;
confirm?: string;
};
loading?: boolean;
onCancel?: () => void;
onConfirm: () => void;
}
export const ConfirmModal = ({
children,
disabled,
labels,
loading,
onCancel,
onConfirm,
}: ConfirmModalProps) => {
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
closeAllModals();
}
};
return (
<Stack>
<Flex>{children}</Flex>
<Group justify="flex-end">
<Button
data-focus
onClick={handleCancel}
variant="default"
>
{labels?.cancel ? labels.cancel : 'Cancel'}
</Button>
<Button
disabled={disabled}
loading={loading}
onClick={onConfirm}
variant="filled"
>
{labels?.confirm ? labels.confirm : 'Confirm'}
</Button>
</Group>
</Stack>
);
};
export interface ModalsProviderProps extends MantineModalsProviderProps {}
export const ModalsProvider = ({ children, ...rest }: ModalsProviderProps) => {
return (
<MantineModalsProvider
modalProps={{
centered: true,
classNames: {
body: styles.body,
content: styles.content,
header: styles.header,
root: styles.root,
title: styles.title,
},
closeButtonProps: {
icon: <Icon icon="x" />,
},
radius: 'lg',
transitionProps: {
duration: 300,
exitDuration: 300,
transition: 'fade',
},
}}
{...rest}
>
{children}
</MantineModalsProvider>
);
};

View file

@ -0,0 +1,59 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.label {
margin-bottom: var(--theme-spacing-sm);
}
.dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.option {
position: relative;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
&[data-checked='true'] {
&::before {
position: absolute;
top: 50%;
left: 2px;
width: 4px;
height: 50%;
content: '';
background-color: var(--theme-colors-primary-filled);
border-radius: var(--theme-border-radius-xl);
transform: translateY(-50%);
}
}
}
.option:hover {
background: lighten(var(--theme-colors-surface), 5%);
}

View file

@ -0,0 +1,36 @@
import {
MultiSelect as MantineMultiSelect,
MultiSelectProps as MantineMultiSelectProps,
} from '@mantine/core';
import { CSSProperties } from 'react';
import styles from './multi-select.module.css';
export interface MultiSelectProps extends MantineMultiSelectProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const MultiSelect = ({
classNames,
maxWidth,
variant = 'default',
width,
...props
}: MultiSelectProps) => {
return (
<MantineMultiSelect
classNames={{
dropdown: styles.dropdown,
input: styles.input,
option: styles.option,
root: styles.root,
...classNames,
}}
style={{ maxWidth, width }}
variant={variant}
withCheckIcon={false}
{...props}
/>
);
};

View file

@ -0,0 +1,46 @@
.root {
transition: width 0.3s ease-in-out;
&[data-disabled='true'] {
opacity: 0.6;
}
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.control {
svg {
color: var(--theme-btn-default-fg);
fill: var(--theme-btn-default-fg);
}
}
.section {
color: var(--theme-colors-foreground-muted);
}
.required {
color: var(--theme-colors-state-error);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}

View file

@ -0,0 +1,51 @@
import {
NumberInput as MantineNumberInput,
NumberInputProps as MantineNumberInputProps,
} from '@mantine/core';
import { CSSProperties, forwardRef } from 'react';
import styles from './number-input.module.css';
export interface NumberInputProps extends MantineNumberInputProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
(
{
children,
classNames,
maxWidth,
size = 'sm',
style,
variant = 'default',
width,
...props
}: NumberInputProps,
ref,
) => {
return (
<MantineNumberInput
classNames={{
control: styles.control,
input: styles.input,
label: styles.label,
required: styles.required,
root: styles.root,
section: styles.section,
wrapper: styles.wrapper,
...classNames,
}}
hideControls
ref={ref}
size={size}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
>
{children}
</MantineNumberInput>
);
},
);

View file

@ -0,0 +1,3 @@
.root {
padding: var(--theme-spacing-sm);
}

View file

@ -0,0 +1,42 @@
import { ReactNode } from 'react';
import styles from './option.module.css';
import { Flex } from '/@/shared/components/flex/flex';
import { Group, GroupProps } from '/@/shared/components/group/group';
import { Text } from '/@/shared/components/text/text';
interface OptionProps extends GroupProps {
children: ReactNode;
}
export const Option = ({ children, ...props }: OptionProps) => {
return (
<Group
classNames={{ root: styles.root }}
grow
{...props}
>
{children}
</Group>
);
};
interface LabelProps {
children: ReactNode;
}
const Label = ({ children }: LabelProps) => {
return <Text>{children}</Text>;
};
interface ControlProps {
children: ReactNode;
}
const Control = ({ children }: ControlProps) => {
return <Flex justify="flex-end">{children}</Flex>;
};
Option.Label = Label;
Option.Control = Control;

View file

@ -0,0 +1,31 @@
.control {
color: var(--theme-btn-default-fg);
background-color: var(--theme-btn-default-bg);
border: none;
transition:
background 0.2s ease-in-out,
color 0.2s ease-in-out;
&[data-active] {
color: var(--theme-btn-primary-fg);
background-color: var(--theme-btn-primary-bg);
}
&[data-dots] {
background-color: transparent;
}
&:hover {
color: var(--theme-btn-default-fg-hover);
background-color: var(--theme-btn-default-bg-hover);
&[data-active] {
color: var(--theme-btn-primary-fg-hover);
background-color: var(--theme-btn-primary-bg-hover);
}
&[data-dots] {
background-color: transparent;
}
}
}

View file

@ -0,0 +1,24 @@
import {
Pagination as MantinePagination,
PaginationProps as MantinePaginationProps,
} from '@mantine/core';
import styles from './pagination.module.css';
interface PaginationProps extends MantinePaginationProps {}
export const Pagination = ({ classNames, style, ...props }: PaginationProps) => {
return (
<MantinePagination
classNames={{
control: styles.control,
...classNames,
}}
radius="xl"
style={{
...style,
}}
{...props}
/>
);
};

View file

@ -0,0 +1,4 @@
.root {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}

View file

@ -0,0 +1,27 @@
import type { PaperProps as MantinePaperProps } from '@mantine/core';
import { Paper as MantinePaper } from '@mantine/core';
import { ReactNode } from 'react';
import styles from './paper.module.css';
export interface PaperProps extends MantinePaperProps {
children?: ReactNode;
}
export const Paper = ({ children, classNames, style, ...props }: PaperProps) => {
return (
<MantinePaper
classNames={{
root: styles.root,
...classNames,
}}
style={{
...style,
}}
{...props}
>
{children}
</MantinePaper>
);
};

View file

@ -0,0 +1,39 @@
.root {
&[data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.section {
color: var(--theme-colors-foreground-muted);
}
.required {
color: var(--theme-colors-state-error);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}

View file

@ -0,0 +1,35 @@
import {
PasswordInput as MantinePasswordInput,
PasswordInputProps as MantinePasswordInputProps,
} from '@mantine/core';
import { CSSProperties, forwardRef } from 'react';
import styles from './password-input.module.css';
export interface PasswordInputProps extends MantinePasswordInputProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ children, classNames, maxWidth, style, variant = 'default', width, ...props }, ref) => {
return (
<MantinePasswordInput
classNames={{
input: styles.input,
label: styles.label,
required: styles.required,
root: styles.root,
section: styles.section,
...classNames,
}}
ref={ref}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
>
{children}
</MantinePasswordInput>
);
},
);

View file

@ -0,0 +1,7 @@
.dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
}

View file

@ -0,0 +1,29 @@
import type {
PopoverDropdownProps as MantinePopoverDropdownProps,
PopoverProps as MantinePopoverProps,
} from '@mantine/core';
import { Popover as MantinePopover } from '@mantine/core';
import styles from './popover.module.css';
export interface PopoverDropdownProps extends MantinePopoverDropdownProps {}
export interface PopoverProps extends MantinePopoverProps {}
export const Popover = ({ children, ...props }: PopoverProps) => {
return (
<MantinePopover
classNames={{
dropdown: styles.dropdown,
}}
transitionProps={{ transition: 'fade' }}
withinPortal
{...props}
>
{children}
</MantinePopover>
);
};
Popover.Target = MantinePopover.Target;
Popover.Dropdown = MantinePopover.Dropdown;

View file

@ -0,0 +1,7 @@
import { Portal as MantinePortal, PortalProps as MantinePortalProps } from '@mantine/core';
export interface PortalProps extends MantinePortalProps {}
export const Portal = ({ children, ...props }: PortalProps) => {
return <MantinePortal {...props}>{children}</MantinePortal>;
};

View file

@ -0,0 +1,5 @@
.symbol-body {
svg {
stroke: var(--theme-colors-foreground-muted);
}
}

View file

@ -0,0 +1,44 @@
import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useCallback } from 'react';
import styles from './rating.module.css';
interface RatingProps extends MantineRatingProps {}
export const Rating = ({ classNames, onChange, style, ...props }: RatingProps) => {
const valueChange = useCallback(
(rating: number) => {
if (onChange) {
if (rating === props.value) {
onChange(0);
} else {
onChange(rating);
}
}
},
[onChange, props.value],
);
const debouncedOnChange = debounce(valueChange, 100);
return (
<MantineRating
classNames={{
symbolBody: styles.symbolBody,
...classNames,
}}
style={{
...style,
}}
{...props}
onChange={(e) => {
debouncedOnChange(e);
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
);
};

View file

@ -0,0 +1,11 @@
.feishin-os-scrollbar {
--os-size: var(--theme-scrollbar-size);
--os-track-bg: var(--theme-scrollbar-track-background);
--os-track-bg-hover: var(--theme-scrollbar-track-hover-background);
--os-track-border-radius: var(--theme-scrollbar-track-border-radius);
--os-handle-bg: var(--theme-scrollbar-handle-background);
--os-handle-bg-hover: var(--theme-scrollbar-handle-hover-background);
--os-handle-bg-active: var(--theme-scrollbar-handle-active-background);
--os-handle-border-radius: var(--theme-scrollbar-handle-border-radius);
--os-handle-max-size: 200px;
}

View file

@ -0,0 +1,35 @@
/* .thumb {
background: var(--theme-scrollbar-handle-background);
border-radius: var(--theme-scrollbar-handle-border-radius);
&:hover {
background: var(--theme-scrollbar-handle-hover-background);
}
&[data-state='visible'] {
animation: fade-in 0.3s forwards;
}
&[data-state='hidden'] {
animation: fade-out 0.2s forwards;
}
}
.scrollbar {
padding: 0;
background: var(--theme-scrollbar-track-background);
border-radius: var(--theme-scrollbar-track-border-radius);
&:hover {
background: var(--theme-scrollbar-track-hover-background);
}
}
.viewport > div {
display: block !important;
} */
.scroll-area {
width: 100%;
height: 100%;
}

View file

@ -0,0 +1,78 @@
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { useMergedRef } from '@mantine/hooks';
import clsx from 'clsx';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { forwardRef, Ref, useEffect, useRef, useState } from 'react';
import styles from './scroll-area.module.css';
import './scroll-area.css';
import { DragData, DragTarget } from '/@/shared/types/drag-and-drop';
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<'div'> {
allowDragScroll?: boolean;
debugScrollPosition?: boolean;
scrollHideDelay?: number;
}
export const ScrollArea = forwardRef((props: ScrollAreaProps, ref: Ref<HTMLDivElement>) => {
const { allowDragScroll, children, className, scrollHideDelay, ...htmlProps } = props;
const containerRef = useRef(null);
const [scroller, setScroller] = useState<HTMLElement | null | Window>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: false,
options: {
overflow: { x: 'hidden', y: 'scroll' },
scrollbars: {
autoHide: 'leave',
autoHideDelay: scrollHideDelay || 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
visibility: 'visible',
},
},
});
useEffect(() => {
const { current: root } = containerRef;
if (scroller && root) {
initialize({
elements: { viewport: scroller as HTMLElement },
target: root,
});
if (allowDragScroll) {
autoScrollForElements({
canScroll: (args) => {
const data = args.source.data as unknown as DragData<unknown>;
if (data.type === DragTarget.TABLE_COLUMN) return false;
return true;
},
element: scroller as HTMLElement,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'standard' }),
});
}
}
return () => osInstance()?.destroy();
}, [allowDragScroll, initialize, osInstance, scroller]);
const mergedRef = useMergedRef(ref, containerRef);
return (
<div
className={clsx(styles.scrollArea, className)}
ref={(el) => {
setScroller(el);
mergedRef(el);
}}
{...htmlProps}
>
{children}
</div>
);
});

View file

@ -0,0 +1,3 @@
.root {
background: var(--theme-colors-surface);
}

View file

@ -0,0 +1,29 @@
import type { SegmentedControlProps as MantineSegmentedControlProps } from '@mantine/core';
import { SegmentedControl as MantineSegmentedControl } from '@mantine/core';
import { forwardRef } from 'react';
import styles from './segmented-control.module.css';
type SegmentedControlProps = MantineSegmentedControlProps;
export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
({ classNames, size = 'sm', ...props }: SegmentedControlProps, ref) => {
return (
<MantineSegmentedControl
classNames={{
control: styles.control,
indicator: styles.indicator,
label: styles.label,
root: styles.root,
...classNames,
}}
ref={ref}
size={size}
transitionDuration={250}
transitionTimingFunction="linear"
{...props}
/>
);
},
);

View file

@ -0,0 +1,59 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}
.dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
}
.option {
position: relative;
padding: var(--theme-spacing-sm) var(--theme-spacing-lg);
&[data-checked='true'] {
&::before {
position: absolute;
top: 50%;
left: 2px;
width: 4px;
height: 50%;
content: '';
background-color: var(--theme-colors-primary-filled);
border-radius: var(--theme-border-radius-xl);
transform: translateY(-50%);
}
}
}
.option:hover {
background: lighten(var(--theme-colors-surface), 5%);
}

View file

@ -0,0 +1,36 @@
import type { SelectProps as MantineSelectProps } from '@mantine/core';
import { Select as MantineSelect } from '@mantine/core';
import { CSSProperties } from 'react';
import styles from './select.module.css';
export interface SelectProps extends MantineSelectProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const Select = ({
classNames,
maxWidth,
variant = 'default',
width,
...props
}: SelectProps) => {
return (
<MantineSelect
classNames={{
dropdown: styles.dropdown,
input: styles.input,
label: styles.label,
option: styles.option,
root: styles.root,
...classNames,
}}
style={{ maxWidth, width }}
variant={variant}
withCheckIcon={false}
{...props}
/>
);
};

View file

@ -0,0 +1,5 @@
import { SEPARATOR_STRING } from '/@/shared/api/utils';
export const Separator = () => {
return <>{SEPARATOR_STRING}</>;
};

View file

@ -0,0 +1,27 @@
.skeleton {
@mixin dark {
--base-color: lighten(var(--theme-colors-surface), 10%) !important;
--highlight-color: lighten(var(--theme-colors-surface), 15%) !important;
}
@mixin light {
--base-color: var(--theme-colors-foreground-muted) !important;
--highlight-color: darken(var(--theme-colors-foreground-muted), 40%) !important;
}
--animation-duration: 1.5s !important;
width: 100%;
height: 100%;
}
.skeleton-container {
display: flex;
align-items: center;
width: 100%;
height: 100%;
}
.centered {
justify-content: center;
}

View file

@ -0,0 +1,56 @@
import type { CSSProperties } from 'react';
import clsx from 'clsx';
import RSkeleton from 'react-loading-skeleton';
import styles from './skeleton.module.css';
import 'react-loading-skeleton/dist/skeleton.css';
interface SkeletonProps {
baseColor?: string;
borderRadius?: string;
className?: string;
containerClassName?: string;
count?: number;
direction?: 'ltr' | 'rtl';
enableAnimation?: boolean;
height?: number | string;
inline?: boolean;
isCentered?: boolean;
style?: CSSProperties;
width?: number | string;
}
export function Skeleton({
baseColor,
borderRadius,
className,
containerClassName,
count,
direction,
enableAnimation = true,
height,
inline,
isCentered,
style,
width,
}: SkeletonProps) {
return (
<RSkeleton
baseColor={baseColor}
borderRadius={borderRadius}
className={clsx(styles.skeleton, className)}
containerClassName={clsx(styles.skeletonContainer, containerClassName, {
[styles.centered]: isCentered,
})}
count={count}
direction={direction}
enableAnimation={enableAnimation}
height={height}
inline={inline}
style={style}
width={width}
/>
);
}

View file

@ -0,0 +1,17 @@
.track {
height: 0.5rem;
}
.thumb {
background: var(--theme-colors-foreground);
}
.label {
max-width: 200px;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
font-size: var(--theme-font-size-md);
font-weight: 550;
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%);
}

View file

@ -0,0 +1,25 @@
import type { SliderProps as MantineSliderProps } from '@mantine/core';
import { Slider as MantineSlider } from '@mantine/core';
import styles from './slider.module.css';
export interface SliderProps extends MantineSliderProps {}
export const Slider = ({ classNames, style, ...props }: SliderProps) => {
return (
<MantineSlider
classNames={{
bar: styles.bar,
label: styles.label,
thumb: styles.thumb,
track: styles.track,
...classNames,
}}
style={{
...style,
}}
{...props}
/>
);
};

View file

@ -0,0 +1,18 @@
.container {
width: 100%;
height: 100%;
}
.icon {
animation: rotating 1s ease-in-out infinite;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,35 @@
import { Center } from '@mantine/core';
import { IconBaseProps } from 'react-icons';
import { RiLoader5Fill } from 'react-icons/ri';
import styles from './spinner.module.css';
interface SpinnerProps extends IconBaseProps {
color?: string;
container?: boolean;
size?: number;
}
export const SpinnerIcon = RiLoader5Fill;
export const Spinner = ({ ...props }: SpinnerProps) => {
if (props.container) {
return (
<Center className={styles.container}>
<SpinnerIcon
className={styles.icon}
color={props.color}
size={props.size}
/>
</Center>
);
}
return (
<SpinnerIcon
className={styles.icon}
color={props.color}
size={props.size}
/>
);
};

View file

@ -0,0 +1,31 @@
.control:hover {
color: var(--theme-btn-subtle-fg-hover);
text-decoration: none;
}
.spoiler {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
text-align: justify;
}
.spoiler:not(.is-expanded).can-expand::after {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
content: '';
background: linear-gradient(to top, var(--theme-colors-background) 10%, transparent 60%);
}
.spoiler.can-expand {
cursor: pointer;
}
.spoiler.is-expanded {
max-height: 2500px !important;
}

View file

@ -0,0 +1,42 @@
import clsx from 'clsx';
import { HTMLAttributes, ReactNode, useRef, useState } from 'react';
import styles from './spoiler.module.css';
import { Text } from '/@/shared/components/text/text';
import { useIsOverflow } from '/@/shared/hooks/use-is-overflow';
interface SpoilerProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
defaultOpened?: boolean;
maxHeight?: number;
}
export const Spoiler = ({ children, defaultOpened, maxHeight, ...props }: SpoilerProps) => {
const ref = useRef(null);
const isOverflow = useIsOverflow(ref);
const [isExpanded, setIsExpanded] = useState(!!defaultOpened);
const spoilerClassNames = clsx(styles.spoiler, {
[styles.canExpand]: isOverflow,
[styles.isExpanded]: isExpanded,
});
const handleToggleExpand = () => {
setIsExpanded((val) => !val);
};
return (
<Text
className={spoilerClassNames}
onClick={handleToggleExpand}
ref={ref}
role="button"
style={{ maxHeight: maxHeight ?? '100px', whiteSpace: 'pre-wrap' }}
tabIndex={-1}
{...props}
>
{children}
</Text>
);
};

View file

@ -0,0 +1,17 @@
import { Stack as MantineStack, StackProps as MantineStackProps } from '@mantine/core';
import { forwardRef } from 'react';
export interface StackProps extends MantineStackProps {}
export const Stack = forwardRef<HTMLDivElement, StackProps>(({ children, ...props }, ref) => {
return (
<MantineStack
classNames={{ ...props.classNames }}
ref={ref}
style={{ ...props.style }}
{...props}
>
{children}
</MantineStack>
);
});

View file

@ -0,0 +1,16 @@
.thumb {
background: var(--theme-colors-foreground);
}
.track {
background-color: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
input:checked + & {
background-color: var(--theme-colors-primary-filled);
& > .thumb {
background: var(--theme-colors-primary-contrast);
}
}
}

View file

@ -0,0 +1,23 @@
import type { SwitchProps as MantineSwitchProps } from '@mantine/core';
import { Switch as MantineSwitch } from '@mantine/core';
import styles from './switch.module.css';
type SwitchProps = MantineSwitchProps;
export const Switch = ({ classNames, ...props }: SwitchProps) => {
return (
<MantineSwitch
classNames={{
input: styles.input,
root: styles.root,
thumb: styles.thumb,
track: styles.track,
...classNames,
}}
withThumbIndicator={false}
{...props}
/>
);
};

View file

@ -0,0 +1,7 @@
.td {
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
}
.th {
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
}

View file

@ -0,0 +1,24 @@
import { Table as MantineTable, TableProps as MantineTableProps } from '@mantine/core';
import styles from './table.module.css';
export interface TableProps extends MantineTableProps {}
export const Table = ({ classNames, ...props }: TableProps) => {
return (
<MantineTable
classNames={{
td: styles.td,
th: styles.th,
...classNames,
}}
{...props}
/>
);
};
Table.Thead = MantineTable.Thead;
Table.Tr = MantineTable.Tr;
Table.Td = MantineTable.Td;
Table.Th = MantineTable.Th;
Table.Tbody = MantineTable.Tbody;

View file

@ -0,0 +1,37 @@
.root {
height: 100%;
}
.list {
padding-right: var(--theme-spacing-md);
&::before {
border: 1px solid var(--theme-colors-border);
}
}
.tab {
padding: var(--theme-spacing-md);
font-weight: 500;
color: var(--theme-btn-subtle-fg);
transition: color 0.2s ease-in-out;
&:hover {
color: var(--theme-btn-subtle-fg-hover);
background: var(--theme-btn-subtle-bg-hover);
}
}
.panel {
padding: var(--theme-spacing-lg) var(--theme-spacing-sm);
}
.tab[data-active] {
color: var(--theme-btn-subtle-fg);
background: none;
border-color: var(--theme-colors-primary-filled);
&:hover {
background: none;
}
}

View file

@ -0,0 +1,34 @@
import { Tabs as MantineTabs, TabsProps as MantineTabsProps, TabsPanelProps } from '@mantine/core';
import { Suspense } from 'react';
import styles from './tabs.module.css';
type TabsProps = MantineTabsProps;
export const Tabs = ({ children, ...props }: TabsProps) => {
return (
<MantineTabs
classNames={{
list: styles.list,
panel: styles.panel,
root: styles.root,
tab: styles.tab,
}}
{...props}
>
{children}
</MantineTabs>
);
};
const Panel = ({ children, ...props }: TabsPanelProps) => {
return (
<MantineTabs.Panel {...props}>
<Suspense fallback={<></>}>{children}</Suspense>
</MantineTabs.Panel>
);
};
Tabs.List = MantineTabs.List;
Tabs.Panel = Panel;
Tabs.Tab = MantineTabs.Tab;

View file

@ -0,0 +1,39 @@
.root {
transition: width 0.3s ease-in-out;
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}
.section {
color: var(--theme-colors-foreground-muted);
}
.required {
color: var(--theme-colors-state-error);
}
.disabled {
opacity: 0.6;
}

View file

@ -0,0 +1,50 @@
import {
TextInput as MantineTextInput,
TextInputProps as MantineTextInputProps,
} from '@mantine/core';
import { CSSProperties, forwardRef } from 'react';
import styles from './text-input.module.css';
export interface TextInputProps extends MantineTextInputProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
(
{
children,
classNames,
maxWidth,
size = 'sm',
style,
variant = 'default',
width,
...props
}: TextInputProps,
ref,
) => {
return (
<MantineTextInput
classNames={{
input: styles.input,
label: styles.label,
required: styles.required,
root: styles.root,
section: styles.section,
wrapper: styles.wrapper,
...classNames,
}}
ref={ref}
size={size}
spellCheck={false}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
>
{children}
</MantineTextInput>
);
},
);

View file

@ -0,0 +1,27 @@
.root {
color: var(--theme-colors-foreground);
transition: color 0.2s ease-in-out;
}
.muted {
color: var(--theme-colors-foreground-muted);
}
.link {
cursor: pointer;
}
.link:hover {
color: var(--theme-colors-foreground);
text-decoration: underline;
}
.no-select {
user-select: none;
}
.overflow-hidden {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -0,0 +1,49 @@
import type { TitleProps as MantineTitleProps } from '@mantine/core';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { createPolymorphicComponent, Title as MantineHeader } from '@mantine/core';
import clsx from 'clsx';
import styles from './text-title.module.css';
type MantineTextTitleDivProps = ComponentPropsWithoutRef<'div'> & MantineTitleProps;
interface TextTitleProps extends MantineTextTitleDivProps {
children?: ReactNode;
isLink?: boolean;
isMuted?: boolean;
isNoSelect?: boolean;
overflow?: 'hidden' | 'visible';
to?: string;
weight?: number;
}
const _TextTitle = ({
children,
className,
isLink,
isMuted,
isNoSelect,
overflow,
...rest
}: TextTitleProps) => {
return (
<MantineHeader
className={clsx(
styles.root,
{
[styles.link]: isLink,
[styles.muted]: isMuted,
[styles.noSelect]: isNoSelect,
[styles.overflowHidden]: overflow === 'hidden' && !rest.lineClamp,
},
className,
)}
{...rest}
>
{children}
</MantineHeader>
);
};
export const TextTitle = createPolymorphicComponent<'div', TextTitleProps>(_TextTitle);

View file

@ -0,0 +1,28 @@
.root {
font-family: var(--font-family);
color: var(--theme-colors-foreground);
user-select: auto;
}
.root.muted {
color: var(--theme-colors-foreground-muted);
}
.root.link {
cursor: pointer;
}
.root.link:hover {
color: var(--theme-colors-foreground);
text-decoration: underline;
}
.root.overflow-hidden {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.root.no-select {
user-select: none;
}

View file

@ -0,0 +1,54 @@
import { Text as MantineText, TextProps as MantineTextProps } from '@mantine/core';
import clsx from 'clsx';
import { ComponentPropsWithoutRef, ReactNode } from 'react';
import styles from './text.module.css';
import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';
export interface TextProps extends MantineTextDivProps {
children?: ReactNode;
font?: Font;
isLink?: boolean;
isMuted?: boolean;
isNoSelect?: boolean;
overflow?: 'hidden' | 'visible';
to?: string;
weight?: number;
}
type Font = 'Epilogue' | 'Gotham' | 'Inter' | 'Poppins';
type MantineTextDivProps = ComponentPropsWithoutRef<'div'> & MantineTextProps;
export const _Text = ({
children,
font,
isLink,
isMuted,
isNoSelect,
overflow,
...rest
}: TextProps) => {
return (
<MantineText
className={clsx(styles.root, {
[styles.link]: isLink,
[styles.muted]: isMuted,
[styles.noSelect]: isNoSelect,
[styles.overflowHidden]: overflow === 'hidden',
})}
component="div"
style={
{
'--font-family': font,
} as React.CSSProperties
}
{...rest}
>
{children}
</MantineText>
);
};
export const Text = createPolymorphicComponent<'div', TextProps>(_Text);

View file

@ -0,0 +1,22 @@
.root {
transition: width 0.3s ease-in-out;
&[data-disabled='true'] {
opacity: 0.6;
}
}
.input {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid transparent;
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}

View file

@ -0,0 +1,31 @@
import { Textarea as MantineTextarea, TextareaProps as MantineTextareaProps } from '@mantine/core';
import { CSSProperties, forwardRef } from 'react';
import styles from './textarea.module.css';
export interface TextareaProps extends MantineTextareaProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ children, classNames, maxWidth, style, width, ...props }: TextareaProps, ref) => {
return (
<MantineTextarea
classNames={{
input: styles.input,
label: styles.label,
required: styles.required,
root: styles.root,
wrapper: styles.wrapper,
...classNames,
}}
ref={ref}
style={{ maxWidth, width, ...style }}
{...props}
>
{children}
</MantineTextarea>
);
},
);

View file

@ -0,0 +1,40 @@
.root {
bottom: 90px;
background-color: var(--theme-colors-surface);
}
.root.error {
--notification-color: var(--theme-colors-state-error);
}
.root.info {
--notification-color: var(--theme-colors-state-info);
}
.root.success {
--notification-color: var(--theme-colors-state-success);
}
.root.warning {
--notification-color: var(--theme-colors-state-warning);
}
.title {
font-size: var(--theme-font-size-md);
}
.body {
padding: var(--theme-spacing-md);
}
.loader {
margin: var(--theme-spacing-md);
}
.description {
font-size: var(--theme-font-size-md);
}
.close-button {
background-color: var(--theme-colors-surface);
}

View file

@ -0,0 +1,61 @@
import type { NotificationsProps as MantineNotificationProps } from '@mantine/notifications';
import {
cleanNotifications,
cleanNotificationsQueue,
hideNotification,
notifications,
updateNotification,
} from '@mantine/notifications';
import clsx from 'clsx';
import styles from './toast.module.css';
interface NotificationProps extends MantineNotificationProps {
message?: string;
onClose?: () => void;
type?: 'error' | 'info' | 'success' | 'warning';
}
const getTitle = (type: NotificationProps['type']) => {
if (type === 'success') return 'Success';
if (type === 'warning') return 'Warning';
if (type === 'error') return 'Error';
return 'Info';
};
const showToast = ({ message, onClose, type, ...props }: NotificationProps) => {
return notifications.show({
autoClose: props.autoClose,
classNames: {
body: styles.body,
closeButton: styles.closeButton,
description: styles.description,
loader: styles.loader,
root: clsx(styles.root, {
[styles.error]: type === 'error',
[styles.info]: type === 'info',
[styles.success]: type === 'success',
[styles.warning]: type === 'warning',
}),
title: styles.title,
},
message: message ?? '',
onClose,
title: getTitle(type),
withBorder: true,
withCloseButton: true,
});
};
export const toast = {
clean: cleanNotifications,
cleanQueue: cleanNotificationsQueue,
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
hide: hideNotification,
info: (props: NotificationProps) => showToast({ type: 'info', ...props }),
show: showToast,
success: (props: NotificationProps) => showToast({ type: 'success', ...props }),
update: updateNotification,
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
};

View file

@ -0,0 +1,9 @@
.tooltip {
max-width: 200px;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
font-size: var(--theme-font-size-md);
font-weight: 550;
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%);
}

View file

@ -0,0 +1,31 @@
import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core';
import styles from './tooltip.module.css';
export interface TooltipProps extends MantineTooltipProps {}
export const Tooltip = ({
children,
openDelay = 500,
transitionProps = {
duration: 250,
transition: 'fade',
},
withinPortal = true,
...props
}: TooltipProps) => {
return (
<MantineTooltip
classNames={{
tooltip: styles.tooltip,
}}
multiline
openDelay={openDelay}
transitionProps={transitionProps}
withinPortal={withinPortal}
{...props}
>
{children}
</MantineTooltip>
);
};