mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +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