Story Collections Feature
This commit is contained in:
203
frontend/src/components/collections/CollectionCard.tsx
Normal file
203
frontend/src/components/collections/CollectionCard.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { Collection } from '../../types/api';
|
||||
import { getImageUrl } from '../../lib/api';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface CollectionCardProps {
|
||||
collection: Collection;
|
||||
viewMode: 'grid' | 'list';
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default function CollectionCard({ collection, viewMode, onUpdate }: CollectionCardProps) {
|
||||
const formatReadingTime = (minutes: number): string => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
const renderRatingStars = (rating?: number) => {
|
||||
if (!rating) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span
|
||||
key={star}
|
||||
className={`text-sm ${
|
||||
star <= rating ? 'text-yellow-400' : 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (viewMode === 'grid') {
|
||||
return (
|
||||
<Link href={`/collections/${collection.id}`}>
|
||||
<div className="theme-card p-4 hover:border-gray-400 transition-colors cursor-pointer">
|
||||
{/* Cover Image or Placeholder */}
|
||||
<div className="aspect-[3/4] mb-3 relative overflow-hidden rounded-lg bg-gray-100">
|
||||
{collection.coverImagePath ? (
|
||||
<img
|
||||
src={getImageUrl(collection.coverImagePath)}
|
||||
alt={`${collection.name} cover`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
|
||||
<div className="text-center p-4">
|
||||
<div className="text-2xl font-bold theme-text mb-1">
|
||||
{collection.storyCount}
|
||||
</div>
|
||||
<div className="text-xs theme-text opacity-60">
|
||||
{collection.storyCount === 1 ? 'story' : 'stories'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collection.isArchived && (
|
||||
<div className="absolute top-2 right-2 bg-yellow-500 text-white px-2 py-1 rounded text-xs">
|
||||
Archived
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection Info */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold theme-header line-clamp-2">
|
||||
{collection.name}
|
||||
</h3>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-sm theme-text opacity-70 line-clamp-2">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs theme-text opacity-60">
|
||||
<span>{collection.storyCount} stories</span>
|
||||
<span>{collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'}</span>
|
||||
</div>
|
||||
|
||||
{collection.rating && (
|
||||
<div className="flex justify-center">
|
||||
{renderRatingStars(collection.rating)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{collection.tags && collection.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{collection.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{collection.tags.length > 3 && (
|
||||
<span className="text-xs theme-text opacity-60">
|
||||
+{collection.tags.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<Link href={`/collections/${collection.id}`}>
|
||||
<div className="theme-card p-4 hover:border-gray-400 transition-colors cursor-pointer">
|
||||
<div className="flex gap-4">
|
||||
{/* Cover Image */}
|
||||
<div className="w-16 h-20 flex-shrink-0 rounded overflow-hidden bg-gray-100">
|
||||
{collection.coverImagePath ? (
|
||||
<img
|
||||
src={getImageUrl(collection.coverImagePath)}
|
||||
alt={`${collection.name} cover`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-bold theme-text">
|
||||
{collection.storyCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold theme-header line-clamp-1">
|
||||
{collection.name}
|
||||
{collection.isArchived && (
|
||||
<span className="ml-2 inline-block bg-yellow-500 text-white px-2 py-1 rounded text-xs">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-sm theme-text opacity-70 line-clamp-2 mt-1">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-sm theme-text opacity-60">
|
||||
<span>{collection.storyCount} stories</span>
|
||||
<span>{collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'} reading</span>
|
||||
{collection.averageStoryRating && collection.averageStoryRating > 0 && (
|
||||
<span>★ {collection.averageStoryRating.toFixed(1)} avg</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{collection.tags && collection.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{collection.tags.slice(0, 5).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{collection.tags.length > 5 && (
|
||||
<span className="text-xs theme-text opacity-60">
|
||||
+{collection.tags.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{collection.rating && (
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
{renderRatingStars(collection.rating)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user