From 6b0093808471f5e34c20217270f42a2cc3a08d75 Mon Sep 17 00:00:00 2001 From: mukimovd <41473651-mukimovd@users.noreply.replit.com> Date: Sat, 10 May 2025 21:07:28 +0000 Subject: [PATCH] Build out initial UI components and component implementation tracker Implement ActivityFeed, DashboardHeader, EmptyState, StatCard, TagInput, DataTable, WorkPreview components and add component implementation tracker. 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/90ee7bd9-7d3b-4bfb-93e2-2aa13b83f414.jpg --- COMPONENT-IMPLEMENTATION-TRACKER.md | 101 +++++ .../components/dashboard/activity-feed.tsx | 243 +++++++++++ .../components/dashboard/dashboard-header.tsx | 121 +++++ client/src/components/ui/data-table.tsx | 332 ++++++++++++++ client/src/components/ui/empty-state.tsx | 105 +++++ client/src/components/ui/stat-card.tsx | 141 ++++++ client/src/components/ui/tag-input.tsx | 233 ++++++++++ client/src/components/work/work-preview.tsx | 412 ++++++++++++++++++ 8 files changed, 1688 insertions(+) create mode 100644 COMPONENT-IMPLEMENTATION-TRACKER.md create mode 100644 client/src/components/ui/data-table.tsx create mode 100644 client/src/components/work/work-preview.tsx diff --git a/COMPONENT-IMPLEMENTATION-TRACKER.md b/COMPONENT-IMPLEMENTATION-TRACKER.md new file mode 100644 index 0000000..7c86ede --- /dev/null +++ b/COMPONENT-IMPLEMENTATION-TRACKER.md @@ -0,0 +1,101 @@ +# Component Implementation Tracker + +This document tracks the implementation status of all components for the Tercul platform. + +## UI Components + +### Phase 1: Critical UI Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Rich Text Editor | ✅ Implemented | `client/src/components/ui/rich-text-editor.tsx` | Complete with formatting tools and preview mode | +| Stat Card | ✅ Implemented | `client/src/components/ui/stat-card.tsx` | Implemented with variants and trend indicators | +| Empty State | ✅ Implemented | `client/src/components/ui/empty-state.tsx` | Complete with icon, title, description and action support | +| Tag Input | ✅ Implemented | `client/src/components/ui/tag-input.tsx` | Implemented with suggestion support | +| Data Table | ✅ Implemented | `client/src/components/ui/data-table.tsx` | Complete with sorting, filtering, and pagination | + +### Phase 2: Dashboard Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Dashboard Header | ✅ Implemented | `client/src/components/dashboard/dashboard-header.tsx` | Complete with title, description, and actions | +| Activity Feed | ✅ Implemented | `client/src/components/dashboard/activity-feed.tsx` | Implemented with grouped timeline | +| Content Queue | ⬜️ Planned | `client/src/components/dashboard/content-queue.tsx` | | + +### Phase 3: Work Management Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Work Preview | ✅ Implemented | `client/src/components/work/work-preview.tsx` | Complete with multiple display variants | +| Work Editor | ⬜️ Planned | `client/src/components/work/work-editor.tsx` | | +| Work Header | ⬜️ Planned | `client/src/components/work/work-header.tsx` | | +| Comparison View | ⬜️ Planned | `client/src/components/work/comparison-view.tsx` | | + +### Phase 4: Author Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Author Editor | ⬜️ Planned | `client/src/components/authors/author-editor.tsx` | | +| Author Card | ⬜️ Planned | `client/src/components/authors/author-card.tsx` | | +| Author Header | ⬜️ Planned | `client/src/components/authors/author-header.tsx` | | + +### Phase 5: Comment and Annotation Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Comment Thread | ⬜️ Planned | `client/src/components/comment/comment-thread.tsx` | | +| Annotation Editor | ⬜️ Planned | `client/src/components/annotation/annotation-editor.tsx` | | +| Annotation Browser | ⬜️ Planned | `client/src/components/annotation/annotation-browser.tsx` | | + +### Phase 6: Search and User Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Advanced Search | ⬜️ Planned | `client/src/components/search/advanced-search.tsx` | | +| Profile Card | ⬜️ Planned | `client/src/components/user/profile-card.tsx` | | +| Reading Progress | ⬜️ Planned | `client/src/components/user/reading-progress.tsx` | | + +### Phase 7: Blog Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Blog Editor | ✅ Implemented | `client/src/components/blog/blog-editor.tsx` | Complete, uses rich text editor | +| Tag Manager | ✅ Implemented | `client/src/components/blog/tag-manager.tsx` | Complete, specialized for blog tags | +| Blog Preview | ⬜️ Planned | `client/src/components/blog/blog-preview.tsx` | | +| Publication Scheduler | ⬜️ Planned | `client/src/components/blog/publication-scheduler.tsx` | | + +## Layout Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Page Layout | ✅ Implemented | `client/src/components/layout/PageLayout.tsx` | Basic page layout with navigation | +| App Shell | ⬜️ Planned | `client/src/components/layout/app-shell.tsx` | | +| Sidebar | ⬜️ Planned | `client/src/components/layout/sidebar.tsx` | | +| Dashboard Layout | ⬜️ Planned | `client/src/components/layout/dashboard-layout.tsx` | | +| Reading Layout | ⬜️ Planned | `client/src/components/layout/reading-layout.tsx` | | + +## Typography Components + +| Component | Status | File Path | Notes | +|-----------|--------|-----------|-------| +| Heading | ⬜️ Missing | `client/src/components/ui/typography/heading.tsx` | Priority: High | +| Paragraph | ⬜️ Missing | `client/src/components/ui/typography/paragraph.tsx` | Priority: High | +| BlockQuote | ⬜️ Missing | `client/src/components/ui/typography/blockquote.tsx` | Priority: Medium | +| Code Block | ⬜️ Missing | `client/src/components/ui/typography/code-block.tsx` | Priority: Medium | +| Prose Container | ⬜️ Missing | `client/src/components/ui/typography/prose.tsx` | Priority: High | + +## Missing UI Components + +Based on the component analysis, the following high-priority UI components are still missing: + +1. **Typography components** - Heading, Paragraph, BlockQuote, Code Block, Prose Container +2. **Timeline component** - For displaying chronological events +3. **File Uploader** - For uploading images and documents +4. **Comparison Slider** - For comparing translations or versions + +## Next Implementation Steps + +1. Create the missing typography components +2. Complete the remaining Phase 1 and Phase 2 components +3. Implement components for Work and Author management +4. Add advanced components for annotations and comments \ No newline at end of file diff --git a/client/src/components/dashboard/activity-feed.tsx b/client/src/components/dashboard/activity-feed.tsx index e69de29..9686a7c 100644 --- a/client/src/components/dashboard/activity-feed.tsx +++ b/client/src/components/dashboard/activity-feed.tsx @@ -0,0 +1,243 @@ +import { cn } from "@/lib/utils"; +import { LucideIcon, Clock, User } from "lucide-react"; +import { format, formatDistanceToNow } from "date-fns"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Activity feed component for displaying recent activities + * + * @example + * ```tsx + * + * ``` + */ + +export interface ActivityItem { + /** + * Unique identifier + */ + id: string | number; + /** + * Activity type + */ + type: string; + /** + * Activity description + */ + description: string; + /** + * When the activity occurred + */ + timestamp: Date | string; + /** + * User who performed the activity + */ + user?: { + id: string | number; + name: string; + avatar?: string; + }; + /** + * Icon to display for this activity + */ + icon?: LucideIcon; + /** + * Link URL for this activity + */ + href?: string; + /** + * Optional metadata + */ + meta?: Record; +} + +export interface ActivityFeedProps { + /** + * List of activities to display + */ + activities: ActivityItem[]; + /** + * Optional title for the feed + */ + title?: string; + /** + * Message to display when there are no activities + */ + emptyMessage?: string; + /** + * Whether the feed is loading + */ + isLoading?: boolean; + /** + * Maximum number of items to show + */ + maxItems?: number; + /** + * Additional CSS classes + */ + className?: string; +} + +export function ActivityFeed({ + activities, + title, + emptyMessage = "No recent activity", + isLoading = false, + maxItems, + className, +}: ActivityFeedProps) { + // Limit number of activities if maxItems is specified + const displayActivities = maxItems + ? activities.slice(0, maxItems) + : activities; + + // Group activities by date + const groupedActivities: Record = {}; + + displayActivities.forEach(activity => { + const date = new Date(activity.timestamp); + const dateKey = format(date, 'yyyy-MM-dd'); + + if (!groupedActivities[dateKey]) { + groupedActivities[dateKey] = []; + } + + groupedActivities[dateKey].push(activity); + }); + + // Sort date keys in descending order + const sortedDateKeys = Object.keys(groupedActivities).sort((a, b) => b.localeCompare(a)); + + // Helper function to format date groups + const formatDateGroup = (dateKey: string) => { + const date = new Date(dateKey); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (format(date, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')) { + return 'Today'; + } else if (format(date, 'yyyy-MM-dd') === format(yesterday, 'yyyy-MM-dd')) { + return 'Yesterday'; + } else { + return format(date, 'MMMM d, yyyy'); + } + }; + + // Format the relative time + const formatTime = (timestamp: Date | string) => { + const date = new Date(timestamp); + return format(date, 'h:mm a'); + }; + + return ( +
+ {/* Title */} + {title &&

{title}

} + + {/* Activity List */} +
+ {isLoading ? ( + // Loading state + Array.from({ length: 3 }).map((_, index) => ( + + )) + ) : displayActivities.length === 0 ? ( + // Empty state +

{emptyMessage}

+ ) : ( + // Grouped activities + sortedDateKeys.map(dateKey => ( +
+

+ {formatDateGroup(dateKey)} +

+
    + {groupedActivities[dateKey].map(activity => ( +
  • + {/* Timeline connector */} +
    + + {/* Activity dot */} +
    + + {/* Activity content */} +
    + {/* Activity icon or user avatar */} + {activity.user ? ( + + + + {activity.user.name.split(' ').map(n => n[0]).join('')} + + + ) : activity.icon ? ( +
    + +
    + ) : null} + + {/* Activity details */} +
    +
    + {activity.user && ( + {activity.user.name} + )}{' '} + {activity.description} +
    + + {/* Time */} +
    + + {formatTime(activity.timestamp)} +
    +
    +
    +
  • + ))} +
+
+ )) + )} +
+ + {/* Show more link */} + {maxItems && activities.length > maxItems && ( +
+ { + e.preventDefault(); + // Handle showing more activities or navigation + }} + > + View all activities + +
+ )} +
+ ); +} + +// Skeleton loader for activity items +function ActivityItemSkeleton() { + return ( +
+
+
+ + + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/dashboard/dashboard-header.tsx b/client/src/components/dashboard/dashboard-header.tsx index e69de29..5b0eb1d 100644 --- a/client/src/components/dashboard/dashboard-header.tsx +++ b/client/src/components/dashboard/dashboard-header.tsx @@ -0,0 +1,121 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ChevronLeft, LucideIcon } from "lucide-react"; + +/** + * Dashboard header component for dashboard pages + * + * @example + * ```tsx + * Create Post} + * backHref="/dashboard" + * /> + * ``` + */ + +export interface DashboardHeaderProps { + /** + * Page title + */ + title: string; + /** + * Optional page description + */ + description?: string; + /** + * Optional action element (usually a button) + */ + action?: React.ReactNode; + /** + * Optional secondary actions + */ + secondaryActions?: React.ReactNode; + /** + * Optional back link URL + */ + backHref?: string; + /** + * Optional back link text + */ + backText?: string; + /** + * Optional back link click handler + */ + onBackClick?: () => void; + /** + * Optional icon to display + */ + icon?: LucideIcon; + /** + * Additional CSS classes + */ + className?: string; +} + +export function DashboardHeader({ + title, + description, + action, + secondaryActions, + backHref, + backText = "Back", + onBackClick, + icon: Icon, + className, +}: DashboardHeaderProps) { + return ( +
+ {/* Back Link */} + {(backHref || onBackClick) && ( + + )} + + {/* Header Content */} +
+
+ {/* Optional Icon */} + {Icon && ( +
+ +
+ )} + + {/* Title and Description */} +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ + {/* Actions */} +
+ {secondaryActions} + {action} +
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/ui/data-table.tsx b/client/src/components/ui/data-table.tsx new file mode 100644 index 0000000..249a820 --- /dev/null +++ b/client/src/components/ui/data-table.tsx @@ -0,0 +1,332 @@ +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableFooter, +} from "@/components/ui/table"; +import { + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + getSortedRowModel, + getFilteredRowModel, + ColumnDef, + SortingState, + ColumnFiltersState, + VisibilityState, +} from "@tanstack/react-table"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ChevronDown, ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react"; +import { EmptyState } from "@/components/ui/empty-state"; +import { cn } from "@/lib/utils"; + +/** + * DataTable component for displaying and managing tabular data + * + * @example + * ```tsx + * + * ``` + */ + +export interface DataTableProps { + /** + * Table column definitions + */ + columns: ColumnDef[]; + /** + * Data to display in the table + */ + data: TData[]; + /** + * Key to use for global search filtering + */ + searchKey?: string; + /** + * Total count of items (for server-side pagination) + */ + totalCount?: number; + /** + * Whether the table is in a loading state + */ + isLoading?: boolean; + /** + * Empty state configuration + */ + emptyState?: { + title: string; + description?: string; + action?: React.ReactNode; + }; + /** + * Row click handler + */ + onRowClick?: (row: TData) => void; + /** + * Initial page size + */ + initialPageSize?: number; + /** + * Show/hide table elements + */ + showElements?: { + search?: boolean; + pagination?: boolean; + columnVisibility?: boolean; + }; + /** + * Additional CSS classes + */ + className?: string; +} + +export function DataTable({ + columns, + data, + searchKey, + totalCount, + isLoading = false, + emptyState, + onRowClick, + initialPageSize = 10, + showElements = { + search: true, + pagination: true, + columnVisibility: true, + }, + className, +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [rowSelection, setRowSelection] = useState({}); + const [globalFilter, setGlobalFilter] = useState(""); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + onGlobalFilterChange: setGlobalFilter, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + globalFilter, + }, + initialState: { + pagination: { + pageSize: initialPageSize, + }, + }, + }); + + // Handle empty state + const isEmpty = table.getFilteredRowModel().rows.length === 0; + + return ( +
+ {/* Table Controls */} +
+ {/* Search Input */} + {showElements.search && ( +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+ )} + + {/* View/Filter Options */} +
+ {showElements.columnVisibility && ( + + + + + + {table.getAllColumns() + .filter(column => column.getCanHide()) + .map(column => ( + column.toggleVisibility(!!value)} + > + {column.id.replace(/([A-Z])/g, ' $1').trim()} + + ))} + + + )} +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: " 🔼", + desc: " 🔽", + }[header.column.getIsSorted() as string] ?? null} +
+ )} +
+ ))} +
+ ))} +
+ + {isLoading ? ( + // Loading state + Array.from({ length: 5 }).map((_, index) => ( + + {columns.map((_, cellIndex) => ( + +
+ + ))} + + )) + ) : isEmpty ? ( + // Empty state + + + {emptyState ? ( +
+ +
+ ) : ( +
No results found
+ )} +
+
+ ) : ( + // Data rows + table.getRowModel().rows.map((row) => ( + onRowClick(row.original) : undefined} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + )} + + + {showElements.pagination && !isEmpty && ( + + + +
+
+ Showing{" "} + + {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} + {" "} + to{" "} + + {Math.min( + (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, + table.getFilteredRowModel().rows.length + )} + {" "} + of {table.getFilteredRowModel().rows.length} results +
+
+ + +
+
+
+
+
+ )} +
+
+
+ ); +} \ 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 e69de29..8a42e9a 100644 --- a/client/src/components/ui/empty-state.tsx +++ b/client/src/components/ui/empty-state.tsx @@ -0,0 +1,105 @@ +import { cn } from "@/lib/utils"; +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 + * + * @example + * ```tsx + * Clear filters} + * /> + * ``` + */ + +const emptyStateVariants = cva( + "flex flex-col items-center justify-center text-center px-4 py-8 rounded-lg border", + { + 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", + }, + 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", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface EmptyStateProps extends VariantProps { + /** + * Optional icon to display + */ + icon?: LucideIcon; + /** + * Title text + */ + title: string; + /** + * Optional description text + */ + description?: string; + /** + * Optional action element (usually a button) + */ + action?: React.ReactNode; + /** + * Additional CSS classes + */ + className?: string; + /** + * Optional custom icon element + */ + customIcon?: React.ReactNode; +} + +export function EmptyState({ + icon: Icon, + title, + description, + action, + variant, + size, + className, + customIcon, +}: EmptyStateProps) { + return ( +
+ {/* Icon */} + {customIcon ? ( + customIcon + ) : Icon ? ( +
+ +
+ ) : null} + + {/* Title */} +

{title}

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

{description}

+ )} + + {/* Action */} + {action && ( +
{action}
+ )} +
+ ); +} \ 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 e69de29..2ac2933 100644 --- a/client/src/components/ui/stat-card.tsx +++ b/client/src/components/ui/stat-card.tsx @@ -0,0 +1,141 @@ +import { cn } from "@/lib/utils"; +import { cva, type VariantProps } from "class-variance-authority"; +import { LucideIcon } from "lucide-react"; + +/** + * Stat card component for displaying statistical metrics + * + * @example + * ```tsx + * + * ``` + */ + +const statCardVariants = cva( + "rounded-lg border p-4 flex flex-col space-y-2", + { + 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", + }, + size: { + default: "p-4", + sm: "p-3", + lg: "p-6", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface StatCardProps extends VariantProps { + /** + * The title of the stat + */ + title: string; + /** + * The value to display + */ + value: string | number; + /** + * Optional description text (like change from previous period) + */ + description?: string; + /** + * Optional trend direction + */ + trend?: "up" | "down" | "neutral"; + /** + * Optional icon to display + */ + icon?: LucideIcon; + /** + * Additional CSS classes + */ + className?: string; + /** + * Whether the stat is loading + */ + isLoading?: boolean; +} + +export function StatCard({ + title, + value, + description, + trend, + icon: Icon, + variant, + size, + className, + isLoading = false, +}: 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" + ? "↓" + : "→"; + + return ( +
+ {/* Header with icon and title */} +
+

{title}

+ {Icon && ( +
+ +
+ )} +
+ + {/* Value */} + {isLoading ? ( +
+ ) : ( +
{value}
+ )} + + {/* Description with trend */} + {description && ( +
+ {trend && {trendSymbol}} + + {description} + +
+ )} +
+ ); +} \ 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 e69de29..b52c305 100644 --- a/client/src/components/ui/tag-input.tsx +++ b/client/src/components/ui/tag-input.tsx @@ -0,0 +1,233 @@ +import { useState, useRef, KeyboardEvent, useEffect } 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"; + +/** + * Tag input component for entering and managing tags + * + * @example + * ```tsx + * + * ``` + */ + +export interface TagInputProps { + /** + * Current tag values + */ + value: string[]; + /** + * Event handler called when tags change + */ + onChange: (value: string[]) => void; + /** + * Input placeholder text + */ + placeholder?: string; + /** + * Additional CSS classes for the container + */ + className?: string; + /** + * CSS classes for the input field + */ + inputClassName?: string; + /** + * Maximum number of tags allowed + */ + maxTags?: number; + /** + * Whether the component is disabled + */ + disabled?: boolean; + /** + * Array of tag suggestions + */ + suggestions?: string[]; + /** + * Whether tags should be validated before adding + */ + validate?: (tag: string) => boolean; + /** + * Maximum length of a single tag + */ + maxTagLength?: number; +} + +export function TagInput({ + value = [], + onChange, + placeholder = "Add tag...", + className, + inputClassName, + maxTags, + disabled = false, + suggestions = [], + validate, + maxTagLength = 30, +}: TagInputProps) { + const [inputValue, setInputValue] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(false); + const [filteredSuggestions, setFilteredSuggestions] = useState([]); + const inputRef = useRef(null); + + // Filter suggestions based on input value + useEffect(() => { + if (inputValue) { + const filtered = suggestions.filter( + (suggestion) => + suggestion.toLowerCase().includes(inputValue.toLowerCase()) && + !value.includes(suggestion) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + } else { + setShowSuggestions(false); + } + }, [inputValue, suggestions, value]); + + // Add a new tag + const addTag = (tag: string) => { + const trimmedTag = tag.trim(); + + // 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) + ) { + return; + } + + onChange([...value, trimmedTag]); + setInputValue(""); + inputRef.current?.focus(); + }; + + // Remove a tag + const removeTag = (index: number) => { + if (disabled) return; + const newTags = [...value]; + newTags.splice(index, 1); + onChange(newTags); + }; + + // Handle keyboard events + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && inputValue) { + e.preventDefault(); + addTag(inputValue); + } else if (e.key === "Backspace" && !inputValue && value.length > 0) { + removeTag(value.length - 1); + } + }; + + // Select a suggestion + const selectSuggestion = (suggestion: string) => { + addTag(suggestion); + setShowSuggestions(false); + }; + + return ( +
+
inputRef.current?.focus()} + > + {/* Display existing tags */} + {value.map((tag, index) => ( + + {tag} + + + ))} + + {/* 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} + /> + + {/* Suggestions dropdown */} + {showSuggestions && ( +
+ {filteredSuggestions.map((suggestion) => ( + + ))} +
+ )} +
+ )} + + {/* Add button for mobile usability */} + {inputValue && !disabled && ( + + )} +
+ + {/* Show max tags limit */} + {maxTags && ( +

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

+ )} +
+ ); +} \ No newline at end of file diff --git a/client/src/components/work/work-preview.tsx b/client/src/components/work/work-preview.tsx new file mode 100644 index 0000000..3122531 --- /dev/null +++ b/client/src/components/work/work-preview.tsx @@ -0,0 +1,412 @@ +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ArrowRight, Bookmark, BookmarkCheck, LucideIcon, MoreVertical } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { Link } from "wouter"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +/** + * Work preview component for displaying works in lists and grids + * + * @example + * ```tsx + * {}} + * /> + * ``` + */ + +// Placeholder image component (replace with your own image component) +const PlaceholderImage = ({ className }: { className?: string }) => ( +
+ No Image +
+); + +export interface WorkPreviewProps { + /** + * Work data to display + */ + work: { + id: number; + title: string; + slug: string; + excerpt?: string | null; + author?: { + name: string; + slug?: string; + }; + coverImage?: string | null; + tags?: Array<{ + id: number; + name: string; + }>; + translationCount?: number; + createdAt?: Date | string; + language?: string; + isFeatured?: boolean; + }; + /** + * Whether this work is bookmarked by the current user + */ + isBookmarked?: boolean; + /** + * Callback when bookmark is toggled + */ + onToggleBookmark?: (id: number) => void; + /** + * Callback when more menu option is selected + */ + onAction?: (action: string, id: number) => void; + /** + * Layout variant + */ + variant?: "default" | "compact" | "grid" | "featured"; + /** + * Custom action button + */ + actionButton?: React.ReactNode; + /** + * Dropdown menu options + */ + menuOptions?: Array<{ + label: string; + action: string; + icon?: LucideIcon; + variant?: "default" | "destructive"; + }>; + /** + * Additional CSS classes + */ + className?: string; + /** + * Whether the preview is in a loading state + */ + isLoading?: boolean; +} + +export function WorkPreview({ + work, + isBookmarked, + onToggleBookmark, + onAction, + variant = "default", + actionButton, + menuOptions = [ + { label: "View details", action: "view" }, + { label: "Edit work", action: "edit" }, + { label: "Add to collection", action: "add-to-collection" }, + { label: "Delete", action: "delete", variant: "destructive" }, + ], + className, + isLoading = false, +}: WorkPreviewProps) { + // Format date + const formattedDate = work.createdAt + ? formatDistanceToNow(new Date(work.createdAt), { addSuffix: true }) + : ""; + + // Determine if we should show tags based on variant + const showTags = variant !== "compact"; + + // Limit number of tags based on variant + const maxTags = variant === "grid" ? 2 : variant === "featured" ? 4 : 3; + const displayTags = work.tags?.slice(0, maxTags) || []; + const hasMoreTags = (work.tags?.length || 0) > maxTags; + + if (isLoading) { + return ; + } + + return ( + + {/* Cover Image - Only for grid and featured variants */} + {(variant === "grid" || variant === "featured") && ( +
+ {work.coverImage ? ( + {work.title} + ) : ( + + )} + + {/* Bookmark button - Overlay on image for grid/featured */} + {onToggleBookmark && ( + + )} + + {/* Featured badge */} + {work.isFeatured && variant === "featured" && ( + + Featured + + )} +
+ )} + + +
+
+ {/* Author name - for all except compact */} + {work.author && variant !== "compact" && ( + + {work.author.slug ? ( + + {work.author.name} + + ) : ( + work.author.name + )} + + )} + + {/* Title */} + + + {work.title} + + + + {/* Language - if available */} + {work.language && ( + + {work.language} + + )} +
+ + {/* Actions dropdown */} + {onAction && ( + + + + + + {menuOptions.map((option, index) => ( + + {index > 0 && option.variant === "destructive" && ( + + )} + onAction(option.action, work.id)} + > + {option.icon && } + {option.label} + + + ))} + + + )} + + {/* Bookmark button - For default and compact when not on image */} + {onToggleBookmark && (variant === "default" || variant === "compact") && ( + + )} +
+
+ + {/* Content - Only for default and featured variants */} + {(variant === "default" || variant === "featured") && work.excerpt && ( + +

{work.excerpt}

+
+ )} + + + {/* Tags */} + {showTags && displayTags.length > 0 && ( +
+ {displayTags.map((tag) => ( + + {tag.name} + + ))} + {hasMoreTags && ( + + +{(work.tags?.length || 0) - maxTags} more + + )} +
+ )} + + {/* Translations count and date */} +
+ {work.translationCount !== undefined && ( + {work.translationCount} translation{work.translationCount !== 1 && 's'} + )} + + {formattedDate && variant !== "compact" && ( + {formattedDate} + )} + + {/* Custom action button or default view button */} + {actionButton || ( + + )} +
+
+
+ ); +} + +// Skeleton loading state +export function WorkPreviewSkeleton({ + variant = "default", + className, +}: { + variant?: "default" | "compact" | "grid" | "featured"; + className?: string; +}) { + return ( + + {/* Image skeleton */} + {(variant === "grid" || variant === "featured") && ( +
+ )} + + + {variant !== "compact" && ( + + )} + + + + {(variant === "default" || variant === "featured") && ( + +
+ + +
+
+ )} + + + {showTags && ( +
+ + +
+ )} +
+ + +
+
+ + ); +} \ No newline at end of file