mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
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:
175
src/components/data-display/Card/Card.tsx
Normal file
175
src/components/data-display/Card/Card.tsx
Normal 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';
|
||||
14
src/components/data-display/Card/index.ts
Normal file
14
src/components/data-display/Card/index.ts
Normal 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';
|
||||
56
src/components/data-display/EmptyState/EmptyState.tsx
Normal file
56
src/components/data-display/EmptyState/EmptyState.tsx
Normal 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';
|
||||
1
src/components/data-display/EmptyState/index.ts
Normal file
1
src/components/data-display/EmptyState/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
83
src/components/data-display/StatCard/StatCard.tsx
Normal file
83
src/components/data-display/StatCard/StatCard.tsx
Normal 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';
|
||||
1
src/components/data-display/StatCard/index.ts
Normal file
1
src/components/data-display/StatCard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StatCard, type StatCardProps } from './StatCard';
|
||||
3
src/components/data-display/index.ts
Normal file
3
src/components/data-display/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Card';
|
||||
export * from './StatCard';
|
||||
export * from './EmptyState';
|
||||
Reference in New Issue
Block a user