mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 00:11:35 +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 |
|
||||
| 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
|
||||
2. **File Uploader** - For uploading images and documents
|
||||
3. **Comparison Slider** - For comparing translations or versions
|
||||
| Component | Status | File Path | Notes |
|
||||
|-----------|--------|-----------|-------|
|
||||
| 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**:
|
||||
- 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
|
||||
|
||||
1. ✅ Create the missing typography components
|
||||
2. Implement remaining components:
|
||||
2. ✅ Implement specialized UI components:
|
||||
- Timeline component
|
||||
- File Uploader component
|
||||
- Comparison Slider component
|
||||
3. Implement components for Work and Author management:
|
||||
3. Focus on components for Work and Author management:
|
||||
- Work Editor
|
||||
- Work Header
|
||||
- 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 { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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
|
||||
* ```tsx
|
||||
* <EmptyState
|
||||
* icon={FileSearch}
|
||||
* icon={SearchX}
|
||||
* title="No results found"
|
||||
* description="Try adjusting your search or filters to find what you're looking for."
|
||||
* action={<Button>Clear filters</Button>}
|
||||
* description="Try adjusting your search or filter to find what you're looking for."
|
||||
* actions={
|
||||
* <Button>Clear filters</Button>
|
||||
* }
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: "bg-background border-border",
|
||||
subtle: "bg-muted/50 border-border",
|
||||
card: "bg-card border-border shadow-sm",
|
||||
ghost: "border-transparent bg-transparent",
|
||||
default: "bg-muted/40",
|
||||
card: "bg-card border shadow-sm",
|
||||
minimal: "",
|
||||
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: {
|
||||
default: "py-8 px-4 space-y-4",
|
||||
sm: "py-6 px-3 space-y-3",
|
||||
lg: "py-12 px-6 space-y-5",
|
||||
default: "py-12 px-4",
|
||||
sm: "py-6 px-4",
|
||||
lg: "py-16 px-4",
|
||||
fullPage: "min-h-[70vh]",
|
||||
},
|
||||
},
|
||||
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
|
||||
*/
|
||||
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({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
customIcon,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
iconSize = 48,
|
||||
iconClassName,
|
||||
actions,
|
||||
header,
|
||||
footer,
|
||||
image,
|
||||
imageAlt,
|
||||
imageHeight = 160,
|
||||
loading = false,
|
||||
children,
|
||||
...props
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn(emptyStateVariants({ variant, size }), className)}>
|
||||
{/* Icon */}
|
||||
{customIcon ? (
|
||||
customIcon
|
||||
) : Icon ? (
|
||||
<div className="rounded-full bg-muted p-3 mb-4">
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
<div
|
||||
className={cn(emptyStateVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
>
|
||||
{/* Custom header content */}
|
||||
{header && <div className="mb-6">{header}</div>}
|
||||
|
||||
{/* Icon or image */}
|
||||
{image ? (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={image}
|
||||
alt={imageAlt || title || "Empty state illustration"}
|
||||
style={{ height: imageHeight }}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</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 */}
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
{title && (
|
||||
<h3 className={cn(
|
||||
"text-xl font-semibold",
|
||||
loading && "animate-pulse"
|
||||
)}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{action && (
|
||||
<div className="mt-2">{action}</div>
|
||||
{/* Custom content */}
|
||||
{children && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* Stat card component for displaying statistical metrics
|
||||
* Stat Card component for displaying statistics and metrics
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <StatCard
|
||||
* title="Total Works"
|
||||
* value="124"
|
||||
* title="Total Readers"
|
||||
* value="12,345"
|
||||
* description="+12% from last month"
|
||||
* trend="up"
|
||||
* icon={Book}
|
||||
* icon={Users}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
const statCardVariants = cva(
|
||||
"rounded-lg border p-4 flex flex-col space-y-2",
|
||||
"relative overflow-hidden rounded-lg p-6 shadow-sm",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background border-border",
|
||||
primary: "bg-primary/10 border-primary/20",
|
||||
secondary: "bg-secondary/10 border-secondary/20",
|
||||
accent: "bg-russet/10 border-russet/20 dark:bg-russet/5",
|
||||
success: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-900/30",
|
||||
warning: "bg-amber-50 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/30",
|
||||
danger: "bg-rose-50 border-rose-200 dark:bg-rose-950/20 dark:border-rose-900/30",
|
||||
default: "bg-card border",
|
||||
filled: "bg-primary text-primary-foreground",
|
||||
outline: "border border-primary/20 bg-background",
|
||||
ghost: "bg-muted/30 border-none shadow-none",
|
||||
destructive: "bg-destructive text-destructive-foreground",
|
||||
success: "bg-emerald-50 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-900/50 border",
|
||||
},
|
||||
size: {
|
||||
default: "p-4",
|
||||
sm: "p-3",
|
||||
lg: "p-6",
|
||||
default: "p-6",
|
||||
sm: "p-4",
|
||||
lg: "p-8",
|
||||
},
|
||||
},
|
||||
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;
|
||||
/**
|
||||
* Optional trend direction
|
||||
*/
|
||||
trend?: "up" | "down" | "neutral";
|
||||
description?: React.ReactNode;
|
||||
/**
|
||||
* Optional icon to display
|
||||
*/
|
||||
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({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
trend,
|
||||
icon: Icon,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
isLoading = false,
|
||||
trend,
|
||||
trendIsPositive,
|
||||
loading = false,
|
||||
footer,
|
||||
badge,
|
||||
children,
|
||||
...props
|
||||
}: StatCardProps) {
|
||||
// Determine the color for trend
|
||||
const trendColor = trend === "up"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: trend === "down"
|
||||
? "text-rose-600 dark:text-rose-400"
|
||||
: "text-gray-600 dark:text-gray-400";
|
||||
|
||||
// Determine the arrow for trend
|
||||
const trendSymbol = trend === "up"
|
||||
? "↑"
|
||||
: trend === "down"
|
||||
? "↓"
|
||||
: "→";
|
||||
// Determine if trend is positive based on direction and trendIsPositive prop
|
||||
const isPositive = trendIsPositive !== undefined
|
||||
? trendIsPositive
|
||||
: trend === "up";
|
||||
|
||||
const trendColor = trend === "none"
|
||||
? "text-muted-foreground"
|
||||
: isPositive
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-destructive";
|
||||
|
||||
return (
|
||||
<div className={cn(statCardVariants({ variant, size }), className)}>
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
<div
|
||||
className={cn(statCardVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
>
|
||||
{/* Optional badge */}
|
||||
{badge && (
|
||||
<div className="absolute top-2 right-2">
|
||||
{badge}
|
||||
</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 */}
|
||||
<div className={cn(
|
||||
"text-2xl font-bold",
|
||||
loading && "animate-pulse bg-muted/40 rounded h-8 w-24",
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Custom content */}
|
||||
{children && (
|
||||
<div className={cn(loading && "animate-pulse")}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
{Icon && (
|
||||
<div className={cn(
|
||||
"rounded-full p-1.5",
|
||||
variant === "primary" && "bg-primary/10 text-primary",
|
||||
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",
|
||||
"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-4 w-4" />
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
{isLoading ? (
|
||||
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-800 animate-pulse rounded" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
)}
|
||||
|
||||
{/* Description with trend */}
|
||||
{description && (
|
||||
<div className="text-xs flex items-center gap-1">
|
||||
{trend && <span className={cn("font-medium", trendColor)}>{trendSymbol}</span>}
|
||||
<span className={cn(trend ? trendColor : "text-muted-foreground")}>
|
||||
{description}
|
||||
</span>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Command, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
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
|
||||
* ```tsx
|
||||
* <TagInput
|
||||
* value={tags}
|
||||
* onChange={setTags}
|
||||
* placeholder="Add tags..."
|
||||
* maxTags={5}
|
||||
* suggestions={["React", "TypeScript", "UI"]}
|
||||
* tags={tags}
|
||||
* 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
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* 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({
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = "Add tag...",
|
||||
className,
|
||||
inputClassName,
|
||||
maxTags,
|
||||
disabled = false,
|
||||
export const TagInput = forwardRef<HTMLInputElement, TagInputProps>(({
|
||||
tags = [],
|
||||
onTagsChange,
|
||||
suggestions = [],
|
||||
maxTags,
|
||||
minLength = 1,
|
||||
maxLength = 50,
|
||||
allowDuplicates = false,
|
||||
validate,
|
||||
maxTagLength = 30,
|
||||
}: TagInputProps) {
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
lowercase = false,
|
||||
allowNew = true,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
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);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
if (inputValue) {
|
||||
const filtered = suggestions.filter(
|
||||
(suggestion) =>
|
||||
suggestion.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||
!value.includes(suggestion)
|
||||
suggestion =>
|
||||
suggestion.label.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||
(allowDuplicates || !tags.some(tag => tag.value === suggestion.value))
|
||||
);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
} 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
|
||||
const addTag = (tag: string) => {
|
||||
const trimmedTag = tag.trim();
|
||||
// Add a tag
|
||||
const addTag = (tag: TagItem) => {
|
||||
if (disabled || readOnly) return;
|
||||
|
||||
// Skip if empty, too long, already exists, or fails validation
|
||||
if (
|
||||
!trimmedTag ||
|
||||
trimmedTag.length > maxTagLength ||
|
||||
value.includes(trimmedTag) ||
|
||||
(validate && !validate(trimmedTag)) ||
|
||||
(maxTags !== undefined && value.length >= maxTags)
|
||||
) {
|
||||
// Check maximum tags
|
||||
if (maxTags && tags.length >= maxTags) {
|
||||
setInputError(`Maximum ${maxTags} tags allowed`);
|
||||
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("");
|
||||
setInputError(null);
|
||||
|
||||
// Focus back on input
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// Remove a tag
|
||||
const removeTag = (index: number) => {
|
||||
if (disabled) return;
|
||||
const newTags = [...value];
|
||||
newTags.splice(index, 1);
|
||||
onChange(newTags);
|
||||
const removeTag = (indexToRemove: number) => {
|
||||
if (disabled || readOnly) return;
|
||||
|
||||
onTagsChange(tags.filter((_, index) => index !== indexToRemove));
|
||||
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>) => {
|
||||
if (e.key === "Enter" && inputValue) {
|
||||
// Clear error on new input
|
||||
if (inputError) {
|
||||
setInputError(null);
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag(inputValue);
|
||||
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
||||
removeTag(value.length - 1);
|
||||
createNewTag();
|
||||
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
} else if (e.key === ',' || e.key === ';') {
|
||||
e.preventDefault();
|
||||
createNewTag();
|
||||
}
|
||||
};
|
||||
|
||||
// Select a suggestion
|
||||
const selectSuggestion = (suggestion: string) => {
|
||||
addTag(suggestion);
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div
|
||||
// Custom tag renderer or default
|
||||
const renderTag = (tag: TagItem, index: number) => {
|
||||
if (tagRenderer) {
|
||||
return tagRenderer(tag, index, () => removeTag(index));
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={`${tag.value}-${index}`}
|
||||
variant={tag.variant || "default"}
|
||||
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"
|
||||
"m-1 max-w-full truncate",
|
||||
tag.disabled && "opacity-60",
|
||||
tagClassName
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{/* Display existing tags */}
|
||||
{value.map((tag, index) => (
|
||||
<Badge
|
||||
key={`${tag}-${index}`}
|
||||
variant="secondary"
|
||||
className="px-2 py-1 flex items-center gap-1"
|
||||
>
|
||||
{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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(index);
|
||||
}}
|
||||
onClick={() => removeTag(index)}
|
||||
className="h-4 w-4 p-0 ml-1"
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">Remove {tag}</span>
|
||||
<span className="sr-only">Remove {tag.label}</span>
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={cn(
|
||||
"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>
|
||||
)}
|
||||
|
||||
{/* Input for new tags */}
|
||||
{(!maxTags || value.length < maxTags) && (
|
||||
<div className="relative flex-1 min-w-[120px]">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
{/* Render input */}
|
||||
{!readOnly && (
|
||||
<div className="flex flex-1 items-center min-w-[180px]">
|
||||
<input
|
||||
ref={handleRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => inputValue && setShowSuggestions(filteredSuggestions.length > 0)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||
placeholder={placeholder}
|
||||
className={cn("border-0 ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 p-0 h-8", inputClassName)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"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 */}
|
||||
{showSuggestions && (
|
||||
<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">
|
||||
{filteredSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
className="w-full text-left px-2 py-1 hover:bg-accent hover:text-accent-foreground text-sm"
|
||||
onClick={() => selectSuggestion(suggestion)}
|
||||
type="button"
|
||||
{suggestions.length > 0 && (
|
||||
<Popover open={suggestionsOpen} onOpenChange={setSuggestionsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 ml-1"
|
||||
disabled={disabled || readOnly}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<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>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Add button */}
|
||||
{allowNew && inputValue && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={createNewTag}
|
||||
disabled={disabled || readOnly}
|
||||
className="h-7 p-1 px-2 ml-1"
|
||||
>
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add button for mobile usability */}
|
||||
{inputValue && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => addTag(inputValue)}
|
||||
className="h-8 w-8 p-0 rounded-full"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="sr-only">Add tag</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show max tags limit */}
|
||||
{maxTags && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{value.length} of {maxTags} tags used
|
||||
</p>
|
||||
{/* Error message */}
|
||||
{inputError && (
|
||||
<p className="text-destructive text-xs mt-1">{inputError}</p>
|
||||
)}
|
||||
</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