mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 00:11:35 +00:00
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
This commit is contained in:
parent
c25c789d01
commit
6b00938084
101
COMPONENT-IMPLEMENTATION-TRACKER.md
Normal file
101
COMPONENT-IMPLEMENTATION-TRACKER.md
Normal file
@ -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
|
||||
@ -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
|
||||
* <ActivityFeed
|
||||
* activities={activities}
|
||||
* title="Recent Activity"
|
||||
* emptyMessage="No recent activities"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<string, any>;
|
||||
}
|
||||
|
||||
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<string, ActivityItem[]> = {};
|
||||
|
||||
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 (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Title */}
|
||||
{title && <h3 className="font-medium text-lg">{title}</h3>}
|
||||
|
||||
{/* Activity List */}
|
||||
<div className="space-y-6">
|
||||
{isLoading ? (
|
||||
// Loading state
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<ActivityItemSkeleton key={index} />
|
||||
))
|
||||
) : displayActivities.length === 0 ? (
|
||||
// Empty state
|
||||
<p className="text-muted-foreground text-center py-6">{emptyMessage}</p>
|
||||
) : (
|
||||
// Grouped activities
|
||||
sortedDateKeys.map(dateKey => (
|
||||
<div key={dateKey} className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{formatDateGroup(dateKey)}
|
||||
</h4>
|
||||
<ul className="space-y-4">
|
||||
{groupedActivities[dateKey].map(activity => (
|
||||
<li key={activity.id} className="relative pl-6">
|
||||
{/* Timeline connector */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-border" />
|
||||
|
||||
{/* Activity dot */}
|
||||
<div className="absolute left-[-4px] top-1.5 w-2 h-2 rounded-full bg-primary" />
|
||||
|
||||
{/* Activity content */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Activity icon or user avatar */}
|
||||
{activity.user ? (
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={activity.user.avatar} alt={activity.user.name} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{activity.user.name.split(' ').map(n => n[0]).join('')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : activity.icon ? (
|
||||
<div className="rounded-full bg-muted p-1.5 text-muted-foreground">
|
||||
<activity.icon className="h-4 w-4" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Activity details */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm">
|
||||
{activity.user && (
|
||||
<span className="font-medium">{activity.user.name}</span>
|
||||
)}{' '}
|
||||
{activity.description}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(activity.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show more link */}
|
||||
{maxItems && activities.length > maxItems && (
|
||||
<div className="text-center pt-2">
|
||||
<a
|
||||
href="#"
|
||||
className="text-sm text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Handle showing more activities or navigation
|
||||
}}
|
||||
>
|
||||
View all activities
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader for activity items
|
||||
function ActivityItemSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 pl-6 relative">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-border" />
|
||||
<div className="absolute left-[-4px] top-1.5 w-2 h-2 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
* <DashboardHeader
|
||||
* title="Blog Posts"
|
||||
* description="Manage and publish your blog content"
|
||||
* action={<Button>Create Post</Button>}
|
||||
* 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 (
|
||||
<div className={cn("pb-4 mb-6 border-b", className)}>
|
||||
{/* Back Link */}
|
||||
{(backHref || onBackClick) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mb-2 px-0 text-muted-foreground hover:text-foreground hover:bg-transparent"
|
||||
asChild={!!backHref && !onBackClick}
|
||||
onClick={onBackClick}
|
||||
>
|
||||
{backHref && !onBackClick ? (
|
||||
<a href={backHref} className="flex items-center gap-1">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>{backText}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>{backText}</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Header Content */}
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Optional Icon */}
|
||||
{Icon && (
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and Description */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{secondaryActions}
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
client/src/components/ui/data-table.tsx
Normal file
332
client/src/components/ui/data-table.tsx
Normal file
@ -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
|
||||
* <DataTable
|
||||
* columns={columns}
|
||||
* data={data}
|
||||
* searchKey="title"
|
||||
* emptyState={{
|
||||
* title: "No works found",
|
||||
* description: "Create your first work to get started."
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
/**
|
||||
* Table column definitions
|
||||
*/
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
/**
|
||||
* 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<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchKey,
|
||||
totalCount,
|
||||
isLoading = false,
|
||||
emptyState,
|
||||
onRowClick,
|
||||
initialPageSize = 10,
|
||||
showElements = {
|
||||
search: true,
|
||||
pagination: true,
|
||||
columnVisibility: true,
|
||||
},
|
||||
className,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
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 (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Table Controls */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
{/* Search Input */}
|
||||
{showElements.search && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder={`Search...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View/Filter Options */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{showElements.columnVisibility && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="ml-auto h-8 gap-1">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Columns</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[180px]">
|
||||
{table.getAllColumns()
|
||||
.filter(column => column.getCanHide())
|
||||
.map(column => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="whitespace-nowrap">
|
||||
{header.isPlaceholder ? null : (
|
||||
<div
|
||||
{...{
|
||||
className: header.column.getCanSort()
|
||||
? "cursor-pointer select-none flex items-center gap-1"
|
||||
: "",
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{{
|
||||
asc: " 🔼",
|
||||
desc: " 🔽",
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
// Loading state
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
{columns.map((_, cellIndex) => (
|
||||
<TableCell key={cellIndex}>
|
||||
<div className="h-4 w-full bg-gray-200 dark:bg-gray-800 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : isEmpty ? (
|
||||
// Empty state
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-[300px] text-center"
|
||||
>
|
||||
{emptyState ? (
|
||||
<div className="flex justify-center">
|
||||
<EmptyState
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={emptyState.action}
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No results found</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
// Data rows
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(onRowClick && "cursor-pointer hover:bg-muted")}
|
||||
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
|
||||
{showElements.pagination && !isEmpty && (
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing{" "}
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
|
||||
</strong>{" "}
|
||||
to{" "}
|
||||
<strong>
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length
|
||||
)}
|
||||
</strong>{" "}
|
||||
of <strong>{table.getFilteredRowModel().rows.length}</strong> results
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
* <EmptyState
|
||||
* icon={FileSearch}
|
||||
* title="No results found"
|
||||
* description="Try adjusting your search or filters to find what you're looking for."
|
||||
* action={<Button>Clear filters</Button>}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<typeof emptyStateVariants> {
|
||||
/**
|
||||
* 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 (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
|
||||
{/* Action */}
|
||||
{action && (
|
||||
<div className="mt-2">{action}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
* <StatCard
|
||||
* title="Total Works"
|
||||
* value="124"
|
||||
* description="+12% from last month"
|
||||
* trend="up"
|
||||
* icon={Book}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<typeof statCardVariants> {
|
||||
/**
|
||||
* 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 (
|
||||
<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>
|
||||
{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",
|
||||
)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
* <TagInput
|
||||
* value={tags}
|
||||
* onChange={setTags}
|
||||
* placeholder="Add tags..."
|
||||
* maxTags={5}
|
||||
* suggestions={["React", "TypeScript", "UI"]}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<string>("");
|
||||
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap gap-2 p-2 bg-background border rounded-md focus-within:ring-1 focus-within:ring-ring focus-within:border-input",
|
||||
disabled && "opacity-60 cursor-not-allowed bg-muted"
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{/* Display existing tags */}
|
||||
{value.map((tag, index) => (
|
||||
<Badge
|
||||
key={`${tag}-${index}`}
|
||||
variant="secondary"
|
||||
className="px-2 py-1 flex items-center gap-1"
|
||||
>
|
||||
{tag}
|
||||
<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);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">Remove {tag}</span>
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Input for new tags */}
|
||||
{(!maxTags || value.length < maxTags) && (
|
||||
<div className="relative flex-1 min-w-[120px]">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
client/src/components/work/work-preview.tsx
Normal file
412
client/src/components/work/work-preview.tsx
Normal file
@ -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
|
||||
* <WorkPreview
|
||||
* work={{
|
||||
* id: 1,
|
||||
* title: "Eugene Onegin",
|
||||
* slug: "eugene-onegin",
|
||||
* excerpt: "A classic work of Russian literature...",
|
||||
* author: { name: "Alexander Pushkin" },
|
||||
* tags: [{ id: 1, name: "Poetry" }],
|
||||
* translationCount: 3,
|
||||
* createdAt: new Date(2023, 0, 15),
|
||||
* }}
|
||||
* isBookmarked={false}
|
||||
* onToggleBookmark={() => {}}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Placeholder image component (replace with your own image component)
|
||||
const PlaceholderImage = ({ className }: { className?: string }) => (
|
||||
<div className={cn("bg-muted flex items-center justify-center", className)}>
|
||||
<span className="text-muted-foreground">No Image</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 <WorkPreviewSkeleton variant={variant} className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"",
|
||||
variant === "featured" && "overflow-hidden border-0 shadow",
|
||||
variant === "compact" && "border-0 shadow-none",
|
||||
className
|
||||
)}>
|
||||
{/* Cover Image - Only for grid and featured variants */}
|
||||
{(variant === "grid" || variant === "featured") && (
|
||||
<div className={cn(
|
||||
"relative aspect-[16/9] overflow-hidden",
|
||||
variant === "featured" && "aspect-[21/9]"
|
||||
)}>
|
||||
{work.coverImage ? (
|
||||
<img
|
||||
src={work.coverImage}
|
||||
alt={work.title}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage className="w-full h-full" />
|
||||
)}
|
||||
|
||||
{/* Bookmark button - Overlay on image for grid/featured */}
|
||||
{onToggleBookmark && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 bg-background/80 backdrop-blur-sm hover:bg-background/90"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleBookmark(work.id);
|
||||
}}
|
||||
>
|
||||
{isBookmarked ? (
|
||||
<BookmarkCheck className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<Bookmark className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isBookmarked ? "Remove bookmark" : "Bookmark"}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Featured badge */}
|
||||
{work.isFeatured && variant === "featured" && (
|
||||
<Badge className="absolute top-2 left-2 bg-primary text-primary-foreground">
|
||||
Featured
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className={cn(
|
||||
variant === "compact" && "px-2 py-3",
|
||||
variant === "grid" && "px-4 py-3"
|
||||
)}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
{/* Author name - for all except compact */}
|
||||
{work.author && variant !== "compact" && (
|
||||
<CardDescription>
|
||||
{work.author.slug ? (
|
||||
<Link href={`/authors/${work.author.slug}`} className="hover:underline">
|
||||
{work.author.name}
|
||||
</Link>
|
||||
) : (
|
||||
work.author.name
|
||||
)}
|
||||
</CardDescription>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<CardTitle className={cn(
|
||||
variant === "compact" && "text-base",
|
||||
variant === "featured" && "text-2xl"
|
||||
)}>
|
||||
<Link href={`/works/${work.slug}`} className="hover:underline">
|
||||
{work.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
|
||||
{/* Language - if available */}
|
||||
{work.language && (
|
||||
<CardDescription className="text-xs">
|
||||
{work.language}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
{onAction && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">More options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{menuOptions.map((option, index) => (
|
||||
<React.Fragment key={option.action}>
|
||||
{index > 0 && option.variant === "destructive" && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
option.variant === "destructive" && "text-destructive focus:text-destructive"
|
||||
)}
|
||||
onClick={() => onAction(option.action, work.id)}
|
||||
>
|
||||
{option.icon && <option.icon className="mr-2 h-4 w-4" />}
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Bookmark button - For default and compact when not on image */}
|
||||
{onToggleBookmark && (variant === "default" || variant === "compact") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleBookmark(work.id);
|
||||
}}
|
||||
>
|
||||
{isBookmarked ? (
|
||||
<BookmarkCheck className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Bookmark className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isBookmarked ? "Remove bookmark" : "Bookmark"}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Content - Only for default and featured variants */}
|
||||
{(variant === "default" || variant === "featured") && work.excerpt && (
|
||||
<CardContent className={cn(
|
||||
"text-sm",
|
||||
variant === "featured" && "text-base"
|
||||
)}>
|
||||
<p className="text-muted-foreground line-clamp-3">{work.excerpt}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className={cn(
|
||||
"flex items-center justify-between flex-wrap gap-2 pt-0",
|
||||
variant === "compact" && "px-2 py-2",
|
||||
variant === "grid" && "px-4 py-3"
|
||||
)}>
|
||||
{/* Tags */}
|
||||
{showTags && displayTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{displayTags.map((tag) => (
|
||||
<Badge key={tag.id} variant="secondary" className="text-xs">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{hasMoreTags && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{(work.tags?.length || 0) - maxTags} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Translations count and date */}
|
||||
<div className="flex items-center gap-4 ml-auto text-xs text-muted-foreground">
|
||||
{work.translationCount !== undefined && (
|
||||
<span>{work.translationCount} translation{work.translationCount !== 1 && 's'}</span>
|
||||
)}
|
||||
|
||||
{formattedDate && variant !== "compact" && (
|
||||
<span>{formattedDate}</span>
|
||||
)}
|
||||
|
||||
{/* Custom action button or default view button */}
|
||||
{actionButton || (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto flex items-center gap-1"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/works/${work.slug}`}>
|
||||
View
|
||||
<ArrowRight className="h-3 w-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loading state
|
||||
export function WorkPreviewSkeleton({
|
||||
variant = "default",
|
||||
className,
|
||||
}: {
|
||||
variant?: "default" | "compact" | "grid" | "featured";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn(
|
||||
"animate-pulse",
|
||||
variant === "featured" && "overflow-hidden border-0 shadow",
|
||||
variant === "compact" && "border-0 shadow-none",
|
||||
className
|
||||
)}>
|
||||
{/* Image skeleton */}
|
||||
{(variant === "grid" || variant === "featured") && (
|
||||
<div className={cn(
|
||||
"aspect-[16/9] bg-muted",
|
||||
variant === "featured" && "aspect-[21/9]"
|
||||
)} />
|
||||
)}
|
||||
|
||||
<CardHeader className={cn(
|
||||
variant === "compact" && "px-2 py-3",
|
||||
variant === "grid" && "px-4 py-3"
|
||||
)}>
|
||||
{variant !== "compact" && (
|
||||
<Skeleton className="h-4 w-40" />
|
||||
)}
|
||||
<Skeleton className={cn(
|
||||
"h-6 w-full",
|
||||
variant === "featured" && "h-8"
|
||||
)} />
|
||||
</CardHeader>
|
||||
|
||||
{(variant === "default" || variant === "featured") && (
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className={cn(
|
||||
"flex items-center justify-between pt-0",
|
||||
variant === "compact" && "px-2 py-2",
|
||||
variant === "grid" && "px-4 py-3"
|
||||
)}>
|
||||
{showTags && (
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16 rounded-md" />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user