feat: Linear-style UI redesign with improved mobile experience

Major changes:
- Redesign cabinet with Linear-style components and top navigation
- Replace detail modals with dedicated pages (users, broadcasts, email templates)
- Add email channel support for broadcasts
- Remove pull-to-refresh, improve drag-and-drop on touch devices
- Fix Telegram Mini App: fullscreen, back button, scroll restoration
- Unify admin pages color scheme to design system
- Mobile-first improvements: horizontal tabs for settings, better touch targets
This commit is contained in:
c0mrade
2026-01-31 22:06:36 +03:00
parent 929634aac4
commit b953ee0b8c
108 changed files with 11711 additions and 4542 deletions

View File

@@ -0,0 +1,104 @@
import { Slot } from '@radix-ui/react-slot';
import { motion, type HTMLMotionProps } from 'framer-motion';
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { buttonTap, buttonHover, springTransition } from '../../motion/transitions';
import { buttonVariants, type ButtonVariants } from './Button.variants';
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'disabled'>, ButtonVariants {
children: ReactNode;
asChild?: boolean;
disabled?: boolean;
loading?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
haptic?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
className,
variant,
size,
fullWidth,
asChild = false,
disabled = false,
loading = false,
leftIcon,
rightIcon,
haptic = true,
onClick,
...props
},
ref,
) => {
const { haptic: platformHaptic } = usePlatform();
const isDisabled = disabled || loading;
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (haptic && !isDisabled) {
platformHaptic.impact('light');
}
onClick?.(e);
};
const classes = cn(buttonVariants({ variant, size, fullWidth }), className);
if (asChild) {
return (
<Slot ref={ref} className={classes} {...props}>
{children}
</Slot>
);
}
return (
<motion.button
ref={ref}
className={classes}
disabled={isDisabled}
onClick={handleClick}
whileHover={!isDisabled ? buttonHover : undefined}
whileTap={!isDisabled ? buttonTap : undefined}
transition={springTransition}
{...(props as HTMLMotionProps<'button'>)}
>
{loading ? (
<LoadingSpinner />
) : (
<>
{leftIcon && <span className="shrink-0">{leftIcon}</span>}
{children}
{rightIcon && <span className="shrink-0">{rightIcon}</span>}
</>
)}
</motion.button>
);
},
);
Button.displayName = 'Button';
function LoadingSpinner() {
return (
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
export { buttonVariants, type ButtonVariants };

View File

@@ -0,0 +1,64 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
// Base styles
[
'inline-flex items-center justify-center gap-2',
'font-medium transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950',
'disabled:pointer-events-none disabled:opacity-50',
'select-none',
],
{
variants: {
variant: {
primary: [
'bg-accent-500 text-white',
'hover:bg-accent-600',
'active:bg-accent-700',
'shadow-linear-sm hover:shadow-linear',
],
secondary: [
'bg-dark-800/80 text-dark-100',
'border border-dark-700/50',
'hover:bg-dark-700/80 hover:border-dark-600/50',
'active:bg-dark-800',
],
ghost: ['text-dark-300', 'hover:text-dark-100 hover:bg-dark-800/50', 'active:bg-dark-800'],
destructive: [
'bg-error-500/10 text-error-400',
'border border-error-500/20',
'hover:bg-error-500/20 hover:border-error-500/30',
'active:bg-error-500/30',
],
outline: [
'border border-dark-700/50 text-dark-200',
'hover:bg-dark-800/50 hover:border-dark-600/50 hover:text-dark-100',
'active:bg-dark-800',
],
link: [
'text-accent-400',
'hover:text-accent-300 hover:underline',
'active:text-accent-500',
],
},
size: {
sm: 'h-8 px-3 text-sm rounded-linear',
md: 'h-10 px-4 text-sm rounded-linear',
lg: 'h-12 px-6 text-base rounded-linear-lg',
icon: 'h-10 w-10 rounded-linear',
'icon-sm': 'h-8 w-8 rounded-linear',
'icon-lg': 'h-12 w-12 rounded-linear-lg',
},
fullWidth: {
true: 'w-full',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;

View File

@@ -0,0 +1 @@
export { Button, type ButtonProps, buttonVariants, type ButtonVariants } from './Button';