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,175 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import { motion, type HTMLMotionProps } from 'framer-motion';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { buttonTap, buttonHover, springTransition } from '@/components/motion/transitions';
const cardVariants = cva(
[
'relative overflow-hidden',
'border border-dark-700/40 bg-dark-900/70',
'rounded-linear-lg',
'transition-all duration-200',
// GPU acceleration
'transform-gpu',
// Glass border inset
'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]',
],
{
variants: {
size: {
sm: 'p-3',
md: 'p-4',
lg: 'p-5 sm:p-6',
xl: 'p-6 sm:p-8',
},
variant: {
default: '',
glass: 'backdrop-blur-linear bg-dark-900/50',
solid: 'bg-dark-900',
outline: 'bg-transparent',
},
interactive: {
true: [
'cursor-pointer',
'hover:border-dark-600/50 hover:bg-dark-800/60',
'active:scale-[0.98]',
],
false: '',
},
glow: {
true: 'hover:border-accent-500/30 hover:shadow-glow',
false: '',
},
},
defaultVariants: {
size: 'lg',
variant: 'default',
interactive: false,
glow: false,
},
},
);
export interface CardProps
extends Omit<HTMLMotionProps<'div'>, 'children'>, VariantProps<typeof cardVariants> {
children: ReactNode;
asChild?: boolean;
haptic?: boolean;
}
export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
children,
className,
size,
variant,
interactive,
glow,
asChild = false,
haptic: enableHaptic = true,
onClick,
...props
},
ref,
) => {
const { haptic } = usePlatform();
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (interactive && enableHaptic) {
haptic.impact('light');
}
onClick?.(e);
};
const classes = cn(cardVariants({ size, variant, interactive, glow }), className);
if (asChild) {
return (
<Slot ref={ref} className={classes}>
{children}
</Slot>
);
}
if (interactive) {
return (
<motion.div
ref={ref}
className={classes}
onClick={handleClick}
whileHover={buttonHover}
whileTap={buttonTap}
transition={springTransition}
{...props}
>
{children}
</motion.div>
);
}
return (
<motion.div ref={ref} className={classes} {...props}>
{children}
</motion.div>
);
},
);
Card.displayName = 'Card';
// Card Header
export type CardHeaderProps = HTMLAttributes<HTMLDivElement>;
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
// Card Title
export type CardTitleProps = HTMLAttributes<HTMLHeadingElement>;
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold text-dark-100', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
// Card Description
export type CardDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-dark-400', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
// Card Content
export type CardContentProps = HTMLAttributes<HTMLDivElement>;
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className, ...props }, ref) => <div ref={ref} className={cn('pt-4', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
// Card Footer
export type CardFooterProps = HTMLAttributes<HTMLDivElement>;
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center pt-4', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,14 @@
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
type CardProps,
type CardHeaderProps,
type CardTitleProps,
type CardDescriptionProps,
type CardContentProps,
type CardFooterProps,
} from './Card';

View File

@@ -0,0 +1,56 @@
import { forwardRef, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Button, type ButtonProps } from '@/components/primitives/Button';
import { fadeIn, fadeInTransition } from '@/components/motion/transitions';
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
variant?: ButtonProps['variant'];
};
className?: string;
}
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
({ icon, title, description, action, className }, ref) => {
return (
<motion.div
ref={ref}
className={cn('flex flex-col items-center justify-center py-12 text-center', className)}
variants={fadeIn}
initial="initial"
animate="animate"
transition={fadeInTransition}
>
{/* Icon */}
{icon && (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50 text-dark-400">
{icon}
</div>
)}
{/* Title */}
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
{/* Description */}
{description && <p className="mt-2 max-w-sm text-sm text-dark-400">{description}</p>}
{/* Action */}
{action && (
<div className="mt-6">
<Button variant={action.variant || 'primary'} onClick={action.onClick}>
{action.label}
</Button>
</div>
)}
</motion.div>
);
},
);
EmptyState.displayName = 'EmptyState';

View File

@@ -0,0 +1 @@
export { EmptyState, type EmptyStateProps } from './EmptyState';

View File

@@ -0,0 +1,83 @@
import { forwardRef, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Card, type CardProps } from '../Card';
import { slideUp, slideUpTransition } from '@/components/motion/transitions';
export interface StatCardProps extends Omit<CardProps, 'children'> {
label: string;
value: ReactNode;
icon?: ReactNode;
change?: {
value: number;
label?: string;
};
trend?: 'up' | 'down' | 'neutral';
loading?: boolean;
}
export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
(
{ label, value, icon, change, trend = 'neutral', loading = false, className, ...props },
ref,
) => {
const trendColors = {
up: 'text-success-400',
down: 'text-error-400',
neutral: 'text-dark-400',
};
const trendIcon = {
up: '↑',
down: '↓',
neutral: '→',
};
return (
<Card ref={ref} className={cn('relative', className)} size="md" {...props}>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
{/* Label */}
<p className="truncate text-sm font-medium text-dark-400">{label}</p>
{/* Value */}
{loading ? (
<div className="mt-2 h-8 w-24 animate-pulse rounded bg-dark-800" />
) : (
<motion.p
className="mt-1 text-2xl font-bold text-dark-100 sm:text-3xl"
variants={slideUp}
initial="initial"
animate="animate"
transition={slideUpTransition}
>
{value}
</motion.p>
)}
{/* Change indicator */}
{change && !loading && (
<div className={cn('mt-2 flex items-center gap-1 text-sm', trendColors[trend])}>
<span>{trendIcon[trend]}</span>
<span>
{change.value > 0 ? '+' : ''}
{change.value}%
</span>
{change.label && <span className="text-dark-500">{change.label}</span>}
</div>
)}
</div>
{/* Icon */}
{icon && (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-linear bg-dark-800/80 text-dark-400">
{icon}
</div>
)}
</div>
</Card>
);
},
);
StatCard.displayName = 'StatCard';

View File

@@ -0,0 +1 @@
export { StatCard, type StatCardProps } from './StatCard';

View File

@@ -0,0 +1,3 @@
export * from './Card';
export * from './StatCard';
export * from './EmptyState';