314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import Link from 'next/link';
|
|
import Image from 'next/image';
|
|
import { Story } from '../../types/api';
|
|
import { storyApi, getImageUrl } from '../../lib/api';
|
|
import Button from '../ui/Button';
|
|
import TagDisplay from '../tags/TagDisplay';
|
|
|
|
interface StoryCardProps {
|
|
story: Story;
|
|
viewMode: 'grid' | 'list';
|
|
onUpdate?: () => void;
|
|
showSelection?: boolean;
|
|
isSelected?: boolean;
|
|
onSelect?: () => void;
|
|
}
|
|
|
|
export default function StoryCard({
|
|
story,
|
|
viewMode,
|
|
onUpdate,
|
|
showSelection = false,
|
|
isSelected = false,
|
|
onSelect
|
|
}: StoryCardProps) {
|
|
const [rating, setRating] = useState(story.rating || 0);
|
|
const [updating, setUpdating] = useState(false);
|
|
|
|
// Helper function to get tags from either tags array or tagNames array
|
|
const getTags = () => {
|
|
if (Array.isArray(story.tags) && story.tags.length > 0) {
|
|
return story.tags;
|
|
}
|
|
if (Array.isArray(story.tagNames) && story.tagNames.length > 0) {
|
|
// Convert tagNames to Tag objects for display compatibility
|
|
return story.tagNames.map((name, index) => ({
|
|
id: `tag-${index}`, // Temporary ID for display
|
|
name: name
|
|
}));
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const displayTags = getTags();
|
|
|
|
const handleRatingClick = async (e: React.MouseEvent, newRating: number) => {
|
|
// Prevent default and stop propagation to avoid triggering navigation
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (updating) return;
|
|
|
|
try {
|
|
setUpdating(true);
|
|
await storyApi.updateRating(story.id, newRating);
|
|
setRating(newRating);
|
|
onUpdate?.();
|
|
} catch (error) {
|
|
console.error('Failed to update rating:', error);
|
|
} finally {
|
|
setUpdating(false);
|
|
}
|
|
};
|
|
|
|
const formatWordCount = (wordCount: number) => {
|
|
return wordCount.toLocaleString() + ' words';
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString();
|
|
};
|
|
|
|
const calculateReadingPercentage = (story: Story): number => {
|
|
if (!story.readingPosition) return 0;
|
|
|
|
const totalLength = story.contentPlain?.length || story.contentHtml?.length || 0;
|
|
if (totalLength === 0) return 0;
|
|
|
|
return Math.round((story.readingPosition / totalLength) * 100);
|
|
};
|
|
|
|
const readingPercentage = calculateReadingPercentage(story);
|
|
|
|
if (viewMode === 'list') {
|
|
return (
|
|
<div className="theme-card theme-shadow rounded-lg p-4 hover:shadow-lg transition-shadow">
|
|
<div className="flex gap-4">
|
|
{/* Cover Image */}
|
|
<div className="flex-shrink-0">
|
|
<Link href={`/stories/${story.id}/detail`}>
|
|
<div className="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded overflow-hidden">
|
|
{story.coverPath ? (
|
|
<Image
|
|
src={getImageUrl(story.coverPath)}
|
|
alt={story.title}
|
|
width={64}
|
|
height={80}
|
|
className="w-full h-full object-cover"
|
|
unoptimized
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center theme-text text-xs">
|
|
📖
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<Link href={`/stories/${story.id}/detail`}>
|
|
<h3 className="text-lg font-semibold theme-header hover:theme-accent transition-colors truncate">
|
|
{story.title}
|
|
</h3>
|
|
</Link>
|
|
|
|
<Link href={`/authors/${story.authorId}`}>
|
|
<p className="theme-text hover:theme-accent transition-colors">
|
|
{story.authorName}
|
|
</p>
|
|
</Link>
|
|
|
|
<div className="flex items-center gap-4 mt-2 text-sm theme-text">
|
|
<span>{formatWordCount(story.wordCount)}</span>
|
|
<span>{formatDate(story.createdAt)}</span>
|
|
{readingPercentage > 0 && (
|
|
<span className="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-mono">
|
|
{readingPercentage}% read
|
|
</span>
|
|
)}
|
|
{story.seriesName && (
|
|
<span>
|
|
{story.seriesName} #{story.volume}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{displayTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{displayTags.slice(0, 3).map((tag) => (
|
|
<TagDisplay
|
|
key={tag.id}
|
|
tag={tag}
|
|
size="sm"
|
|
clickable={false}
|
|
/>
|
|
))}
|
|
{displayTags.length > 3 && (
|
|
<span className="px-2 py-1 text-xs theme-text">
|
|
+{displayTags.length - 3} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col items-end gap-2 ml-4">
|
|
{/* Rating */}
|
|
<div className="flex gap-1">
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<button
|
|
key={star}
|
|
onClick={(e) => handleRatingClick(e, star)}
|
|
className={`text-lg ${
|
|
star <= rating
|
|
? 'text-yellow-400'
|
|
: 'text-gray-300 dark:text-gray-600'
|
|
} hover:text-yellow-400 transition-colors ${
|
|
updating ? 'cursor-not-allowed' : 'cursor-pointer'
|
|
}`}
|
|
disabled={updating}
|
|
>
|
|
★
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex flex-col gap-2">
|
|
<Link href={`/stories/${story.id}`}>
|
|
<Button size="sm" className="w-full">
|
|
Read
|
|
</Button>
|
|
</Link>
|
|
<Link href={`/stories/${story.id}/edit`}>
|
|
<Button size="sm" variant="ghost" className="w-full">
|
|
Edit
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Grid view
|
|
return (
|
|
<div className="theme-card theme-shadow rounded-lg overflow-hidden hover:shadow-lg transition-shadow group">
|
|
{/* Cover Image */}
|
|
<Link href={`/stories/${story.id}`}>
|
|
<div className="aspect-[3/4] bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
|
{story.coverPath ? (
|
|
<Image
|
|
src={getImageUrl(story.coverPath)}
|
|
alt={story.title}
|
|
width={300}
|
|
height={400}
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
unoptimized
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center theme-text text-6xl">
|
|
📖
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
|
|
<div className="p-4">
|
|
{/* Title and Author */}
|
|
<Link href={`/stories/${story.id}`}>
|
|
<h3 className="font-semibold theme-header hover:theme-accent transition-colors line-clamp-2 mb-1">
|
|
{story.title}
|
|
</h3>
|
|
</Link>
|
|
|
|
<Link href={`/authors/${story.authorId}`}>
|
|
<p className="text-sm theme-text hover:theme-accent transition-colors mb-2">
|
|
{story.authorName}
|
|
</p>
|
|
</Link>
|
|
|
|
{/* Rating */}
|
|
<div className="flex gap-1 mb-2">
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<button
|
|
key={star}
|
|
onClick={(e) => handleRatingClick(e, star)}
|
|
className={`text-sm ${
|
|
star <= rating
|
|
? 'text-yellow-400'
|
|
: 'text-gray-300 dark:text-gray-600'
|
|
} hover:text-yellow-400 transition-colors ${
|
|
updating ? 'cursor-not-allowed' : 'cursor-pointer'
|
|
}`}
|
|
disabled={updating}
|
|
>
|
|
★
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Metadata */}
|
|
<div className="text-xs theme-text space-y-1">
|
|
<div>{formatWordCount(story.wordCount)}</div>
|
|
<div>{formatDate(story.createdAt)}</div>
|
|
{readingPercentage > 0 && (
|
|
<div className="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded font-mono inline-block">
|
|
{readingPercentage}% read
|
|
</div>
|
|
)}
|
|
{story.seriesName && (
|
|
<div>
|
|
{story.seriesName} #{story.volume}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{displayTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{displayTags.slice(0, 2).map((tag) => (
|
|
<TagDisplay
|
|
key={tag.id}
|
|
tag={tag}
|
|
size="sm"
|
|
clickable={false}
|
|
/>
|
|
))}
|
|
{displayTags.length > 2 && (
|
|
<span className="px-2 py-1 text-xs theme-text">
|
|
+{displayTags.length - 2}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 mt-4">
|
|
<Link href={`/stories/${story.id}`} className="flex-1">
|
|
<Button size="sm" className="w-full">
|
|
Read
|
|
</Button>
|
|
</Link>
|
|
<Link href={`/stories/${story.id}/edit`}>
|
|
<Button size="sm" variant="ghost">
|
|
Edit
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |