Files
storycove/frontend/src/app/stories/[id]/detail/page.tsx
2025-07-26 12:05:54 +02:00

374 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { storyApi, seriesApi, getImageUrl } from '../../../../lib/api';
import { Story, Collection } from '../../../../types/api';
import AppLayout from '../../../../components/layout/AppLayout';
import Button from '../../../../components/ui/Button';
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
import { calculateReadingTime } from '../../../../lib/settings';
export default function StoryDetailPage() {
const params = useParams();
const router = useRouter();
const storyId = params.id as string;
const [story, setStory] = useState<Story | null>(null);
const [seriesStories, setSeriesStories] = useState<Story[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
useEffect(() => {
const loadStoryData = async () => {
try {
setLoading(true);
const storyData = await storyApi.getStory(storyId);
setStory(storyData);
// Load series stories if this story is part of a series
if (storyData.seriesId) {
const seriesData = await seriesApi.getSeriesStories(storyData.seriesId);
setSeriesStories(seriesData);
}
// Load collections that contain this story
const collectionsData = await storyApi.getStoryCollections(storyId);
setCollections(collectionsData);
} catch (error) {
console.error('Failed to load story data:', error);
router.push('/library');
} finally {
setLoading(false);
}
};
if (storyId) {
loadStoryData();
}
}, [storyId, router]);
const handleRatingClick = async (newRating: number) => {
if (updating || !story) return;
try {
setUpdating(true);
await storyApi.updateRating(story.id, newRating);
setStory(prev => prev ? { ...prev, rating: newRating } : null);
} catch (error) {
console.error('Failed to update rating:', error);
} finally {
setUpdating(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const estimateReadingTime = (wordCount: number) => {
return calculateReadingTime(wordCount);
};
if (loading) {
return (
<AppLayout>
<div className="flex items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
</AppLayout>
);
}
if (!story) {
return (
<AppLayout>
<div className="text-center py-20">
<h1 className="text-2xl font-bold theme-header mb-4">Story Not Found</h1>
<Button href="/library">Back to Library</Button>
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="max-w-6xl mx-auto">
{/* Header Actions */}
<div className="flex justify-between items-center mb-6">
<Button href="/library" variant="ghost">
Back to Library
</Button>
<div className="flex gap-2">
<Button href={`/stories/${story.id}`}>
Read Story
</Button>
<Button href={`/stories/${story.id}/edit`} variant="ghost">
Edit
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
{/* Left Column - Cover */}
<div className="md:col-span-4 lg:col-span-3">
{/* Cover Image */}
<div className="aspect-[3/4] bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden max-w-sm mx-auto">
{story.coverPath ? (
<Image
src={getImageUrl(story.coverPath)}
alt={story.title}
width={300}
height={400}
className="w-full h-full object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center theme-text text-6xl">
📖
</div>
)}
</div>
</div>
{/* Right Column - Story Details */}
<div className="md:col-span-8 lg:col-span-9 space-y-6">
{/* Title and Author */}
<div>
<h1 className="text-4xl font-bold theme-header mb-2">
{story.title}
</h1>
<Link
href={`/authors/${story.authorId}`}
className="text-xl theme-accent hover:underline"
>
by {story.authorName}
</Link>
</div>
{/* Quick Stats and Rating */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Quick Stats */}
<div className="theme-card theme-shadow rounded-lg p-4 space-y-3">
<h3 className="font-semibold theme-header mb-3">Details</h3>
<div className="flex justify-between items-center">
<span className="theme-text">Word Count:</span>
<span className="font-medium theme-header">
{story.wordCount.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="theme-text">Reading Time:</span>
<span className="font-medium theme-header">
~{estimateReadingTime(story.wordCount)} min
</span>
</div>
<div className="flex justify-between items-center">
<span className="theme-text">Added:</span>
<span className="font-medium theme-header">
{formatDate(story.createdAt)}
</span>
</div>
{story.updatedAt !== story.createdAt && (
<div className="flex justify-between items-center">
<span className="theme-text">Updated:</span>
<span className="font-medium theme-header">
{formatDate(story.updatedAt)}
</span>
</div>
)}
</div>
{/* Rating */}
<div className="theme-card theme-shadow rounded-lg p-4">
<h3 className="font-semibold theme-header mb-3">Your Rating</h3>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => handleRatingClick(star)}
className={`text-3xl transition-colors ${
star <= (story.rating || 0)
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600 hover:text-yellow-300'
} ${updating ? 'cursor-not-allowed' : 'cursor-pointer'}`}
disabled={updating}
>
</button>
))}
</div>
{story.rating && (
<p className="text-sm theme-text mt-2">
{story.rating}/5 stars
</p>
)}
</div>
</div>
{/* Series Info */}
{story.seriesName && (
<div className="theme-card theme-shadow rounded-lg p-4">
<h3 className="font-semibold theme-header mb-2">Part of Series</h3>
<p className="theme-text">
<strong>{story.seriesName}</strong>
{story.volume && ` - Volume ${story.volume}`}
</p>
{/* Series Navigation */}
{seriesStories.length > 1 && (
<div className="mt-4">
<h4 className="text-sm font-medium theme-header mb-2">
Other stories in this series:
</h4>
<div className="space-y-1">
{seriesStories
.filter(s => s.id !== story.id)
.slice(0, 5)
.map((seriesStory) => (
<Link
key={seriesStory.id}
href={`/stories/${seriesStory.id}/detail`}
className="block text-sm theme-accent hover:underline"
>
{seriesStory.volume && `${seriesStory.volume}. `}
{seriesStory.title}
</Link>
))}
{seriesStories.length > 6 && (
<p className="text-sm theme-text">
+{seriesStories.length - 6} more stories
</p>
)}
</div>
</div>
)}
</div>
)}
{/* Collections */}
{collections.length > 0 && (
<div className="theme-card theme-shadow rounded-lg p-4">
<h3 className="font-semibold theme-header mb-3">
Part of Collections ({collections.length})
</h3>
<div className="space-y-2">
{collections.map((collection) => (
<Link
key={collection.id}
href={`/collections/${collection.id}`}
className="block p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="flex items-center gap-3">
{collection.coverImagePath ? (
<img
src={getImageUrl(collection.coverImagePath)}
alt={`${collection.name} cover`}
className="w-8 h-10 object-cover rounded"
/>
) : (
<div className="w-8 h-10 bg-gradient-to-br from-blue-100 to-purple-100 rounded flex items-center justify-center">
<span className="text-xs font-bold text-gray-600">
{collection.storyCount}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<h4 className="font-medium theme-header truncate">
{collection.name}
</h4>
<p className="text-sm theme-text opacity-70">
{collection.storyCount} {collection.storyCount === 1 ? 'story' : 'stories'}
{collection.estimatedReadingTime && (
<span> ~{Math.ceil(collection.estimatedReadingTime / 60)}h reading</span>
)}
</p>
</div>
{collection.rating && (
<div className="flex-shrink-0">
<span className="text-yellow-400"></span>
<span className="text-sm theme-text ml-1">{collection.rating}</span>
</div>
)}
</div>
</Link>
))}
</div>
</div>
)}
{/* Summary */}
{story.summary && (
<div className="theme-card theme-shadow rounded-lg p-6">
<h3 className="text-xl font-semibold theme-header mb-4">Summary</h3>
<div className="theme-text prose prose-gray dark:prose-invert max-w-none">
<p className="whitespace-pre-wrap leading-relaxed">
{story.summary}
</p>
</div>
</div>
)}
{/* Tags */}
{story.tags && story.tags.length > 0 && (
<div className="theme-card theme-shadow rounded-lg p-4">
<h3 className="font-semibold theme-header mb-3">Tags</h3>
<div className="flex flex-wrap gap-2">
{story.tags.map((tag) => (
<span
key={tag.id}
className="px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
>
{tag.name}
</span>
))}
</div>
</div>
)}
{/* Source URL */}
{story.sourceUrl && (
<div className="theme-card theme-shadow rounded-lg p-4">
<h3 className="font-semibold theme-header mb-2">Source</h3>
<a
href={story.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="theme-accent hover:underline break-all"
>
{story.sourceUrl}
</a>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-4 pt-6">
<Button
href={`/stories/${story.id}`}
className="flex-1"
size="lg"
>
📚 Start Reading
</Button>
<Button
href={`/stories/${story.id}/edit`}
variant="ghost"
size="lg"
>
Edit Story
</Button>
</div>
</div>
</div>
</div>
</AppLayout>
);
}