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:
mukimovd 2025-05-10 21:07:28 +00:00
parent c25c789d01
commit 6b00938084
8 changed files with 1688 additions and 0 deletions

View 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

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
}