mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +00:00
Implement specialized UI components and enhance existing elements
Adds Timeline, File Uploader, Comparison Slider, and improves EmptyState and StatCard components with enhanced functionality. Replit-Commit-Author: Agent Replit-Commit-Session-Id: bddfbb2b-6d6b-457b-b18c-05792cdaa035 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/77273c83-56ee-471a-b5e4-559a358f9a20.jpg
This commit is contained in:
parent
66dd28e128
commit
ec099f65b4
@ -84,13 +84,15 @@ This document tracks the implementation status of all components for the Tercul
|
|||||||
| Code Block | ✅ Implemented | `client/src/components/ui/typography/code-block.tsx` | Complete with line numbers and syntax highlighting |
|
| Code Block | ✅ Implemented | `client/src/components/ui/typography/code-block.tsx` | Complete with line numbers and syntax highlighting |
|
||||||
| Prose Container | ✅ Implemented | `client/src/components/ui/typography/prose.tsx` | Complete with various content styling options |
|
| Prose Container | ✅ Implemented | `client/src/components/ui/typography/prose.tsx` | Complete with various content styling options |
|
||||||
|
|
||||||
## Missing UI Components
|
## Additional UI Components
|
||||||
|
|
||||||
Based on the component analysis, the following high-priority UI components are still needed:
|
The following specialized UI components have been implemented:
|
||||||
|
|
||||||
1. **Timeline component** - For displaying chronological events
|
| Component | Status | File Path | Notes |
|
||||||
2. **File Uploader** - For uploading images and documents
|
|-----------|--------|-----------|-------|
|
||||||
3. **Comparison Slider** - For comparing translations or versions
|
| Timeline | ✅ Implemented | `client/src/components/ui/timeline.tsx` | Complete with vertical/horizontal layouts |
|
||||||
|
| File Uploader | ✅ Implemented | `client/src/components/ui/file-uploader.tsx` | Complete with drag & drop, progress, and previews |
|
||||||
|
| Comparison Slider | ✅ Implemented | `client/src/components/ui/comparison-slider.tsx` | Complete with horizontal/vertical sliding |
|
||||||
|
|
||||||
✅ **Completed**:
|
✅ **Completed**:
|
||||||
- All critical UI components from Phase 1
|
- All critical UI components from Phase 1
|
||||||
@ -100,11 +102,11 @@ Based on the component analysis, the following high-priority UI components are s
|
|||||||
## Next Implementation Steps
|
## Next Implementation Steps
|
||||||
|
|
||||||
1. ✅ Create the missing typography components
|
1. ✅ Create the missing typography components
|
||||||
2. Implement remaining components:
|
2. ✅ Implement specialized UI components:
|
||||||
- Timeline component
|
- Timeline component
|
||||||
- File Uploader component
|
- File Uploader component
|
||||||
- Comparison Slider component
|
- Comparison Slider component
|
||||||
3. Implement components for Work and Author management:
|
3. Focus on components for Work and Author management:
|
||||||
- Work Editor
|
- Work Editor
|
||||||
- Work Header
|
- Work Header
|
||||||
- Author Card
|
- Author Card
|
||||||
|
|||||||
@ -0,0 +1,241 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import React, { useState, useRef, useEffect, TouchEvent, MouseEvent } from "react";
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparison Slider component for comparing two views side by side
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ComparisonSlider>
|
||||||
|
* <ComparisonSlider.First>
|
||||||
|
* <img src="/original.jpg" alt="Original" />
|
||||||
|
* </ComparisonSlider.First>
|
||||||
|
* <ComparisonSlider.Second>
|
||||||
|
* <img src="/translated.jpg" alt="Translated" />
|
||||||
|
* </ComparisonSlider.Second>
|
||||||
|
* </ComparisonSlider>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ComparisonSliderContextValue {
|
||||||
|
position: number;
|
||||||
|
setPosition: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComparisonSliderContext = React.createContext<ComparisonSliderContextValue | null>(null);
|
||||||
|
|
||||||
|
const useComparisonSlider = () => {
|
||||||
|
const context = React.useContext(ComparisonSliderContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("ComparisonSlider components must be used within a ComparisonSlider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ComparisonSliderProps {
|
||||||
|
/**
|
||||||
|
* Initial position of the slider (0-100)
|
||||||
|
*/
|
||||||
|
defaultPosition?: number;
|
||||||
|
/**
|
||||||
|
* CSS class for the container
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Class for the divider/handle
|
||||||
|
*/
|
||||||
|
handleClassName?: string;
|
||||||
|
/**
|
||||||
|
* Whether to maintain aspect ratio
|
||||||
|
*/
|
||||||
|
aspectRatio?: string | boolean;
|
||||||
|
/**
|
||||||
|
* Direction of the comparison (horizontal or vertical)
|
||||||
|
*/
|
||||||
|
direction?: "horizontal" | "vertical";
|
||||||
|
/**
|
||||||
|
* Children components
|
||||||
|
*/
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComparisonSlider = ({
|
||||||
|
defaultPosition = 50,
|
||||||
|
className,
|
||||||
|
handleClassName,
|
||||||
|
aspectRatio,
|
||||||
|
direction = "horizontal",
|
||||||
|
children,
|
||||||
|
}: ComparisonSliderProps) => {
|
||||||
|
const [position, setPosition] = useState(defaultPosition);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
// Handle mouse/touch interactions
|
||||||
|
const updatePositionFromEvent = (clientX: number, clientY: number) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const { left, top, width, height } = containerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (direction === "horizontal") {
|
||||||
|
const newPosition = ((clientX - left) / width) * 100;
|
||||||
|
setPosition(Math.min(Math.max(newPosition, 0), 100));
|
||||||
|
} else {
|
||||||
|
const newPosition = ((clientY - top) / height) * 100;
|
||||||
|
setPosition(Math.min(Math.max(newPosition, 0), 100));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
updatePositionFromEvent(e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
updatePositionFromEvent(e.touches[0].clientX, e.touches[0].clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: globalThis.MouseEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
updatePositionFromEvent(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: globalThis.TouchEvent) => {
|
||||||
|
if (isDragging && e.touches[0]) {
|
||||||
|
updatePositionFromEvent(e.touches[0].clientX, e.touches[0].clientY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
window.addEventListener('touchend', handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
window.removeEventListener('touchend', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
|
const isHorizontal = direction === "horizontal";
|
||||||
|
|
||||||
|
// Set aspect ratio style
|
||||||
|
let aspectRatioStyle = {};
|
||||||
|
if (aspectRatio) {
|
||||||
|
if (typeof aspectRatio === 'boolean' && aspectRatio) {
|
||||||
|
aspectRatioStyle = { aspectRatio: "16/9" };
|
||||||
|
} else if (typeof aspectRatio === 'string') {
|
||||||
|
aspectRatioStyle = { aspectRatio };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComparisonSliderContext.Provider value={{ position, setPosition, containerRef }}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden",
|
||||||
|
isHorizontal ? "w-full" : "h-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={aspectRatioStyle}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Handle/Divider */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute z-10",
|
||||||
|
isHorizontal
|
||||||
|
? "top-0 bottom-0 w-1 cursor-ew-resize"
|
||||||
|
: "left-0 right-0 h-1 cursor-ns-resize",
|
||||||
|
isDragging && "select-none",
|
||||||
|
handleClassName
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
[isHorizontal ? 'left' : 'top']: `${position}%`,
|
||||||
|
transform: isHorizontal ? 'translateX(-50%)' : 'translateY(-50%)',
|
||||||
|
backgroundColor: "hsl(var(--primary) / 0.2)",
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute flex items-center justify-center rounded-full shadow bg-background border border-border",
|
||||||
|
isHorizontal
|
||||||
|
? "top-1/2 -translate-y-1/2 h-8 w-8"
|
||||||
|
: "left-1/2 -translate-x-1/2 h-8 w-8"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
[isHorizontal ? 'left' : 'top']: "50%",
|
||||||
|
transform: isHorizontal
|
||||||
|
? "translateX(-50%)"
|
||||||
|
: "translateY(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical className={cn(
|
||||||
|
"h-4 w-4 text-muted-foreground",
|
||||||
|
isHorizontal ? "" : "rotate-90"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ComparisonSliderContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SliderItemProps {
|
||||||
|
/**
|
||||||
|
* Children components
|
||||||
|
*/
|
||||||
|
children: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* CSS class for the component
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const First = ({ children, className }: SliderItemProps) => {
|
||||||
|
const { position, containerRef } = useComparisonSlider();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("absolute inset-0 overflow-hidden", className)}
|
||||||
|
style={{
|
||||||
|
clipPath: `inset(0 ${100 - position}% 0 0)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Second = ({ children, className }: SliderItemProps) => {
|
||||||
|
const { position, containerRef } = useComparisonSlider();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("absolute inset-0", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ComparisonSlider.First = First;
|
||||||
|
ComparisonSlider.Second = Second;
|
||||||
|
|
||||||
|
export { ComparisonSlider };
|
||||||
@ -1,36 +1,40 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LucideIcon } from "lucide-react";
|
import { LucideIcon } from "lucide-react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty state component for displaying when no data is available
|
* Empty state component for displaying when content is not available
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <EmptyState
|
* <EmptyState
|
||||||
* icon={FileSearch}
|
* icon={SearchX}
|
||||||
* title="No results found"
|
* title="No results found"
|
||||||
* description="Try adjusting your search or filters to find what you're looking for."
|
* description="Try adjusting your search or filter to find what you're looking for."
|
||||||
* action={<Button>Clear filters</Button>}
|
* actions={
|
||||||
|
* <Button>Clear filters</Button>
|
||||||
|
* }
|
||||||
* />
|
* />
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const emptyStateVariants = cva(
|
const emptyStateVariants = cva(
|
||||||
"flex flex-col items-center justify-center text-center px-4 py-8 rounded-lg border",
|
"flex flex-col items-center justify-center text-center p-8 rounded-lg",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-background border-border",
|
default: "bg-muted/40",
|
||||||
subtle: "bg-muted/50 border-border",
|
card: "bg-card border shadow-sm",
|
||||||
card: "bg-card border-border shadow-sm",
|
minimal: "",
|
||||||
ghost: "border-transparent bg-transparent",
|
alert: "bg-destructive/5 border border-destructive/20",
|
||||||
|
success: "bg-emerald-50 dark:bg-emerald-950/10 border border-emerald-200 dark:border-emerald-900",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "py-8 px-4 space-y-4",
|
default: "py-12 px-4",
|
||||||
sm: "py-6 px-3 space-y-3",
|
sm: "py-6 px-4",
|
||||||
lg: "py-12 px-6 space-y-5",
|
lg: "py-16 px-4",
|
||||||
|
fullPage: "min-h-[70vh]",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@ -40,66 +44,228 @@ const emptyStateVariants = cva(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface EmptyStateProps extends VariantProps<typeof emptyStateVariants> {
|
export interface EmptyStateProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof emptyStateVariants> {
|
||||||
|
/**
|
||||||
|
* Title text for the empty state
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* Description text explaining the empty state
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
/**
|
/**
|
||||||
* Optional icon to display
|
* Optional icon to display
|
||||||
*/
|
*/
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
/**
|
/**
|
||||||
* Title text
|
* Optional icon size
|
||||||
*/
|
*/
|
||||||
title: string;
|
iconSize?: number;
|
||||||
/**
|
/**
|
||||||
* Optional description text
|
* Optional icon class name
|
||||||
*/
|
*/
|
||||||
description?: string;
|
iconClassName?: string;
|
||||||
/**
|
/**
|
||||||
* Optional action element (usually a button)
|
* Content to display in the actions area
|
||||||
*/
|
*/
|
||||||
action?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Optional content to display above the title
|
||||||
*/
|
*/
|
||||||
className?: string;
|
header?: React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* Optional custom icon element
|
* Optional content to display below the description
|
||||||
*/
|
*/
|
||||||
customIcon?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Optional image to display instead of an icon
|
||||||
|
*/
|
||||||
|
image?: string;
|
||||||
|
/**
|
||||||
|
* Optional image alt text
|
||||||
|
*/
|
||||||
|
imageAlt?: string;
|
||||||
|
/**
|
||||||
|
* Optional image height
|
||||||
|
*/
|
||||||
|
imageHeight?: number | string;
|
||||||
|
/**
|
||||||
|
* Whether this represents a loading state
|
||||||
|
*/
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
icon: Icon,
|
className,
|
||||||
title,
|
|
||||||
description,
|
|
||||||
action,
|
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
className,
|
title,
|
||||||
customIcon,
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
iconSize = 48,
|
||||||
|
iconClassName,
|
||||||
|
actions,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
image,
|
||||||
|
imageAlt,
|
||||||
|
imageHeight = 160,
|
||||||
|
loading = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(emptyStateVariants({ variant, size }), className)}>
|
<div
|
||||||
{/* Icon */}
|
className={cn(emptyStateVariants({ variant, size, className }))}
|
||||||
{customIcon ? (
|
{...props}
|
||||||
customIcon
|
>
|
||||||
) : Icon ? (
|
{/* Custom header content */}
|
||||||
<div className="rounded-full bg-muted p-3 mb-4">
|
{header && <div className="mb-6">{header}</div>}
|
||||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
|
||||||
|
{/* Icon or image */}
|
||||||
|
{image ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={imageAlt || title || "Empty state illustration"}
|
||||||
|
style={{ height: imageHeight }}
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : Icon && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"mx-auto text-muted-foreground",
|
||||||
|
loading && "animate-pulse",
|
||||||
|
iconClassName
|
||||||
|
)}
|
||||||
|
size={iconSize}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3 className="text-lg font-medium">{title}</h3>
|
{title && (
|
||||||
|
<h3 className={cn(
|
||||||
|
"text-xl font-semibold",
|
||||||
|
loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className={cn(
|
||||||
|
"mt-2 text-muted-foreground",
|
||||||
|
loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action */}
|
{/* Custom content */}
|
||||||
{action && (
|
{children && (
|
||||||
<div className="mt-2">{action}</div>
|
<div className={cn(
|
||||||
|
"mt-4",
|
||||||
|
loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{actions && (
|
||||||
|
<div className={cn(
|
||||||
|
"mt-6 flex flex-wrap gap-3 justify-center",
|
||||||
|
loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer content */}
|
||||||
|
{footer && (
|
||||||
|
<div className={cn(
|
||||||
|
"mt-6 text-sm text-muted-foreground",
|
||||||
|
loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmptySearchProps extends Omit<EmptyStateProps, "title" | "description" | "icon"> {
|
||||||
|
/**
|
||||||
|
* The search term that produced no results
|
||||||
|
*/
|
||||||
|
searchTerm?: string;
|
||||||
|
/**
|
||||||
|
* Action to clear the search
|
||||||
|
*/
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptySearch({
|
||||||
|
searchTerm,
|
||||||
|
onReset,
|
||||||
|
actions,
|
||||||
|
...props
|
||||||
|
}: EmptySearchProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={`No results ${searchTerm ? `for "${searchTerm}"` : ""}`}
|
||||||
|
description="Try adjusting your search or filter to find what you're looking for."
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center">
|
||||||
|
{onReset && (
|
||||||
|
<Button variant="outline" onClick={onReset}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmptyFilterProps extends Omit<EmptyStateProps, "title" | "description" | "icon"> {
|
||||||
|
/**
|
||||||
|
* Action to clear filters
|
||||||
|
*/
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyFilter({
|
||||||
|
onClearFilters,
|
||||||
|
actions,
|
||||||
|
...props
|
||||||
|
}: EmptyFilterProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No matching results"
|
||||||
|
description="Try adjusting your filters to find what you're looking for."
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center">
|
||||||
|
{onClearFilters && (
|
||||||
|
<Button variant="outline" onClick={onClearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { emptyStateVariants };
|
||||||
@ -0,0 +1,458 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { forwardRef, useState, useRef, ChangeEvent, DragEvent } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
File,
|
||||||
|
Image as ImageIcon,
|
||||||
|
FileText,
|
||||||
|
FileCode,
|
||||||
|
CheckCircle2,
|
||||||
|
X,
|
||||||
|
AlertCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File uploader component for uploading files with drag and drop support
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <FileUploader
|
||||||
|
* onFilesSelected={(files) => console.log(files)}
|
||||||
|
* accept=".jpg, .png, .pdf"
|
||||||
|
* maxFiles={3}
|
||||||
|
* maxSize={5 * 1024 * 1024} // 5MB
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fileUploaderVariants = cva(
|
||||||
|
"relative border-2 border-dashed rounded-lg p-4 transition-colors",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-border bg-background hover:border-primary/50",
|
||||||
|
compact: "border-border bg-muted/20 p-2",
|
||||||
|
invisible: "border-transparent bg-transparent p-0",
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
idle: "",
|
||||||
|
dragging: "border-primary bg-primary/5",
|
||||||
|
uploading: "border-primary/50",
|
||||||
|
success: "border-emerald-500/50 bg-emerald-50 dark:bg-emerald-950/10",
|
||||||
|
error: "border-destructive/50 bg-destructive/5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
state: "idle",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface FileUploaderProps
|
||||||
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onDrop" | "onError" | "onChange">,
|
||||||
|
VariantProps<typeof fileUploaderVariants> {
|
||||||
|
/**
|
||||||
|
* File types to accept
|
||||||
|
*/
|
||||||
|
accept?: string;
|
||||||
|
/**
|
||||||
|
* Maximum number of files allowed
|
||||||
|
*/
|
||||||
|
maxFiles?: number;
|
||||||
|
/**
|
||||||
|
* Maximum file size in bytes
|
||||||
|
*/
|
||||||
|
maxSize?: number;
|
||||||
|
/**
|
||||||
|
* Whether to allow multiple files
|
||||||
|
*/
|
||||||
|
multiple?: boolean;
|
||||||
|
/**
|
||||||
|
* Button text
|
||||||
|
*/
|
||||||
|
buttonText?: string;
|
||||||
|
/**
|
||||||
|
* Instruction text
|
||||||
|
*/
|
||||||
|
instructionText?: string;
|
||||||
|
/**
|
||||||
|
* Whether to automatically upload files
|
||||||
|
*/
|
||||||
|
autoUpload?: boolean;
|
||||||
|
/**
|
||||||
|
* Function to handle file upload
|
||||||
|
*/
|
||||||
|
uploadFunction?: (files: File[]) => Promise<any>;
|
||||||
|
/**
|
||||||
|
* Callback when files are selected
|
||||||
|
*/
|
||||||
|
onFilesSelected?: (files: File[]) => void;
|
||||||
|
/**
|
||||||
|
* Callback when files change
|
||||||
|
*/
|
||||||
|
onChange?: (files: File[]) => void;
|
||||||
|
/**
|
||||||
|
* Callback on upload error
|
||||||
|
*/
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
/**
|
||||||
|
* Callback on upload success
|
||||||
|
*/
|
||||||
|
onSuccess?: (result: any) => void;
|
||||||
|
/**
|
||||||
|
* Whether to show selected files
|
||||||
|
*/
|
||||||
|
showSelectedFiles?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to show file preview
|
||||||
|
*/
|
||||||
|
showPreview?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the uploader is disabled
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get file icon based on mime type
|
||||||
|
const getFileIcon = (file: File) => {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
return <ImageIcon className="h-6 w-6 text-blue-500" />;
|
||||||
|
} else if (file.type.startsWith('text/')) {
|
||||||
|
return <FileText className="h-6 w-6 text-amber-500" />;
|
||||||
|
} else if (file.type.includes('pdf')) {
|
||||||
|
return <FileText className="h-6 w-6 text-rose-500" />;
|
||||||
|
} else if (file.type.includes('code') || file.type.includes('javascript') || file.type.includes('html')) {
|
||||||
|
return <FileCode className="h-6 w-6 text-emerald-500" />;
|
||||||
|
} else {
|
||||||
|
return <File className="h-6 w-6 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format file size in a human-readable way
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileUploader = forwardRef<HTMLDivElement, FileUploaderProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
state: externalState,
|
||||||
|
accept,
|
||||||
|
maxFiles = 1,
|
||||||
|
maxSize,
|
||||||
|
multiple = false,
|
||||||
|
buttonText = "Select file",
|
||||||
|
instructionText = "or drag and drop",
|
||||||
|
autoUpload = false,
|
||||||
|
uploadFunction,
|
||||||
|
onFilesSelected,
|
||||||
|
onChange,
|
||||||
|
onError,
|
||||||
|
onSuccess,
|
||||||
|
showSelectedFiles = true,
|
||||||
|
showPreview = true,
|
||||||
|
disabled = false,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Determine component state based on props and internal state
|
||||||
|
const state = externalState || (
|
||||||
|
error ? "error" :
|
||||||
|
success ? "success" :
|
||||||
|
uploading ? "uploading" :
|
||||||
|
dragging ? "dragging" :
|
||||||
|
"idle"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle file selection from input
|
||||||
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = Array.from(e.target.files || []);
|
||||||
|
processFiles(selectedFiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file drop
|
||||||
|
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragging(false);
|
||||||
|
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
|
processFiles(droppedFiles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file validation and processing
|
||||||
|
const processFiles = (selectedFiles: File[]) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Check if too many files
|
||||||
|
if (selectedFiles.length > maxFiles) {
|
||||||
|
const errorMessage = `Maximum ${maxFiles} file${maxFiles === 1 ? '' : 's'} allowed`;
|
||||||
|
setError(errorMessage);
|
||||||
|
onError?.(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file sizes if maxSize is provided
|
||||||
|
if (maxSize) {
|
||||||
|
const oversizedFiles = selectedFiles.filter(file => file.size > maxSize);
|
||||||
|
if (oversizedFiles.length > 0) {
|
||||||
|
const errorMessage = `File${oversizedFiles.length > 1 ? 's' : ''} exceed${oversizedFiles.length === 1 ? 's' : ''} maximum size of ${formatFileSize(maxSize)}`;
|
||||||
|
setError(errorMessage);
|
||||||
|
onError?.(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update files state
|
||||||
|
const newFiles = multiple ? [...files, ...selectedFiles] : selectedFiles;
|
||||||
|
setFiles(newFiles);
|
||||||
|
|
||||||
|
// Call callbacks
|
||||||
|
onFilesSelected?.(newFiles);
|
||||||
|
onChange?.(newFiles);
|
||||||
|
|
||||||
|
// Auto upload if enabled
|
||||||
|
if (autoUpload && uploadFunction) {
|
||||||
|
uploadFiles(newFiles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file upload
|
||||||
|
const uploadFiles = async (filesToUpload: File[]) => {
|
||||||
|
if (!uploadFunction) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
|
// Fake progress updates for demo
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
setUploadProgress(prev => {
|
||||||
|
const increment = Math.random() * 10;
|
||||||
|
return Math.min(prev + increment, 95);
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Actual upload
|
||||||
|
const result = await uploadFunction(filesToUpload);
|
||||||
|
|
||||||
|
// Complete progress and show success
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setUploadProgress(100);
|
||||||
|
setSuccess(true);
|
||||||
|
setUploading(false);
|
||||||
|
onSuccess?.(result);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setUploading(false);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Upload failed';
|
||||||
|
setError(errorMessage);
|
||||||
|
onError?.(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle drag events
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!disabled) setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger file input click
|
||||||
|
const handleButtonClick = () => {
|
||||||
|
if (!disabled && fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove file from selection
|
||||||
|
const handleRemoveFile = (indexToRemove: number) => {
|
||||||
|
const newFiles = files.filter((_, index) => index !== indexToRemove);
|
||||||
|
setFiles(newFiles);
|
||||||
|
onChange?.(newFiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create preview for image files
|
||||||
|
const renderPreview = (file: File) => {
|
||||||
|
if (!showPreview) return null;
|
||||||
|
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
return (
|
||||||
|
<div className="relative h-16 w-16 rounded overflow-hidden bg-muted">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
alt={file.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onLoad={() => URL.revokeObjectURL(URL.createObjectURL(file))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-16 w-16 rounded bg-muted/50">
|
||||||
|
{getFileIcon(file)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full space-y-2"
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* File Input (hidden) */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple && maxFiles > 1}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drag & Drop Area */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
fileUploaderVariants({ variant, state, className }),
|
||||||
|
disabled && "opacity-60 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center py-4 text-center">
|
||||||
|
<Upload className={cn(
|
||||||
|
"h-10 w-10 mb-2",
|
||||||
|
state === "error" ? "text-destructive" :
|
||||||
|
state === "success" ? "text-emerald-500" :
|
||||||
|
"text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{state === "error" ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-destructive">Upload failed</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{error}</p>
|
||||||
|
</>
|
||||||
|
) : state === "success" ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Upload complete</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Files uploaded successfully</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground pt-1">
|
||||||
|
{instructionText}
|
||||||
|
{accept && <span className="block">{accept.replaceAll(',', ', ')}</span>}
|
||||||
|
{maxSize && <span className="block">Max size: {formatFileSize(maxSize)}</span>}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Progress */}
|
||||||
|
{uploading && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Uploading...</span>
|
||||||
|
<span>{Math.round(uploadProgress)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={uploadProgress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File List */}
|
||||||
|
{showSelectedFiles && files.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<li
|
||||||
|
key={`${file.name}-${index}`}
|
||||||
|
className="flex items-center gap-3 bg-muted/20 rounded p-2 text-sm"
|
||||||
|
>
|
||||||
|
{renderPreview(file)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{file.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state === "success" ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-500 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-full"
|
||||||
|
onClick={() => handleRemoveFile(index)}
|
||||||
|
disabled={uploading || disabled}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Remove {file.name}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload Button (when not auto-uploading) */}
|
||||||
|
{!autoUpload && files.length > 0 && uploadFunction && !success && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => uploadFiles(files)}
|
||||||
|
disabled={uploading || disabled || files.length === 0}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading..." : "Upload"}
|
||||||
|
{uploading && <span className="loading ml-2"></span>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
FileUploader.displayName = "FileUploader";
|
||||||
|
|
||||||
|
export { FileUploader, fileUploaderVariants };
|
||||||
@ -3,37 +3,36 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { LucideIcon } from "lucide-react";
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stat card component for displaying statistical metrics
|
* Stat Card component for displaying statistics and metrics
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <StatCard
|
* <StatCard
|
||||||
* title="Total Works"
|
* title="Total Readers"
|
||||||
* value="124"
|
* value="12,345"
|
||||||
* description="+12% from last month"
|
* description="+12% from last month"
|
||||||
* trend="up"
|
* trend="up"
|
||||||
* icon={Book}
|
* icon={Users}
|
||||||
* />
|
* />
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const statCardVariants = cva(
|
const statCardVariants = cva(
|
||||||
"rounded-lg border p-4 flex flex-col space-y-2",
|
"relative overflow-hidden rounded-lg p-6 shadow-sm",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-background border-border",
|
default: "bg-card border",
|
||||||
primary: "bg-primary/10 border-primary/20",
|
filled: "bg-primary text-primary-foreground",
|
||||||
secondary: "bg-secondary/10 border-secondary/20",
|
outline: "border border-primary/20 bg-background",
|
||||||
accent: "bg-russet/10 border-russet/20 dark:bg-russet/5",
|
ghost: "bg-muted/30 border-none shadow-none",
|
||||||
success: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-900/30",
|
destructive: "bg-destructive text-destructive-foreground",
|
||||||
warning: "bg-amber-50 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/30",
|
success: "bg-emerald-50 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-900/50 border",
|
||||||
danger: "bg-rose-50 border-rose-200 dark:bg-rose-950/20 dark:border-rose-900/30",
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "p-4",
|
default: "p-6",
|
||||||
sm: "p-3",
|
sm: "p-4",
|
||||||
lg: "p-6",
|
lg: "p-8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@ -43,99 +42,192 @@ const statCardVariants = cva(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface StatCardProps extends VariantProps<typeof statCardVariants> {
|
export interface StatCardProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof statCardVariants> {
|
||||||
/**
|
/**
|
||||||
* The title of the stat
|
* Title or label for the stat
|
||||||
*/
|
*/
|
||||||
title: string;
|
title?: string;
|
||||||
/**
|
/**
|
||||||
* The value to display
|
* The value/data to display
|
||||||
*/
|
*/
|
||||||
value: string | number;
|
value?: React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* Optional description text (like change from previous period)
|
* Optional description or context for the value
|
||||||
*/
|
*/
|
||||||
description?: string;
|
description?: React.ReactNode;
|
||||||
/**
|
|
||||||
* Optional trend direction
|
|
||||||
*/
|
|
||||||
trend?: "up" | "down" | "neutral";
|
|
||||||
/**
|
/**
|
||||||
* Optional icon to display
|
* Optional icon to display
|
||||||
*/
|
*/
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Direction of the trend (up, down, or none)
|
||||||
*/
|
*/
|
||||||
className?: string;
|
trend?: "up" | "down" | "none";
|
||||||
/**
|
/**
|
||||||
* Whether the stat is loading
|
* Whether the trend is positive (green) or negative (red)
|
||||||
|
* By default, "up" is positive and "down" is negative
|
||||||
*/
|
*/
|
||||||
isLoading?: boolean;
|
trendIsPositive?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the card is in a loading state
|
||||||
|
*/
|
||||||
|
loading?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional footer content
|
||||||
|
*/
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Optional badge content (displayed in the top right)
|
||||||
|
*/
|
||||||
|
badge?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatCard({
|
export function StatCard({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
description,
|
description,
|
||||||
trend,
|
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
variant,
|
trend,
|
||||||
size,
|
trendIsPositive,
|
||||||
className,
|
loading = false,
|
||||||
isLoading = false,
|
footer,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
// Determine the color for trend
|
// Determine if trend is positive based on direction and trendIsPositive prop
|
||||||
const trendColor = trend === "up"
|
const isPositive = trendIsPositive !== undefined
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
? trendIsPositive
|
||||||
: trend === "down"
|
: trend === "up";
|
||||||
? "text-rose-600 dark:text-rose-400"
|
|
||||||
: "text-gray-600 dark:text-gray-400";
|
|
||||||
|
|
||||||
// Determine the arrow for trend
|
const trendColor = trend === "none"
|
||||||
const trendSymbol = trend === "up"
|
? "text-muted-foreground"
|
||||||
? "↑"
|
: isPositive
|
||||||
: trend === "down"
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
? "↓"
|
: "text-destructive";
|
||||||
: "→";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(statCardVariants({ variant, size }), className)}>
|
<div
|
||||||
{/* Header with icon and title */}
|
className={cn(statCardVariants({ variant, size, className }))}
|
||||||
<div className="flex items-center justify-between">
|
{...props}
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
>
|
||||||
{Icon && (
|
{/* Optional badge */}
|
||||||
<div className={cn(
|
{badge && (
|
||||||
"rounded-full p-1.5",
|
<div className="absolute top-2 right-2">
|
||||||
variant === "primary" && "bg-primary/10 text-primary",
|
{badge}
|
||||||
variant === "secondary" && "bg-secondary/10 text-secondary",
|
|
||||||
variant === "accent" && "bg-russet/10 text-russet dark:bg-russet/5",
|
|
||||||
variant === "success" && "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400",
|
|
||||||
variant === "warning" && "bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400",
|
|
||||||
variant === "danger" && "bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400",
|
|
||||||
!variant && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
|
||||||
)}>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Title/label */}
|
||||||
|
{title && (
|
||||||
|
<h3 className={cn(
|
||||||
|
"text-sm font-medium text-muted-foreground",
|
||||||
|
variant === "filled" && "text-primary-foreground/80",
|
||||||
|
variant === "destructive" && "text-destructive-foreground/80",
|
||||||
|
loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Value */}
|
{/* Value */}
|
||||||
{isLoading ? (
|
<div className={cn(
|
||||||
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-800 animate-pulse rounded" />
|
"text-2xl font-bold",
|
||||||
) : (
|
loading && "animate-pulse bg-muted/40 rounded h-8 w-24",
|
||||||
<div className="text-2xl font-bold">{value}</div>
|
typeof value === "undefined" && loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{loading && typeof value === "undefined" ? null : value}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description with trend indicator */}
|
||||||
|
{description && (
|
||||||
|
<p className={cn(
|
||||||
|
"text-sm",
|
||||||
|
trend ? trendColor : "text-muted-foreground",
|
||||||
|
loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description with trend */}
|
{/* Custom content */}
|
||||||
{description && (
|
{children && (
|
||||||
<div className="text-xs flex items-center gap-1">
|
<div className={cn(loading && "animate-pulse")}>
|
||||||
{trend && <span className={cn("font-medium", trendColor)}>{trendSymbol}</span>}
|
{children}
|
||||||
<span className={cn(trend ? trendColor : "text-muted-foreground")}>
|
</div>
|
||||||
{description}
|
)}
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
{Icon && (
|
||||||
|
<div className={cn(
|
||||||
|
"h-9 w-9 rounded-lg flex items-center justify-center",
|
||||||
|
variant === "default" && "bg-primary/10 text-primary",
|
||||||
|
variant === "filled" && "bg-primary-foreground/10 text-primary-foreground",
|
||||||
|
variant === "outline" && "bg-muted/50 text-muted-foreground",
|
||||||
|
variant === "ghost" && "bg-muted/50 text-muted-foreground",
|
||||||
|
variant === "destructive" && "bg-destructive-foreground/10 text-destructive-foreground",
|
||||||
|
variant === "success" && "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400"
|
||||||
|
)}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div className={cn(
|
||||||
|
"mt-4 pt-4 border-t",
|
||||||
|
variant === "filled" && "border-primary-foreground/10",
|
||||||
|
variant === "destructive" && "border-destructive-foreground/10",
|
||||||
|
loading && "animate-pulse"
|
||||||
|
)}>
|
||||||
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/**
|
||||||
|
* Number of columns for larger screens
|
||||||
|
*/
|
||||||
|
cols?: 1 | 2 | 3 | 4;
|
||||||
|
/**
|
||||||
|
* Whether to apply gap between cards
|
||||||
|
*/
|
||||||
|
gap?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatGroup({
|
||||||
|
className,
|
||||||
|
cols = 4,
|
||||||
|
gap = true,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StatGroupProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-1 md:grid-cols-2",
|
||||||
|
cols === 3 && "lg:grid-cols-3",
|
||||||
|
cols === 4 && "lg:grid-cols-4",
|
||||||
|
gap && "gap-4 md:gap-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { statCardVariants };
|
||||||
@ -1,233 +1,438 @@
|
|||||||
import { useState, useRef, KeyboardEvent, useEffect } from "react";
|
import { cn } from "@/lib/utils";
|
||||||
|
import React, { useState, useRef, useEffect, KeyboardEvent, forwardRef } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Command, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||||
import { X, Plus } from "lucide-react";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { X, ChevronsUpDown, Check, Tag as TagIcon } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag input component for entering and managing tags
|
* Tag Input component for adding and managing tags
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <TagInput
|
* <TagInput
|
||||||
* value={tags}
|
|
||||||
* onChange={setTags}
|
|
||||||
* placeholder="Add tags..."
|
* placeholder="Add tags..."
|
||||||
* maxTags={5}
|
* tags={tags}
|
||||||
* suggestions={["React", "TypeScript", "UI"]}
|
* onTagsChange={setTags}
|
||||||
|
* suggestions={[
|
||||||
|
* { value: 'fiction', label: 'Fiction' },
|
||||||
|
* { value: 'historical', label: 'Historical' }
|
||||||
|
* ]}
|
||||||
* />
|
* />
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface TagInputProps {
|
export type TagItem = {
|
||||||
/**
|
/**
|
||||||
* Current tag values
|
* Unique identifier for the tag
|
||||||
*/
|
*/
|
||||||
value: string[];
|
value: string;
|
||||||
/**
|
/**
|
||||||
* Event handler called when tags change
|
* Display text for the tag
|
||||||
*/
|
*/
|
||||||
onChange: (value: string[]) => void;
|
label: string;
|
||||||
/**
|
/**
|
||||||
* Input placeholder text
|
* Optional color or variant for the tag
|
||||||
*/
|
*/
|
||||||
placeholder?: string;
|
variant?: string;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes for the container
|
* Optional icon for the tag
|
||||||
*/
|
*/
|
||||||
className?: string;
|
icon?: React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* CSS classes for the input field
|
* Optional description of the tag
|
||||||
*/
|
*/
|
||||||
inputClassName?: string;
|
description?: string;
|
||||||
|
/**
|
||||||
|
* Whether the tag is disabled/readonly
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Any additional properties
|
||||||
|
*/
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TagInputProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
|
||||||
|
/**
|
||||||
|
* Array of tag items
|
||||||
|
*/
|
||||||
|
tags: TagItem[];
|
||||||
|
/**
|
||||||
|
* Callback when tags change
|
||||||
|
*/
|
||||||
|
onTagsChange: (tags: TagItem[]) => void;
|
||||||
|
/**
|
||||||
|
* Optional array of tag suggestions
|
||||||
|
*/
|
||||||
|
suggestions?: TagItem[];
|
||||||
/**
|
/**
|
||||||
* Maximum number of tags allowed
|
* Maximum number of tags allowed
|
||||||
*/
|
*/
|
||||||
maxTags?: number;
|
maxTags?: number;
|
||||||
/**
|
/**
|
||||||
* Whether the component is disabled
|
* Minimum length for new tag values
|
||||||
|
*/
|
||||||
|
minLength?: number;
|
||||||
|
/**
|
||||||
|
* Maximum length for new tag values
|
||||||
|
*/
|
||||||
|
maxLength?: number;
|
||||||
|
/**
|
||||||
|
* Whether to allow duplicate tags
|
||||||
|
*/
|
||||||
|
allowDuplicates?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to validate tag values
|
||||||
|
*/
|
||||||
|
validate?: (value: string) => boolean | string;
|
||||||
|
/**
|
||||||
|
* Whether to convert tag input to lowercase
|
||||||
|
*/
|
||||||
|
lowercase?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to allow creating new tags
|
||||||
|
*/
|
||||||
|
allowNew?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the input is disabled
|
||||||
*/
|
*/
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* Array of tag suggestions
|
* Whether the component should display in read-only mode
|
||||||
*/
|
*/
|
||||||
suggestions?: string[];
|
readOnly?: boolean;
|
||||||
/**
|
/**
|
||||||
* Whether tags should be validated before adding
|
* Button text for adding tags
|
||||||
*/
|
*/
|
||||||
validate?: (tag: string) => boolean;
|
addButtonText?: string;
|
||||||
/**
|
/**
|
||||||
* Maximum length of a single tag
|
* Placeholder text when no tags are present
|
||||||
*/
|
*/
|
||||||
maxTagLength?: number;
|
emptyPlaceholder?: string;
|
||||||
|
/**
|
||||||
|
* Custom class for the container
|
||||||
|
*/
|
||||||
|
containerClassName?: string;
|
||||||
|
/**
|
||||||
|
* Custom class for each tag
|
||||||
|
*/
|
||||||
|
tagClassName?: string;
|
||||||
|
/**
|
||||||
|
* Custom renderer for tags
|
||||||
|
*/
|
||||||
|
tagRenderer?: (tag: TagItem, index: number, onRemove: () => void) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagInput({
|
export const TagInput = forwardRef<HTMLInputElement, TagInputProps>(({
|
||||||
value = [],
|
tags = [],
|
||||||
onChange,
|
onTagsChange,
|
||||||
placeholder = "Add tag...",
|
|
||||||
className,
|
|
||||||
inputClassName,
|
|
||||||
maxTags,
|
|
||||||
disabled = false,
|
|
||||||
suggestions = [],
|
suggestions = [],
|
||||||
|
maxTags,
|
||||||
|
minLength = 1,
|
||||||
|
maxLength = 50,
|
||||||
|
allowDuplicates = false,
|
||||||
validate,
|
validate,
|
||||||
maxTagLength = 30,
|
lowercase = false,
|
||||||
}: TagInputProps) {
|
allowNew = true,
|
||||||
const [inputValue, setInputValue] = useState<string>("");
|
disabled = false,
|
||||||
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
readOnly = false,
|
||||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
addButtonText = "Add",
|
||||||
|
placeholder = "Add a tag...",
|
||||||
|
emptyPlaceholder = "No tags added",
|
||||||
|
containerClassName,
|
||||||
|
tagClassName,
|
||||||
|
tagRenderer,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
|
||||||
|
const [inputError, setInputError] = useState<string | null>(null);
|
||||||
|
const [filteredSuggestions, setFilteredSuggestions] = useState(suggestions);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Forward the ref
|
||||||
|
const handleRef = (el: HTMLInputElement) => {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(el);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = el;
|
||||||
|
}
|
||||||
|
inputRef.current = el;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Focus the input when clicking the container
|
||||||
|
const focusInput = () => {
|
||||||
|
if (!disabled && !readOnly && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Filter suggestions based on input value
|
// Filter suggestions based on input value
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputValue) {
|
if (inputValue) {
|
||||||
const filtered = suggestions.filter(
|
const filtered = suggestions.filter(
|
||||||
(suggestion) =>
|
suggestion =>
|
||||||
suggestion.toLowerCase().includes(inputValue.toLowerCase()) &&
|
suggestion.label.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||||
!value.includes(suggestion)
|
(allowDuplicates || !tags.some(tag => tag.value === suggestion.value))
|
||||||
);
|
);
|
||||||
setFilteredSuggestions(filtered);
|
setFilteredSuggestions(filtered);
|
||||||
setShowSuggestions(filtered.length > 0);
|
|
||||||
} else {
|
} else {
|
||||||
setShowSuggestions(false);
|
// Show all available suggestions when input is empty
|
||||||
|
setFilteredSuggestions(
|
||||||
|
suggestions.filter(suggestion =>
|
||||||
|
allowDuplicates || !tags.some(tag => tag.value === suggestion.value)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [inputValue, suggestions, value]);
|
}, [inputValue, suggestions, tags, allowDuplicates]);
|
||||||
|
|
||||||
// Add a new tag
|
// Add a tag
|
||||||
const addTag = (tag: string) => {
|
const addTag = (tag: TagItem) => {
|
||||||
const trimmedTag = tag.trim();
|
if (disabled || readOnly) return;
|
||||||
|
|
||||||
// Skip if empty, too long, already exists, or fails validation
|
// Check maximum tags
|
||||||
if (
|
if (maxTags && tags.length >= maxTags) {
|
||||||
!trimmedTag ||
|
setInputError(`Maximum ${maxTags} tags allowed`);
|
||||||
trimmedTag.length > maxTagLength ||
|
|
||||||
value.includes(trimmedTag) ||
|
|
||||||
(validate && !validate(trimmedTag)) ||
|
|
||||||
(maxTags !== undefined && value.length >= maxTags)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange([...value, trimmedTag]);
|
// Check for duplicates if not allowed
|
||||||
|
if (!allowDuplicates && tags.some(t => t.value === tag.value)) {
|
||||||
|
setInputError("This tag already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the tag and reset input
|
||||||
|
onTagsChange([...tags, tag]);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
|
setInputError(null);
|
||||||
|
|
||||||
|
// Focus back on input
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove a tag
|
// Remove a tag
|
||||||
const removeTag = (index: number) => {
|
const removeTag = (indexToRemove: number) => {
|
||||||
if (disabled) return;
|
if (disabled || readOnly) return;
|
||||||
const newTags = [...value];
|
|
||||||
newTags.splice(index, 1);
|
onTagsChange(tags.filter((_, index) => index !== indexToRemove));
|
||||||
onChange(newTags);
|
setInputError(null);
|
||||||
|
|
||||||
|
// Focus back on input
|
||||||
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle keyboard events
|
// Create a new tag from input
|
||||||
|
const createNewTag = () => {
|
||||||
|
if (!inputValue.trim() || !allowNew || disabled || readOnly) return;
|
||||||
|
|
||||||
|
// Format the value
|
||||||
|
let value = inputValue.trim();
|
||||||
|
if (lowercase) {
|
||||||
|
value = value.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate length
|
||||||
|
if (value.length < minLength) {
|
||||||
|
setInputError(`Tag must be at least ${minLength} characters`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > maxLength) {
|
||||||
|
setInputError(`Tag must be at most ${maxLength} characters`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom validation
|
||||||
|
if (validate) {
|
||||||
|
const validationResult = validate(value);
|
||||||
|
if (typeof validationResult === 'string') {
|
||||||
|
setInputError(validationResult);
|
||||||
|
return;
|
||||||
|
} else if (validationResult === false) {
|
||||||
|
setInputError("Invalid tag");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and add the tag
|
||||||
|
addTag({ value, label: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle keyboard input
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === "Enter" && inputValue) {
|
// Clear error on new input
|
||||||
|
if (inputError) {
|
||||||
|
setInputError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addTag(inputValue);
|
createNewTag();
|
||||||
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
|
||||||
removeTag(value.length - 1);
|
removeTag(tags.length - 1);
|
||||||
|
} else if (e.key === ',' || e.key === ';') {
|
||||||
|
e.preventDefault();
|
||||||
|
createNewTag();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Select a suggestion
|
// Custom tag renderer or default
|
||||||
const selectSuggestion = (suggestion: string) => {
|
const renderTag = (tag: TagItem, index: number) => {
|
||||||
addTag(suggestion);
|
if (tagRenderer) {
|
||||||
setShowSuggestions(false);
|
return tagRenderer(tag, index, () => removeTag(index));
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", className)}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-wrap gap-2 p-2 bg-background border rounded-md focus-within:ring-1 focus-within:ring-ring focus-within:border-input",
|
|
||||||
disabled && "opacity-60 cursor-not-allowed bg-muted"
|
|
||||||
)}
|
|
||||||
onClick={() => inputRef.current?.focus()}
|
|
||||||
>
|
|
||||||
{/* Display existing tags */}
|
|
||||||
{value.map((tag, index) => (
|
|
||||||
<Badge
|
<Badge
|
||||||
key={`${tag}-${index}`}
|
key={`${tag.value}-${index}`}
|
||||||
variant="secondary"
|
variant={tag.variant || "default"}
|
||||||
className="px-2 py-1 flex items-center gap-1"
|
className={cn(
|
||||||
|
"m-1 max-w-full truncate",
|
||||||
|
tag.disabled && "opacity-60",
|
||||||
|
tagClassName
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tag}
|
<div className="flex items-center max-w-full">
|
||||||
|
{tag.icon && <span className="mr-1 flex-shrink-0">{tag.icon}</span>}
|
||||||
|
<span className="truncate">{tag.label}</span>
|
||||||
|
{!readOnly && !tag.disabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-4 w-4 p-0 hover:bg-transparent text-muted-foreground hover:text-foreground"
|
onClick={() => removeTag(index)}
|
||||||
onClick={(e) => {
|
className="h-4 w-4 p-0 ml-1"
|
||||||
e.stopPropagation();
|
|
||||||
removeTag(index);
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
<span className="sr-only">Remove {tag}</span>
|
<span className="sr-only">Remove {tag.label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{/* Input for new tags */}
|
return (
|
||||||
{(!maxTags || value.length < maxTags) && (
|
<div className="space-y-2">
|
||||||
<div className="relative flex-1 min-w-[120px]">
|
<div
|
||||||
<Input
|
className={cn(
|
||||||
ref={inputRef}
|
"flex flex-wrap items-center border rounded-md px-3 py-2 focus-within:ring-1 focus-within:ring-ring focus-within:border-input",
|
||||||
|
disabled && "opacity-60 cursor-not-allowed bg-muted",
|
||||||
|
readOnly && "bg-muted/50 cursor-default",
|
||||||
|
inputError && "border-destructive focus-within:ring-destructive",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
onClick={focusInput}
|
||||||
|
>
|
||||||
|
{/* Render existing tags */}
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap -m-1">
|
||||||
|
{tags.map((tag, index) => renderTag(tag, index))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-sm py-1.5">{emptyPlaceholder}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render input */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="flex flex-1 items-center min-w-[180px]">
|
||||||
|
<input
|
||||||
|
ref={handleRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => inputValue && setShowSuggestions(filteredSuggestions.length > 0)}
|
|
||||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={cn("border-0 ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 p-0 h-8", inputClassName)}
|
className={cn(
|
||||||
disabled={disabled}
|
"flex-1 bg-transparent border-0 outline-none focus:outline-none focus:ring-0 p-1.5 text-sm",
|
||||||
|
disabled && "cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Suggestions dropdown */}
|
{/* Suggestions dropdown */}
|
||||||
{showSuggestions && (
|
{suggestions.length > 0 && (
|
||||||
<div className="absolute left-0 top-full z-10 w-full bg-popover border rounded-md shadow-md mt-1 py-1 text-popover-foreground">
|
<Popover open={suggestionsOpen} onOpenChange={setSuggestionsOpen}>
|
||||||
{filteredSuggestions.map((suggestion) => (
|
<PopoverTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
key={suggestion}
|
variant="ghost"
|
||||||
className="w-full text-left px-2 py-1 hover:bg-accent hover:text-accent-foreground text-sm"
|
size="sm"
|
||||||
onClick={() => selectSuggestion(suggestion)}
|
className="h-7 w-7 p-0 ml-1"
|
||||||
type="button"
|
disabled={disabled || readOnly}
|
||||||
>
|
>
|
||||||
{suggestion}
|
<ChevronsUpDown className="h-4 w-4" />
|
||||||
</button>
|
<span className="sr-only">Show suggestions</span>
|
||||||
))}
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-[200px]" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandGroup heading="Suggestions">
|
||||||
|
{filteredSuggestions.length > 0 ? (
|
||||||
|
filteredSuggestions.map((suggestion) => (
|
||||||
|
<CommandItem
|
||||||
|
key={suggestion.value}
|
||||||
|
onSelect={() => {
|
||||||
|
addTag(suggestion);
|
||||||
|
setSuggestionsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{suggestion.icon || <TagIcon className="mr-2 h-4 w-4" />}
|
||||||
|
<span className="ml-2">{suggestion.label}</span>
|
||||||
|
</div>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
tags.some(tag => tag.value === suggestion.value)
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
|
||||||
|
No suggestions available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add button for mobile usability */}
|
{/* Add button */}
|
||||||
{inputValue && !disabled && (
|
{allowNew && inputValue && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => addTag(inputValue)}
|
onClick={createNewTag}
|
||||||
className="h-8 w-8 p-0 rounded-full"
|
disabled={disabled || readOnly}
|
||||||
|
className="h-7 p-1 px-2 ml-1"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
{addButtonText}
|
||||||
<span className="sr-only">Add tag</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Show max tags limit */}
|
{/* Error message */}
|
||||||
{maxTags && (
|
{inputError && (
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-destructive text-xs mt-1">{inputError}</p>
|
||||||
{value.length} of {maxTags} tags used
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
TagInput.displayName = "TagInput";
|
||||||
|
|
||||||
|
export default TagInput;
|
||||||
@ -0,0 +1,219 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeline component for displaying chronological events
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Timeline>
|
||||||
|
* <TimelineItem
|
||||||
|
* date="2023"
|
||||||
|
* title="Published Eugene Onegin"
|
||||||
|
* description="A milestone in Russian literature"
|
||||||
|
* icon={BookOpen}
|
||||||
|
* />
|
||||||
|
* <TimelineItem
|
||||||
|
* date="2024"
|
||||||
|
* title="New translation released"
|
||||||
|
* variant="highlight"
|
||||||
|
* />
|
||||||
|
* </Timeline>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
const timelineVariants = cva(
|
||||||
|
"relative",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "",
|
||||||
|
compact: "",
|
||||||
|
detailed: "",
|
||||||
|
},
|
||||||
|
orientation: {
|
||||||
|
vertical: "pl-6",
|
||||||
|
horizontal: "flex",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface TimelineProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof timelineVariants> {}
|
||||||
|
|
||||||
|
const Timeline = forwardRef<HTMLDivElement, TimelineProps>(
|
||||||
|
({ className, variant, orientation, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(timelineVariants({ variant, orientation, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Timeline.displayName = "Timeline";
|
||||||
|
|
||||||
|
const timelineItemVariants = cva(
|
||||||
|
"relative mb-8 last:mb-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "",
|
||||||
|
highlight: "bg-muted/20 rounded-md p-4 border-l-2 border-primary",
|
||||||
|
muted: "text-muted-foreground",
|
||||||
|
card: "bg-card rounded-md p-4 shadow-sm border",
|
||||||
|
},
|
||||||
|
orientation: {
|
||||||
|
vertical: "pl-0",
|
||||||
|
horizontal: "flex-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface TimelineItemProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof timelineItemVariants> {
|
||||||
|
/**
|
||||||
|
* The date or time of the event
|
||||||
|
*/
|
||||||
|
date?: string;
|
||||||
|
/**
|
||||||
|
* The title of the event
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* Optional description of the event
|
||||||
|
*/
|
||||||
|
description?: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Optional icon to display
|
||||||
|
*/
|
||||||
|
icon?: LucideIcon;
|
||||||
|
/**
|
||||||
|
* Whether this is the active event
|
||||||
|
*/
|
||||||
|
active?: boolean;
|
||||||
|
/**
|
||||||
|
* Custom dot/indicator content
|
||||||
|
*/
|
||||||
|
customDot?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimelineItem = forwardRef<HTMLDivElement, TimelineItemProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
orientation,
|
||||||
|
date,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
active = false,
|
||||||
|
customDot,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const isVertical = orientation !== "horizontal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(timelineItemVariants({ variant, orientation, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Connector line (for vertical orientation) */}
|
||||||
|
{isVertical && (
|
||||||
|
<div className="absolute top-0 left-0 h-full w-px bg-border ml-1.5 -z-10" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date indicator (for vertical orientation) */}
|
||||||
|
{isVertical && date && (
|
||||||
|
<div className="text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
{date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Dot/indicator */}
|
||||||
|
{isVertical && (
|
||||||
|
<div className="flex-shrink-0 relative -left-[18px] mt-1">
|
||||||
|
{customDot || (
|
||||||
|
<div className={cn(
|
||||||
|
"w-3 h-3 rounded-full border-2",
|
||||||
|
active
|
||||||
|
? "bg-primary border-background"
|
||||||
|
: "bg-background border-primary",
|
||||||
|
Icon && "mt-1"
|
||||||
|
)}>
|
||||||
|
{Icon && (
|
||||||
|
<div className="bg-muted h-6 w-6 rounded-full flex items-center justify-center -mt-1.5 -ml-1.5">
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex-1 min-w-0",
|
||||||
|
isVertical ? "-mt-0.5" : ""
|
||||||
|
)}>
|
||||||
|
{/* Date (for horizontal orientation) */}
|
||||||
|
{!isVertical && date && (
|
||||||
|
<div className="text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
{date}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{title && (
|
||||||
|
<h4 className={cn(
|
||||||
|
"font-medium text-base",
|
||||||
|
active && "text-primary"
|
||||||
|
)}>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{description && (
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional content */}
|
||||||
|
{children && (
|
||||||
|
<div className={cn(
|
||||||
|
title || description ? "mt-2" : ""
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TimelineItem.displayName = "TimelineItem";
|
||||||
|
|
||||||
|
export { Timeline, TimelineItem, timelineVariants, timelineItemVariants };
|
||||||
Loading…
Reference in New Issue
Block a user