From ec099f65b4f9a406606e8f1e7704e4fcdb233a72 Mon Sep 17 00:00:00 2001 From: mukimovd <41473651-mukimovd@users.noreply.replit.com> Date: Sat, 10 May 2025 21:17:57 +0000 Subject: [PATCH] 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 --- COMPONENT-IMPLEMENTATION-TRACKER.md | 16 +- .../src/components/ui/comparison-slider.tsx | 241 +++++++++ client/src/components/ui/empty-state.tsx | 254 +++++++-- client/src/components/ui/file-uploader.tsx | 458 +++++++++++++++++ client/src/components/ui/stat-card.tsx | 244 ++++++--- client/src/components/ui/tag-input.tsx | 481 +++++++++++++----- client/src/components/ui/timeline.tsx | 219 ++++++++ 7 files changed, 1648 insertions(+), 265 deletions(-) diff --git a/COMPONENT-IMPLEMENTATION-TRACKER.md b/COMPONENT-IMPLEMENTATION-TRACKER.md index 7eb5e39..6be5a4b 100644 --- a/COMPONENT-IMPLEMENTATION-TRACKER.md +++ b/COMPONENT-IMPLEMENTATION-TRACKER.md @@ -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 diff --git a/client/src/components/ui/comparison-slider.tsx b/client/src/components/ui/comparison-slider.tsx index e69de29..12a1969 100644 --- a/client/src/components/ui/comparison-slider.tsx +++ b/client/src/components/ui/comparison-slider.tsx @@ -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 + * + * + * Original + * + * + * Translated + * + * + * ``` + */ + +interface ComparisonSliderContextValue { + position: number; + setPosition: React.Dispatch>; + containerRef: React.RefObject; +} + +const ComparisonSliderContext = React.createContext(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(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 ( + +
+ {children} + + {/* Handle/Divider */} +
+
+ +
+
+
+
+ ); +}; + +interface SliderItemProps { + /** + * Children components + */ + children: React.ReactNode; + /** + * CSS class for the component + */ + className?: string; +} + +const First = ({ children, className }: SliderItemProps) => { + const { position, containerRef } = useComparisonSlider(); + + return ( +
+ {children} +
+ ); +}; + +const Second = ({ children, className }: SliderItemProps) => { + const { position, containerRef } = useComparisonSlider(); + + return ( +
+ {children} +
+ ); +}; + +ComparisonSlider.First = First; +ComparisonSlider.Second = Second; + +export { ComparisonSlider }; \ No newline at end of file diff --git a/client/src/components/ui/empty-state.tsx b/client/src/components/ui/empty-state.tsx index 8a42e9a..5f6bd92 100644 --- a/client/src/components/ui/empty-state.tsx +++ b/client/src/components/ui/empty-state.tsx @@ -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 * Clear filters} + * description="Try adjusting your search or filter to find what you're looking for." + * actions={ + * + * } * /> * ``` */ 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 { +export interface EmptyStateProps + extends React.HTMLAttributes, + VariantProps { + /** + * 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 ( -
- {/* Icon */} - {customIcon ? ( - customIcon - ) : Icon ? ( -
- +
+ {/* Custom header content */} + {header &&
{header}
} + + {/* Icon or image */} + {image ? ( +
+ {imageAlt
- ) : null} + ) : Icon && ( +
+
+ )} {/* Title */} -

{title}

+ {title && ( +

+ {title} +

+ )} {/* Description */} {description && ( -

{description}

+

+ {description} +

)} - {/* Action */} - {action && ( -
{action}
+ {/* Custom content */} + {children && ( +
+ {children} +
+ )} + + {/* Actions */} + {actions && ( +
+ {actions} +
+ )} + + {/* Footer content */} + {footer && ( +
+ {footer} +
)}
); -} \ No newline at end of file +} + +export interface EmptySearchProps extends Omit { + /** + * 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 ( + + {onReset && ( + + )} + {actions} +
+ } + {...props} + /> + ); +} + +export interface EmptyFilterProps extends Omit { + /** + * Action to clear filters + */ + onClearFilters?: () => void; +} + +export function EmptyFilter({ + onClearFilters, + actions, + ...props +}: EmptyFilterProps) { + return ( + + {onClearFilters && ( + + )} + {actions} +
+ } + {...props} + /> + ); +} + +export { emptyStateVariants }; \ No newline at end of file diff --git a/client/src/components/ui/file-uploader.tsx b/client/src/components/ui/file-uploader.tsx index e69de29..9fc30e7 100644 --- a/client/src/components/ui/file-uploader.tsx +++ b/client/src/components/ui/file-uploader.tsx @@ -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 + * 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, "onDrop" | "onError" | "onChange">, + VariantProps { + /** + * 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; + /** + * 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 ; + } else if (file.type.startsWith('text/')) { + return ; + } else if (file.type.includes('pdf')) { + return ; + } else if (file.type.includes('code') || file.type.includes('javascript') || file.type.includes('html')) { + return ; + } else { + return ; + } +}; + +// 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( + ({ + 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([]); + const [dragging, setDragging] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const fileInputRef = useRef(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) => { + const selectedFiles = Array.from(e.target.files || []); + processFiles(selectedFiles); + }; + + // Handle file drop + const handleDrop = (e: DragEvent) => { + 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) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) setDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + 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 ( +
+ {file.name} URL.revokeObjectURL(URL.createObjectURL(file))} + /> +
+ ); + } + + return ( +
+ {getFileIcon(file)} +
+ ); + }; + + return ( +
+ {/* File Input (hidden) */} + 1} + className="hidden" + onChange={handleFileChange} + disabled={disabled} + /> + + {/* Drag & Drop Area */} +
+
+ + +
+ {state === "error" ? ( + <> +

Upload failed

+

{error}

+ + ) : state === "success" ? ( + <> +

Upload complete

+

Files uploaded successfully

+ + ) : ( + <> + +

+ {instructionText} + {accept && {accept.replaceAll(',', ', ')}} + {maxSize && Max size: {formatFileSize(maxSize)}} +

+ + )} +
+
+
+ + {/* Upload Progress */} + {uploading && ( +
+
+ Uploading... + {Math.round(uploadProgress)}% +
+ +
+ )} + + {/* File List */} + {showSelectedFiles && files.length > 0 && ( +
    + {files.map((file, index) => ( +
  • + {renderPreview(file)} + +
    +

    {file.name}

    +

    {formatFileSize(file.size)}

    +
    + + {state === "success" ? ( + + ) : ( + + )} +
  • + ))} +
+ )} + + {/* Upload Button (when not auto-uploading) */} + {!autoUpload && files.length > 0 && uploadFunction && !success && ( + + )} +
+ ); + } +); + +FileUploader.displayName = "FileUploader"; + +export { FileUploader, fileUploaderVariants }; \ No newline at end of file diff --git a/client/src/components/ui/stat-card.tsx b/client/src/components/ui/stat-card.tsx index 2ac2933..f6f6616 100644 --- a/client/src/components/ui/stat-card.tsx +++ b/client/src/components/ui/stat-card.tsx @@ -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 * * ``` */ 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 { +export interface StatCardProps + extends React.HTMLAttributes, + VariantProps { /** - * 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 ( -
- {/* Header with icon and title */} -
-

{title}

+
+ {/* Optional badge */} + {badge && ( +
+ {badge} +
+ )} + +
+
+ {/* Title/label */} + {title && ( +

+ {title} +

+ )} + + {/* Value */} +
+ {loading && typeof value === "undefined" ? null : value} +
+ + {/* Description with trend indicator */} + {description && ( +

+ {description} +

+ )} + + {/* Custom content */} + {children && ( +
+ {children} +
+ )} +
+ + {/* Icon */} {Icon && (
- +
)}
- {/* Value */} - {isLoading ? ( -
- ) : ( -
{value}
- )} - - {/* Description with trend */} - {description && ( -
- {trend && {trendSymbol}} - - {description} - + {/* Footer */} + {footer && ( +
+ {footer}
)}
); -} \ No newline at end of file +} + +export interface StatGroupProps extends React.HTMLAttributes { + /** + * 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 ( +
+ {children} +
+ ); +} + +export { statCardVariants }; \ No newline at end of file diff --git a/client/src/components/ui/tag-input.tsx b/client/src/components/ui/tag-input.tsx index b52c305..02ebb71 100644 --- a/client/src/components/ui/tag-input.tsx +++ b/client/src/components/ui/tag-input.tsx @@ -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 * * ``` */ -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, "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(({ + tags = [], + onTagsChange, suggestions = [], + maxTags, + minLength = 1, + maxLength = 50, + allowDuplicates = false, validate, - maxTagLength = 30, -}: TagInputProps) { - const [inputValue, setInputValue] = useState(""); - const [showSuggestions, setShowSuggestions] = useState(false); - const [filteredSuggestions, setFilteredSuggestions] = useState([]); + 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(null); + const [filteredSuggestions, setFilteredSuggestions] = useState(suggestions); const inputRef = useRef(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) => { - 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 ( -
-
{ + if (tagRenderer) { + return tagRenderer(tag, index, () => removeTag(index)); + } + + return ( + inputRef.current?.focus()} > - {/* Display existing tags */} - {value.map((tag, index) => ( - - {tag} +
+ {tag.icon && {tag.icon}} + {tag.label} + {!readOnly && !tag.disabled && ( - - ))} + )} +
+
+ ); + }; + + return ( +
+
+ {/* Render existing tags */} + {tags.length > 0 ? ( +
+ {tags.map((tag, index) => renderTag(tag, index))} +
+ ) : ( +
{emptyPlaceholder}
+ )} - {/* Input for new tags */} - {(!maxTags || value.length < maxTags) && ( -
- + 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 && ( -
- {filteredSuggestions.map((suggestion) => ( - - ))} -
+ + Show suggestions + + + + + + {filteredSuggestions.length > 0 ? ( + filteredSuggestions.map((suggestion) => ( + { + addTag(suggestion); + setSuggestionsOpen(false); + }} + > +
+ {suggestion.icon || } + {suggestion.label} +
+ tag.value === suggestion.value) + ? "opacity-100" + : "opacity-0" + )} + /> +
+ )) + ) : ( +
+ No suggestions available +
+ )} +
+
+
+ + )} + + {/* Add button */} + {allowNew && inputValue && ( + )}
)} - - {/* Add button for mobile usability */} - {inputValue && !disabled && ( - - )}
- {/* Show max tags limit */} - {maxTags && ( -

- {value.length} of {maxTags} tags used -

+ {/* Error message */} + {inputError && ( +

{inputError}

)}
); -} \ No newline at end of file +}); + +TagInput.displayName = "TagInput"; + +export default TagInput; \ No newline at end of file diff --git a/client/src/components/ui/timeline.tsx b/client/src/components/ui/timeline.tsx index e69de29..74d2036 100644 --- a/client/src/components/ui/timeline.tsx +++ b/client/src/components/ui/timeline.tsx @@ -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 + * + * + * + * + * ``` + */ + +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, + VariantProps {} + +const Timeline = forwardRef( + ({ className, variant, orientation, ...props }, ref) => { + return ( +
+ ); + } +); + +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, + VariantProps { + /** + * 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( + ({ + className, + variant, + orientation, + date, + title, + description, + icon: Icon, + active = false, + customDot, + children, + ...props + }, ref) => { + const isVertical = orientation !== "horizontal"; + + return ( +
+ {/* Connector line (for vertical orientation) */} + {isVertical && ( +
+ )} + + {/* Date indicator (for vertical orientation) */} + {isVertical && date && ( +
+ {date} +
+ )} + +
+ {/* Dot/indicator */} + {isVertical && ( +
+ {customDot || ( +
+ {Icon && ( +
+ +
+ )} +
+ )} +
+ )} + + {/* Content */} +
+ {/* Date (for horizontal orientation) */} + {!isVertical && date && ( +
+ {date} +
+ )} + + {/* Title */} + {title && ( +

+ {title} +

+ )} + + {/* Description */} + {description && ( +
+ {description} +
+ )} + + {/* Additional content */} + {children && ( +
+ {children} +
+ )} +
+
+
+ ); + } +); + +TimelineItem.displayName = "TimelineItem"; + +export { Timeline, TimelineItem, timelineVariants, timelineItemVariants }; \ No newline at end of file