From 92e3e6876c37bb7038dbc8db0f468f6d90d4e538 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:07:29 +0000 Subject: [PATCH 01/16] Initial plan From 92c6f72cea8db067bb9147e43f707d0b02745304 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:12:36 +0000 Subject: [PATCH 02/16] refactor: replace MUI runtime with local shadcn-style adapters --- components/mui-adapter/lab.tsx | 59 ++ components/mui-adapter/lab/Masonry.tsx | 19 + components/mui-adapter/material-nextjs.tsx | 11 + components/mui-adapter/material.tsx | 649 ++++++++++++++++++ .../mui-adapter/material/IconButton.tsx | 1 + .../material/InitColorSchemeScript.tsx | 14 + components/mui-adapter/material/styles.ts | 24 + next.config.ts | 3 - package.json | 7 - pages/_app.tsx | 3 +- pnpm-lock.yaml | 21 - tsconfig.json | 30 +- 12 files changed, 805 insertions(+), 36 deletions(-) create mode 100644 components/mui-adapter/lab.tsx create mode 100644 components/mui-adapter/lab/Masonry.tsx create mode 100644 components/mui-adapter/material-nextjs.tsx create mode 100644 components/mui-adapter/material.tsx create mode 100644 components/mui-adapter/material/IconButton.tsx create mode 100644 components/mui-adapter/material/InitColorSchemeScript.tsx create mode 100644 components/mui-adapter/material/styles.ts diff --git a/components/mui-adapter/lab.tsx b/components/mui-adapter/lab.tsx new file mode 100644 index 0000000..2977ec0 --- /dev/null +++ b/components/mui-adapter/lab.tsx @@ -0,0 +1,59 @@ +import { + Children, + FC, + MouseEvent, + PropsWithChildren, + ReactElement, + cloneElement, + createContext, + isValidElement, + useContext, +} from 'react'; + +import { TabProps } from './material'; + +const tabValueContext = createContext('0'); + +export const TabContext: FC> = ({ value, children }) => ( + {children} +); + +export interface TabListProps { + onChange?: (event: MouseEvent, value: string) => void; + className?: string; + component?: string; + variant?: 'fullWidth' | 'standard'; + 'aria-label'?: string; +} + +export const TabList: FC> = ({ onChange, className, children }) => { + const value = useContext(tabValueContext); + + return ( +
+
+ {Children.map(children, child => + isValidElement(child) + ? cloneElement(child as ReactElement, { + selected: (child.props as TabProps).value === value, + onSelect: (event: MouseEvent, newValue?: string) => + onChange?.(event, String(newValue)), + }) + : child, + )} +
+
+ ); +}; + +export const TabPanel: FC> = ({ + value, + className, + children, +}) => { + const current = useContext(tabValueContext); + + if (value !== current) return null; + + return
{children}
; +}; diff --git a/components/mui-adapter/lab/Masonry.tsx b/components/mui-adapter/lab/Masonry.tsx new file mode 100644 index 0000000..a50a368 --- /dev/null +++ b/components/mui-adapter/lab/Masonry.tsx @@ -0,0 +1,19 @@ +import { FC, PropsWithChildren } from 'react'; + +export interface MasonryProps { + component?: 'ul' | 'div'; + className?: string; + columns?: { xs?: number; sm?: number; md?: number }; + spacing?: number; + defaultHeight?: number; + defaultColumns?: number; + defaultSpacing?: number; +} + +const Masonry: FC> = ({ component = 'div', className, children }) => { + const Tag = component; + + return {children}; +}; + +export default Masonry; diff --git a/components/mui-adapter/material-nextjs.tsx b/components/mui-adapter/material-nextjs.tsx new file mode 100644 index 0000000..ba6ef20 --- /dev/null +++ b/components/mui-adapter/material-nextjs.tsx @@ -0,0 +1,11 @@ +import Document, { DocumentContext } from 'next/document'; +import { FC, PropsWithChildren } from 'react'; + +export const createEmotionCache = (_options?: unknown) => ({ key: 'css' }); + +export const AppCacheProvider: FC> = ({ children }) => ( + <>{children} +); + +export const documentGetInitialProps = async (context: DocumentContext, _options?: unknown) => + Document.getInitialProps(context); diff --git a/components/mui-adapter/material.tsx b/components/mui-adapter/material.tsx new file mode 100644 index 0000000..d5f2e56 --- /dev/null +++ b/components/mui-adapter/material.tsx @@ -0,0 +1,649 @@ +import { + ComponentProps, + CSSProperties, + ElementType, + FC, + HTMLAttributes, + MouseEvent, + PropsWithChildren, + ReactElement, + ReactNode, + cloneElement, + createContext, + isValidElement, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +const cx = (...list: (string | undefined | false)[]) => list.filter(Boolean).join(' '); + +const modeContext = createContext<{ + mode: 'light' | 'dark'; + setMode: (mode: 'light' | 'dark') => void; +}>({ mode: 'light', setMode: () => undefined }); + +export interface Theme { + breakpoints: { down: (key: 'sm' | 'md' | 'lg' | 'xl') => string }; +} + +export const createTheme = (theme: unknown) => theme; + +export const ThemeProvider: FC< + PropsWithChildren<{ + theme?: unknown; + defaultMode?: 'light' | 'dark' | 'system'; + disableTransitionOnChange?: boolean; + }> +> = ({ children, defaultMode = 'system' }) => { + const [mode, setMode] = useState<'light' | 'dark'>(() => { + if (typeof window === 'undefined') return 'light'; + + const stored = window.localStorage.getItem('color-mode'); + + if (stored === 'light' || stored === 'dark') return stored; + + if (defaultMode === 'light' || defaultMode === 'dark') return defaultMode; + + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }); + + useEffect(() => { + document.documentElement.classList.toggle('dark', mode === 'dark'); + window.localStorage.setItem('color-mode', mode); + }, [mode]); + + const value = useMemo(() => ({ mode, setMode }), [mode]); + + return {children}; +}; + +export const useTheme = (): Theme => ({ + breakpoints: { + down: key => + ({ sm: '(max-width: 599px)', md: '(max-width: 899px)', lg: '(max-width: 1199px)', xl: '(max-width: 1535px)' })[key], + }, +}); + +export const useMediaQuery = (query: string) => { + const [matches, setMatches] = useState(() => + typeof window === 'undefined' ? false : window.matchMedia(query).matches, + ); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const media = window.matchMedia(query); + const handler = () => setMatches(media.matches); + + handler(); + media.addEventListener('change', handler); + + return () => media.removeEventListener('change', handler); + }, [query]); + + return matches; +}; + +export const StyledEngineProvider: FC> = ({ children }) => ( + <>{children} +); + +export const GlobalStyles: FC<{ styles: string }> = () => null; + +type GenericProps = { + component?: T; + className?: string; +} & Omit, 'className' | 'component'>; + +function renderByComponent( + defaultTag: T, + props: GenericProps, + extraClass?: string, + children?: ReactNode, +) { + const { component, className, ...rest } = props as GenericProps; + const Tag = (component || defaultTag) as ElementType; + + return ( + + {children} + + ); +} + +export type ContainerProps = HTMLAttributes & { maxWidth?: 'md' | 'lg' | 'xl' | false }; +export const Container: FC> = ({ + maxWidth = 'lg', + className, + children, + ...props +}) => { + const widthClass = + maxWidth === 'xl' + ? 'max-w-screen-xl' + : maxWidth === 'md' + ? 'max-w-screen-md' + : maxWidth === false + ? '' + : 'max-w-screen-lg'; + + return ( +
+ {children} +
+ ); +}; + +const buttonVariantClass = (variant?: string, color?: string) => { + if (variant === 'contained') { + if (color === 'success') return 'bg-green-600 text-white hover:bg-green-700'; + if (color === 'warning') return 'bg-amber-600 text-white hover:bg-amber-700'; + if (color === 'info') return 'bg-cyan-600 text-white hover:bg-cyan-700'; + + return 'bg-blue-600 text-white hover:bg-blue-700'; + } + + if (variant === 'outlined') return 'border border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-900'; + + return 'hover:bg-gray-100 dark:hover:bg-gray-800'; +}; + +export interface ButtonProps extends GenericProps<'button'> { + variant?: 'contained' | 'outlined' | 'text'; + color?: 'primary' | 'secondary' | 'info' | 'success' | 'warning'; + size?: 'small' | 'medium' | 'large'; + fullWidth?: boolean; + href?: string; + target?: string; + disabled?: boolean; + startIcon?: ReactNode; +} + +export const Button: FC> = ({ + component, + variant, + color, + size = 'medium', + fullWidth, + href, + target, + disabled, + startIcon, + className, + children, + ...props +}) => { + const sizeClass = size === 'small' ? 'px-2 py-1 text-xs' : size === 'large' ? 'px-4 py-2.5 text-base' : 'px-3 py-2 text-sm'; + const common = cx( + 'inline-flex items-center justify-center gap-1 rounded-md transition disabled:pointer-events-none disabled:opacity-50', + buttonVariantClass(variant, color), + sizeClass, + fullWidth && 'w-full', + className, + ); + + if (component) { + const Tag = component as ElementType; + + return ( + + {startIcon} + {children} + + ); + } + + if (href) { + return ( + )}> + {startIcon} + {children} + + ); + } + + return ( + + ); +}; + +export interface IconButtonProps extends GenericProps<'button'> { + size?: 'small' | 'medium' | 'large'; + disabled?: boolean; +} + +export const IconButton: FC> = ({ + component, + size = 'medium', + className, + children, + disabled, + ...props +}) => { + const Tag = (component || 'button') as ElementType; + + return ( + + {children} + + ); +}; + +export type CardProps = HTMLAttributes & { component?: ElementType }; + +export const Card: FC> = ({ children, className, ...props }) => + renderByComponent('div', props as GenericProps<'div'>, cx('rounded-xl border border-gray-200 dark:border-gray-700', className), children); + +export const CardContent: FC>> = ({ className, ...props }) => ( +
+); + +export const CardActions: FC>> = ({ className, ...props }) => ( +
+); + +export const Paper: FC & { component?: ElementType }>> = ({ className, children, ...props }) => + renderByComponent('div', props as GenericProps<'div'>, cx('rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900', className), children); + +const toStyle = (sx?: Record): CSSProperties | undefined => { + if (!sx) return; + + const style: CSSProperties = {}; + + if (typeof sx.borderTop === 'string') style.borderTop = sx.borderTop; + if (typeof sx.borderColor === 'string') style.borderColor = 'currentColor'; + if (typeof sx.fontSize === 'string') style.fontSize = sx.fontSize; + if (typeof sx.display === 'string') style.display = sx.display; + if (typeof sx.alignItems === 'string') style.alignItems = sx.alignItems; + if (typeof sx.gap === 'number') style.gap = `${sx.gap * 0.25}rem`; + if (typeof sx.flexDirection === 'string') style.flexDirection = sx.flexDirection as CSSProperties['flexDirection']; + if (typeof sx.flexWrap === 'string') style.flexWrap = sx.flexWrap as CSSProperties['flexWrap']; + + return Object.keys(style).length ? style : undefined; +}; + +export const Box: FC } & Record>> = ({ + component = 'div', + className, + sx, + children, + ...props +}) => { + const Tag = component as ElementType; + + return ( + + {children} + + ); +}; + +type GridSize = number | { xs?: number; sm?: number; md?: number; lg?: number; xl?: number }; + +const spanClass = (size: GridSize | undefined) => { + if (!size) return ''; + if (typeof size === 'number') return `col-span-${size}`; + + return cx( + size.xs && `col-span-${size.xs}`, + size.sm && `sm:col-span-${size.sm}`, + size.md && `md:col-span-${size.md}`, + size.lg && `lg:col-span-${size.lg}`, + size.xl && `xl:col-span-${size.xl}`, + ); +}; + +export interface GridProps extends HTMLAttributes { + container?: boolean; + size?: GridSize; + spacing?: number | { xs?: number; sm?: number; md?: number; lg?: number; xl?: number }; +} + +export const Grid: FC> = ({ + container, + size, + spacing, + className, + children, + ...props +}) => { + const gap = + typeof spacing === 'number' + ? `gap-${spacing}` + : spacing?.xs + ? `gap-${spacing.xs}` + : undefined; + + return ( +
+ {children} +
+ ); +}; + +export interface TextFieldProps extends Omit, 'size'> { + label?: ReactNode; + fullWidth?: boolean; + multiline?: boolean; + maxRows?: number; + size?: 'small' | 'medium'; + variant?: 'outlined' | 'filled' | 'standard'; +} + +export const TextField: FC = ({ + label, + fullWidth, + multiline, + className, + maxRows, + size = 'medium', + ...props +}) => { + const inputClass = cx( + 'rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700', + size === 'small' && 'py-1.5', + ); + + return ( +