inital working version
This commit is contained in:
267
frontend/src/app/add-story/page.tsx
Normal file
267
frontend/src/app/add-story/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
import { Input, Textarea } from '../../components/ui/Input';
|
||||
import Button from '../../components/ui/Button';
|
||||
import TagInput from '../../components/stories/TagInput';
|
||||
import RichTextEditor from '../../components/stories/RichTextEditor';
|
||||
import ImageUpload from '../../components/ui/ImageUpload';
|
||||
import { storyApi } from '../../lib/api';
|
||||
|
||||
export default function AddStoryPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
summary: '',
|
||||
authorName: '',
|
||||
contentHtml: '',
|
||||
sourceUrl: '',
|
||||
tags: [] as string[],
|
||||
seriesName: '',
|
||||
volume: '',
|
||||
});
|
||||
|
||||
const [coverImage, setCoverImage] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleInputChange = (field: string) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: e.target.value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (html: string) => {
|
||||
setFormData(prev => ({ ...prev, contentHtml: html }));
|
||||
if (errors.contentHtml) {
|
||||
setErrors(prev => ({ ...prev, contentHtml: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagsChange = (tags: string[]) => {
|
||||
setFormData(prev => ({ ...prev, tags }));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
}
|
||||
|
||||
if (!formData.authorName.trim()) {
|
||||
newErrors.authorName = 'Author name is required';
|
||||
}
|
||||
|
||||
if (!formData.contentHtml.trim()) {
|
||||
newErrors.contentHtml = 'Story content is required';
|
||||
}
|
||||
|
||||
if (formData.seriesName && !formData.volume) {
|
||||
newErrors.volume = 'Volume number is required when series is specified';
|
||||
}
|
||||
|
||||
if (formData.volume && !formData.seriesName.trim()) {
|
||||
newErrors.seriesName = 'Series name is required when volume is specified';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// First, create the story with JSON data
|
||||
const storyData = {
|
||||
title: formData.title,
|
||||
summary: formData.summary || undefined,
|
||||
contentHtml: formData.contentHtml,
|
||||
sourceUrl: formData.sourceUrl || undefined,
|
||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||
authorName: formData.authorName || undefined,
|
||||
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||
};
|
||||
|
||||
const story = await storyApi.createStory(storyData);
|
||||
|
||||
// If there's a cover image, upload it separately
|
||||
if (coverImage) {
|
||||
await storyApi.uploadCover(story.id, coverImage);
|
||||
}
|
||||
|
||||
router.push(`/stories/${story.id}`);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create story:', error);
|
||||
const errorMessage = error.response?.data?.message || 'Failed to create story';
|
||||
setErrors({ submit: errorMessage });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold theme-header">Add New Story</h1>
|
||||
<p className="theme-text mt-2">
|
||||
Add a story to your personal collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Title */}
|
||||
<Input
|
||||
label="Title *"
|
||||
value={formData.title}
|
||||
onChange={handleInputChange('title')}
|
||||
placeholder="Enter the story title"
|
||||
error={errors.title}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Author */}
|
||||
<Input
|
||||
label="Author *"
|
||||
value={formData.authorName}
|
||||
onChange={handleInputChange('authorName')}
|
||||
placeholder="Enter the author's name"
|
||||
error={errors.authorName}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Summary
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.summary}
|
||||
onChange={handleInputChange('summary')}
|
||||
placeholder="Brief summary or description of the story..."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-sm theme-text mt-1">
|
||||
Optional summary that will be displayed on the story detail page
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cover Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Cover Image
|
||||
</label>
|
||||
<ImageUpload
|
||||
onImageSelect={setCoverImage}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
maxSizeMB={5}
|
||||
aspectRatio="3:4"
|
||||
placeholder="Drop a cover image here or click to select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Story Content *
|
||||
</label>
|
||||
<RichTextEditor
|
||||
value={formData.contentHtml}
|
||||
onChange={handleContentChange}
|
||||
placeholder="Write or paste your story content here..."
|
||||
error={errors.contentHtml}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<TagInput
|
||||
tags={formData.tags}
|
||||
onChange={handleTagsChange}
|
||||
placeholder="Add tags to categorize your story..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Series and Volume */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Series (optional)"
|
||||
value={formData.seriesName}
|
||||
onChange={handleInputChange('seriesName')}
|
||||
placeholder="Enter series name if part of a series"
|
||||
error={errors.seriesName}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Volume/Part (optional)"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.volume}
|
||||
onChange={handleInputChange('volume')}
|
||||
placeholder="Enter volume/part number"
|
||||
error={errors.volume}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source URL */}
|
||||
<Input
|
||||
label="Source URL (optional)"
|
||||
type="url"
|
||||
value={formData.sourceUrl}
|
||||
onChange={handleInputChange('sourceUrl')}
|
||||
placeholder="https://example.com/original-story-url"
|
||||
/>
|
||||
|
||||
{/* Submit Error */}
|
||||
{errors.submit && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-4 pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
|
||||
>
|
||||
Add Story
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
423
frontend/src/app/authors/[id]/edit/page.tsx
Normal file
423
frontend/src/app/authors/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { authorApi, getImageUrl } from '../../../../lib/api';
|
||||
import { Author } from '../../../../types/api';
|
||||
import AppLayout from '../../../../components/layout/AppLayout';
|
||||
import { Input, Textarea } from '../../../../components/ui/Input';
|
||||
import Button from '../../../../components/ui/Button';
|
||||
import ImageUpload from '../../../../components/ui/ImageUpload';
|
||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
|
||||
export default function EditAuthorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const authorId = params.id as string;
|
||||
|
||||
const [author, setAuthor] = useState<Author | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
notes: '',
|
||||
authorRating: 0,
|
||||
urls: [] as string[],
|
||||
});
|
||||
|
||||
const [avatarImage, setAvatarImage] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAuthor = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const authorData = await authorApi.getAuthor(authorId);
|
||||
setAuthor(authorData);
|
||||
|
||||
// Initialize form with author data
|
||||
setFormData({
|
||||
name: authorData.name,
|
||||
notes: authorData.notes || '',
|
||||
authorRating: authorData.authorRating || 0,
|
||||
urls: authorData.urls || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load author:', error);
|
||||
router.push('/authors');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authorId) {
|
||||
loadAuthor();
|
||||
}
|
||||
}, [authorId, router]);
|
||||
|
||||
const handleInputChange = (field: string) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: e.target.value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRatingChange = (rating: number) => {
|
||||
setFormData(prev => ({ ...prev, authorRating: rating }));
|
||||
};
|
||||
|
||||
const addUrl = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
urls: [...prev.urls, '']
|
||||
}));
|
||||
};
|
||||
|
||||
const updateUrl = (index: number, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
urls: prev.urls.map((url, i) => i === index ? value : url)
|
||||
}));
|
||||
};
|
||||
|
||||
const removeUrl = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
urls: prev.urls.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Author name is required';
|
||||
}
|
||||
|
||||
// Validate URLs
|
||||
formData.urls.forEach((url, index) => {
|
||||
if (url.trim() && !url.match(/^https?:\/\/.+/)) {
|
||||
newErrors[`url_${index}`] = 'Please enter a valid URL';
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm() || !author) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Prepare form data for multipart upload
|
||||
const updateFormData = new FormData();
|
||||
updateFormData.append('name', formData.name);
|
||||
updateFormData.append('notes', formData.notes);
|
||||
if (formData.authorRating > 0) {
|
||||
updateFormData.append('authorRating', formData.authorRating.toString());
|
||||
}
|
||||
|
||||
// Add URLs as array
|
||||
const validUrls = formData.urls.filter(url => url.trim());
|
||||
validUrls.forEach((url, index) => {
|
||||
updateFormData.append(`urls[${index}]`, url);
|
||||
});
|
||||
|
||||
// Add avatar if selected
|
||||
if (avatarImage) {
|
||||
updateFormData.append('avatarImage', avatarImage);
|
||||
}
|
||||
|
||||
await authorApi.updateAuthor(authorId, updateFormData);
|
||||
|
||||
router.push(`/authors/${authorId}`);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update author:', error);
|
||||
const errorMessage = error.response?.data?.message || 'Failed to update author';
|
||||
setErrors({ submit: errorMessage });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async () => {
|
||||
if (!avatarImage || !author) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await authorApi.uploadAvatar(authorId, avatarImage);
|
||||
setAvatarImage(null);
|
||||
// Reload to show new avatar
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to upload avatar:', error);
|
||||
setErrors({ submit: 'Failed to upload avatar' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
if (!author?.avatarImagePath) return;
|
||||
|
||||
if (!confirm('Are you sure you want to remove the current avatar?')) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await authorApi.removeAvatar(authorId);
|
||||
// Reload to show removed avatar
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove avatar:', error);
|
||||
setErrors({ submit: 'Failed to remove avatar' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="text-center py-20">
|
||||
<h1 className="text-2xl font-bold theme-header mb-4">Author Not Found</h1>
|
||||
<Button href="/authors">Back to Authors</Button>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold theme-header">Edit Author</h1>
|
||||
<p className="theme-text mt-2">
|
||||
Make changes to {author.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left Column - Avatar and Basic Info */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
{/* Current Avatar */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Current Avatar
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
{author.avatarImagePath ? (
|
||||
<Image
|
||||
src={getImageUrl(author.avatarImagePath)}
|
||||
alt={author.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-3xl theme-text">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{author.avatarImagePath && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={saving}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
Remove Avatar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Avatar Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Upload New Avatar
|
||||
</label>
|
||||
<ImageUpload
|
||||
onImageSelect={setAvatarImage}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
maxSizeMB={5}
|
||||
aspectRatio="1:1"
|
||||
placeholder="Drop an avatar image here or click to select"
|
||||
/>
|
||||
{avatarImage && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleAvatarUpload}
|
||||
loading={saving}
|
||||
>
|
||||
Upload Avatar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setAvatarImage(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Author Rating
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => handleRatingChange(star)}
|
||||
className={`text-2xl transition-colors ${
|
||||
star <= formData.authorRating
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{formData.authorRating > 0 && (
|
||||
<p className="text-sm theme-text mt-1">
|
||||
{formData.authorRating}/5 stars
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Name */}
|
||||
<Input
|
||||
label="Author Name *"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange('name')}
|
||||
placeholder="Enter author name"
|
||||
error={errors.name}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Notes
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={handleInputChange('notes')}
|
||||
placeholder="Add notes about this author..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URLs */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
URLs
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{formData.urls.map((url, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => updateUrl(index, e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="flex-1"
|
||||
error={errors[`url_${index}`]}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeUrl(index)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={addUrl}
|
||||
>
|
||||
+ Add URL
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Error */}
|
||||
{errors.submit && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-4 pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/authors/${authorId}`)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saving}
|
||||
disabled={!formData.name.trim()}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
217
frontend/src/app/authors/[id]/page.tsx
Normal file
217
frontend/src/app/authors/[id]/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { authorApi, storyApi, getImageUrl } from '../../../lib/api';
|
||||
import { Author, Story } from '../../../types/api';
|
||||
import AppLayout from '../../../components/layout/AppLayout';
|
||||
import Button from '../../../components/ui/Button';
|
||||
import StoryCard from '../../../components/stories/StoryCard';
|
||||
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
||||
|
||||
export default function AuthorDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const authorId = params.id as string;
|
||||
|
||||
const [author, setAuthor] = useState<Author | null>(null);
|
||||
const [stories, setStories] = useState<Story[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAuthorData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [authorData, storiesResult] = await Promise.all([
|
||||
authorApi.getAuthor(authorId),
|
||||
storyApi.getStories({ page: 0, size: 1000 }) // Get all stories to filter by author
|
||||
]);
|
||||
|
||||
setAuthor(authorData);
|
||||
// Filter stories by this author
|
||||
const authorStories = storiesResult.content.filter(
|
||||
story => story.authorId === authorId
|
||||
);
|
||||
setStories(authorStories);
|
||||
} catch (error) {
|
||||
console.error('Failed to load author data:', error);
|
||||
router.push('/authors');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authorId) {
|
||||
loadAuthorData();
|
||||
}
|
||||
}, [authorId, router]);
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="text-center py-20">
|
||||
<h1 className="text-2xl font-bold theme-header mb-4">Author Not Found</h1>
|
||||
<Button href="/authors">Back to Authors</Button>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
{author.avatarImagePath ? (
|
||||
<Image
|
||||
src={getImageUrl(author.avatarImagePath)}
|
||||
alt={author.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-3xl theme-text">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold theme-header">{author.name}</h1>
|
||||
<p className="theme-text mt-1">
|
||||
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
||||
</p>
|
||||
|
||||
{/* Author Rating */}
|
||||
{author.authorRating && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span
|
||||
key={star}
|
||||
className={`text-lg ${
|
||||
star <= (author.authorRating || 0)
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm theme-text ml-1">
|
||||
({author.authorRating}/5)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button href="/authors" variant="ghost">
|
||||
← Back to Authors
|
||||
</Button>
|
||||
<Button href={`/authors/${authorId}/edit`}>
|
||||
Edit Author
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Details */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
{/* Notes Section */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Notes</h2>
|
||||
|
||||
<div className="theme-text">
|
||||
{author.notes ? (
|
||||
<p className="whitespace-pre-wrap">{author.notes}</p>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 italic">
|
||||
No notes added yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URLs Section */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">URLs</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
{author.urls && author.urls.length > 0 ? (
|
||||
author.urls.map((url, index) => (
|
||||
<div key={index}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="theme-accent hover:underline break-all"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 italic">
|
||||
No URLs added yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stories Section */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold theme-header">Stories</h2>
|
||||
<p className="theme-text">
|
||||
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{stories.length === 0 ? (
|
||||
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
||||
<p className="theme-text text-lg mb-4">No stories by this author yet.</p>
|
||||
<Button href="/add-story">Add a Story</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{stories.map((story) => (
|
||||
<StoryCard
|
||||
key={story.id}
|
||||
story={story}
|
||||
viewMode="list"
|
||||
onUpdate={() => {
|
||||
// Reload stories after update
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
208
frontend/src/app/authors/page.tsx
Normal file
208
frontend/src/app/authors/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { authorApi, getImageUrl } from '../../lib/api';
|
||||
import { Author } from '../../types/api';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
import { Input } from '../../components/ui/Input';
|
||||
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||
|
||||
export default function AuthorsPage() {
|
||||
const [authors, setAuthors] = useState<Author[]>([]);
|
||||
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadAuthors = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const authorsResult = await authorApi.getAuthors({ page: 0, size: 1000 }); // Get all authors
|
||||
setAuthors(authorsResult.content || []);
|
||||
setFilteredAuthors(authorsResult.content || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load authors:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAuthors();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(authors)) {
|
||||
setFilteredAuthors([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = authors.filter(author =>
|
||||
author.name.toLowerCase().includes(query) ||
|
||||
(author.notes && author.notes.toLowerCase().includes(query))
|
||||
);
|
||||
setFilteredAuthors(filtered);
|
||||
} else {
|
||||
setFilteredAuthors(authors);
|
||||
}
|
||||
}, [searchQuery, authors]);
|
||||
|
||||
// Note: We no longer have individual story ratings in the author list
|
||||
// Average rating would need to be calculated on backend if needed
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold theme-header">Authors</h1>
|
||||
<p className="theme-text mt-1">
|
||||
{filteredAuthors.length} {filteredAuthors.length === 1 ? 'author' : 'authors'}
|
||||
{searchQuery ? ` found` : ` in your library`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="max-w-md">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search authors..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Authors Grid */}
|
||||
{filteredAuthors.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="theme-text text-lg mb-4">
|
||||
{searchQuery
|
||||
? 'No authors match your search'
|
||||
: 'No authors in your library yet'
|
||||
}
|
||||
</div>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="theme-accent hover:underline"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
) : (
|
||||
<p className="theme-text">
|
||||
Authors will appear here when you add stories to your library.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredAuthors.map((author) => {
|
||||
return (
|
||||
<Link
|
||||
key={author.id}
|
||||
href={`/authors/${author.id}`}
|
||||
className="theme-card theme-shadow rounded-lg p-6 hover:shadow-lg transition-shadow group"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
{author.avatarImagePath ? (
|
||||
<Image
|
||||
src={getImageUrl(author.avatarImagePath)}
|
||||
alt={author.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-2xl theme-text">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold theme-header group-hover:theme-accent transition-colors truncate">
|
||||
{author.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{/* Author Rating */}
|
||||
{author.authorRating && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span
|
||||
key={star}
|
||||
className={`text-sm ${
|
||||
star <= (author.authorRating || 0)
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm theme-text">
|
||||
({author.authorRating}/5)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="theme-text">Stories:</span>
|
||||
<span className="font-medium theme-header">
|
||||
{author.storyCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{author.urls.length > 0 && (
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="theme-text">Links:</span>
|
||||
<span className="font-medium theme-header">
|
||||
{author.urls.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes Preview */}
|
||||
{author.notes && (
|
||||
<div className="text-sm theme-text">
|
||||
<p className="line-clamp-3">
|
||||
{author.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,85 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light Mode Variables */
|
||||
--color-background: #FAFAF8;
|
||||
--color-text-primary: #2C3E50;
|
||||
--color-text-header: #0A1628;
|
||||
--color-accent: #2A4D5C;
|
||||
--color-accent-hover: #1E3D4A;
|
||||
--color-border: #E2E8F0;
|
||||
--color-card: #FFFFFF;
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark Mode Variables */
|
||||
--color-background: #0A1628;
|
||||
--color-text-primary: #F5E6D3;
|
||||
--color-text-header: #F5E6D3;
|
||||
--color-accent: #D4A574;
|
||||
--color-accent-hover: #C49860;
|
||||
--color-border: #2A4D5C;
|
||||
--color-card: #1E2D3A;
|
||||
--color-shadow: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.theme-bg {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.theme-text {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.theme-header {
|
||||
color: var(--color-text-header);
|
||||
}
|
||||
|
||||
.theme-accent {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.theme-accent-bg {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.theme-accent-bg:hover {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.theme-border {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
background-color: var(--color-card);
|
||||
}
|
||||
|
||||
.theme-shadow {
|
||||
box-shadow: 0 4px 6px -1px var(--color-shadow), 0 2px 4px -1px var(--color-shadow);
|
||||
}
|
||||
|
||||
.reading-content {
|
||||
@apply max-w-reading mx-auto px-6 py-8;
|
||||
font-family: Georgia, Times, serif;
|
||||
@apply mx-auto px-6 py-8;
|
||||
font-family: var(--reading-font-family, Georgia, Times, serif);
|
||||
font-size: var(--reading-font-size, 16px);
|
||||
max-width: var(--reading-max-width, 800px);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@@ -21,14 +91,31 @@
|
||||
.reading-content h4,
|
||||
.reading-content h5,
|
||||
.reading-content h6 {
|
||||
@apply font-bold mt-8 mb-4;
|
||||
@apply font-bold mt-8 mb-4 theme-header;
|
||||
}
|
||||
|
||||
.reading-content p {
|
||||
@apply mb-4;
|
||||
@apply mb-4 theme-text;
|
||||
}
|
||||
|
||||
.reading-content blockquote {
|
||||
@apply border-l-4 border-gray-300 pl-4 italic my-6;
|
||||
@apply border-l-4 pl-4 italic my-6 theme-border theme-text;
|
||||
}
|
||||
|
||||
.reading-content ul,
|
||||
.reading-content ol {
|
||||
@apply mb-4 pl-6 theme-text;
|
||||
}
|
||||
|
||||
.reading-content li {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.reading-content strong {
|
||||
@apply font-semibold theme-header;
|
||||
}
|
||||
|
||||
.reading-content em {
|
||||
@apply italic;
|
||||
}
|
||||
}
|
||||
34
frontend/src/app/layout.tsx
Normal file
34
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'StoryCove',
|
||||
description: 'Your personal story collection and reading experience',
|
||||
icons: {
|
||||
icon: '/favicon.png',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
|
||||
<meta name="theme-color" content="#2A4D5C" />
|
||||
</head>
|
||||
<body className={`${inter.className} theme-bg theme-text min-h-screen`}>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
288
frontend/src/app/library/page.tsx
Normal file
288
frontend/src/app/library/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { storyApi, searchApi, tagApi } from '../../lib/api';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
import { Input } from '../../components/ui/Input';
|
||||
import Button from '../../components/ui/Button';
|
||||
import StoryCard from '../../components/stories/StoryCard';
|
||||
import TagFilter from '../../components/stories/TagFilter';
|
||||
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating';
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [stories, setStories] = useState<Story[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalElements, setTotalElements] = useState(0);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
|
||||
// Load tags for filtering
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const tagsResult = await tagApi.getTags({ page: 0, size: 1000 });
|
||||
setTags(tagsResult?.content || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
// Debounce search to avoid too many API calls
|
||||
useEffect(() => {
|
||||
const debounceTimer = setTimeout(() => {
|
||||
const performSearch = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Always use search API for consistency - use '*' for match-all when no query
|
||||
const result = await searchApi.search({
|
||||
query: searchQuery.trim() || '*',
|
||||
page: page, // Use 0-based pagination consistently
|
||||
size: 20,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
sortBy: sortOption,
|
||||
sortDir: sortDirection,
|
||||
});
|
||||
|
||||
setStories(result?.results || []);
|
||||
setTotalPages(Math.ceil((result?.totalHits || 0) / 20));
|
||||
setTotalElements(result?.totalHits || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to load stories:', error);
|
||||
setStories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
performSearch();
|
||||
}, searchQuery ? 300 : 0); // Debounce search, but not other changes
|
||||
|
||||
return () => clearTimeout(debounceTimer);
|
||||
}, [searchQuery, selectedTags, page, sortOption, sortDirection, refreshTrigger]);
|
||||
|
||||
// Reset page when search or filters change
|
||||
const resetPage = () => {
|
||||
if (page !== 0) {
|
||||
setPage(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagToggle = (tagName: string) => {
|
||||
setSelectedTags(prev => {
|
||||
const newTags = prev.includes(tagName)
|
||||
? prev.filter(t => t !== tagName)
|
||||
: [...prev, tagName];
|
||||
resetPage();
|
||||
return newTags;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortOption: SortOption) => {
|
||||
if (newSortOption === sortOption) {
|
||||
// Toggle direction if same option
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortOption(newSortOption);
|
||||
setSortDirection('desc'); // Default to desc for new sort option
|
||||
}
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedTags([]);
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const handleStoryUpdate = () => {
|
||||
// Trigger reload by incrementing refresh trigger
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold theme-header">Your Story Library</h1>
|
||||
<p className="theme-text mt-1">
|
||||
{totalElements} {totalElements === 1 ? 'story' : 'stories'}
|
||||
{searchQuery || selectedTags.length > 0 ? ` found` : ` total`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button href="/add-story">
|
||||
Add New Story
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by title, author, or tags..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
viewMode === 'grid'
|
||||
? 'theme-accent-bg text-white'
|
||||
: 'theme-card theme-text hover:bg-opacity-80'
|
||||
}`}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'theme-accent-bg text-white'
|
||||
: 'theme-card theme-text hover:bg-opacity-80'
|
||||
}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort and Tag Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Sort Options */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="theme-text font-medium text-sm">Sort by:</label>
|
||||
<select
|
||||
value={sortOption}
|
||||
onChange={(e) => handleSortChange(e.target.value as SortOption)}
|
||||
className="px-3 py-1 rounded-lg theme-card theme-text theme-border border focus:outline-none focus:ring-2 focus:ring-theme-accent"
|
||||
>
|
||||
<option value="createdAt">Date Added</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="authorName">Author</option>
|
||||
<option value="rating">Rating</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(searchQuery || selectedTags.length > 0) && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag Filter */}
|
||||
<TagFilter
|
||||
tags={tags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stories Display */}
|
||||
{stories.length === 0 && !loading ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="theme-text text-lg mb-4">
|
||||
{searchQuery || selectedTags.length > 0
|
||||
? 'No stories match your filters'
|
||||
: 'No stories in your library yet'
|
||||
}
|
||||
</div>
|
||||
{searchQuery || selectedTags.length > 0 ? (
|
||||
<Button variant="ghost" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
) : (
|
||||
<Button href="/add-story">
|
||||
Add Your First Story
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{stories.map((story) => (
|
||||
<StoryCard
|
||||
key={story.id}
|
||||
story={story}
|
||||
viewMode={viewMode}
|
||||
onUpdate={handleStoryUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="flex items-center px-4 py-2 theme-text">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
100
frontend/src/app/login/page.tsx
Normal file
100
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Input } from '../../components/ui/Input';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import { useTheme } from '../../lib/theme';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push('/library');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(password);
|
||||
router.push('/library');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Invalid password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 theme-bg">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<Image
|
||||
src={theme === 'dark' ? '/logo-dark-large.png' : '/logo-large.png'}
|
||||
alt="StoryCove"
|
||||
width={128}
|
||||
height={128}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="text-center text-3xl font-bold theme-header mb-2">
|
||||
Welcome to StoryCove
|
||||
</h1>
|
||||
<p className="text-center theme-text">
|
||||
Enter your password to access your story collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="theme-card theme-shadow py-8 px-4 sm:rounded-lg sm:px-10">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={!password}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="theme-text hover:theme-accent transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'light' ? '🌙 Dark Mode' : '☀️ Light Mode'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend/src/app/page.tsx
Normal file
27
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner';
|
||||
|
||||
export default function HomePage() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (isAuthenticated) {
|
||||
router.push('/library');
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, loading, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center theme-bg">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
frontend/src/app/settings/page.tsx
Normal file
276
frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
import { useTheme } from '../../lib/theme';
|
||||
import Button from '../../components/ui/Button';
|
||||
|
||||
type FontFamily = 'serif' | 'sans' | 'mono';
|
||||
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||
type ReadingWidth = 'narrow' | 'medium' | 'wide';
|
||||
|
||||
interface Settings {
|
||||
theme: 'light' | 'dark';
|
||||
fontFamily: FontFamily;
|
||||
fontSize: FontSize;
|
||||
readingWidth: ReadingWidth;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
theme: 'light',
|
||||
fontFamily: 'serif',
|
||||
fontSize: 'medium',
|
||||
readingWidth: 'medium',
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Load settings from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedSettings = localStorage.getItem('storycove-settings');
|
||||
if (savedSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
setSettings({ ...defaultSettings, ...parsed, theme });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved settings:', error);
|
||||
setSettings({ ...defaultSettings, theme });
|
||||
}
|
||||
} else {
|
||||
setSettings({ ...defaultSettings, theme });
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Save settings to localStorage
|
||||
const saveSettings = () => {
|
||||
localStorage.setItem('storycove-settings', JSON.stringify(settings));
|
||||
|
||||
// Apply theme change
|
||||
setTheme(settings.theme);
|
||||
|
||||
// Apply font settings to CSS custom properties
|
||||
const root = document.documentElement;
|
||||
|
||||
const fontFamilyMap = {
|
||||
serif: 'Georgia, Times, serif',
|
||||
sans: 'Inter, system-ui, sans-serif',
|
||||
mono: 'Monaco, Consolas, monospace',
|
||||
};
|
||||
|
||||
const fontSizeMap = {
|
||||
small: '14px',
|
||||
medium: '16px',
|
||||
large: '18px',
|
||||
'extra-large': '20px',
|
||||
};
|
||||
|
||||
const readingWidthMap = {
|
||||
narrow: '600px',
|
||||
medium: '800px',
|
||||
wide: '1000px',
|
||||
};
|
||||
|
||||
root.style.setProperty('--reading-font-family', fontFamilyMap[settings.fontFamily]);
|
||||
root.style.setProperty('--reading-font-size', fontSizeMap[settings.fontSize]);
|
||||
root.style.setProperty('--reading-max-width', readingWidthMap[settings.readingWidth]);
|
||||
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
const updateSetting = <K extends keyof Settings>(key: K, value: Settings[K]) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold theme-header">Settings</h1>
|
||||
<p className="theme-text mt-2">
|
||||
Customize your StoryCove reading experience
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Theme Settings */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Appearance</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Theme
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => updateSetting('theme', 'light')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors ${
|
||||
settings.theme === 'light'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
☀️ Light
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSetting('theme', 'dark')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors ${
|
||||
settings.theme === 'dark'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
🌙 Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Settings */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Reading Experience</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Font Family */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Font Family
|
||||
</label>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button
|
||||
onClick={() => updateSetting('fontFamily', 'serif')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors font-serif ${
|
||||
settings.fontFamily === 'serif'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Serif
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSetting('fontFamily', 'sans')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors font-sans ${
|
||||
settings.fontFamily === 'sans'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Sans Serif
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSetting('fontFamily', 'mono')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors font-mono ${
|
||||
settings.fontFamily === 'mono'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Monospace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Font Size
|
||||
</label>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{(['small', 'medium', 'large', 'extra-large'] as FontSize[]).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => updateSetting('fontSize', size)}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors capitalize ${
|
||||
settings.fontSize === size
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{size.replace('-', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Width */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Reading Width
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
{(['narrow', 'medium', 'wide'] as ReadingWidth[]).map((width) => (
|
||||
<button
|
||||
key={width}
|
||||
onClick={() => updateSetting('readingWidth', width)}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors capitalize ${
|
||||
settings.readingWidth === width
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{width}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Preview</h2>
|
||||
|
||||
<div
|
||||
className="p-4 theme-card border theme-border rounded-lg"
|
||||
style={{
|
||||
fontFamily: settings.fontFamily === 'serif' ? 'Georgia, Times, serif'
|
||||
: settings.fontFamily === 'sans' ? 'Inter, system-ui, sans-serif'
|
||||
: 'Monaco, Consolas, monospace',
|
||||
fontSize: settings.fontSize === 'small' ? '14px'
|
||||
: settings.fontSize === 'medium' ? '16px'
|
||||
: settings.fontSize === 'large' ? '18px'
|
||||
: '20px',
|
||||
maxWidth: settings.readingWidth === 'narrow' ? '600px'
|
||||
: settings.readingWidth === 'medium' ? '800px'
|
||||
: '1000px',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xl font-bold theme-header mb-2">Sample Story Title</h3>
|
||||
<p className="theme-text mb-4">by Sample Author</p>
|
||||
<p className="theme-text leading-relaxed">
|
||||
This is how your story text will look with the current settings.
|
||||
The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet,
|
||||
consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore
|
||||
et dolore magna aliqua.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSettings({ ...defaultSettings, theme });
|
||||
}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
className={saved ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||
>
|
||||
{saved ? '✓ Saved!' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
319
frontend/src/app/stories/[id]/detail/page.tsx
Normal file
319
frontend/src/app/stories/[id]/detail/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
'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 } from '../../../../types/api';
|
||||
import AppLayout from '../../../../components/layout/AppLayout';
|
||||
import Button from '../../../../components/ui/Button';
|
||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
|
||||
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 [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);
|
||||
}
|
||||
} 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) => {
|
||||
const wordsPerMinute = 200; // Average reading speed
|
||||
const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||
return minutes;
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
371
frontend/src/app/stories/[id]/edit/page.tsx
Normal file
371
frontend/src/app/stories/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import AppLayout from '../../../../components/layout/AppLayout';
|
||||
import { Input, Textarea } from '../../../../components/ui/Input';
|
||||
import Button from '../../../../components/ui/Button';
|
||||
import TagInput from '../../../../components/stories/TagInput';
|
||||
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
||||
import ImageUpload from '../../../../components/ui/ImageUpload';
|
||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
import { storyApi } from '../../../../lib/api';
|
||||
import { Story } from '../../../../types/api';
|
||||
|
||||
export default function EditStoryPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const storyId = params.id as string;
|
||||
|
||||
const [story, setStory] = useState<Story | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
summary: '',
|
||||
authorName: '',
|
||||
contentHtml: '',
|
||||
sourceUrl: '',
|
||||
tags: [] as string[],
|
||||
seriesName: '',
|
||||
volume: '',
|
||||
});
|
||||
|
||||
const [coverImage, setCoverImage] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStory = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const storyData = await storyApi.getStory(storyId);
|
||||
setStory(storyData);
|
||||
|
||||
// Initialize form with story data
|
||||
setFormData({
|
||||
title: storyData.title,
|
||||
summary: storyData.summary || '',
|
||||
authorName: storyData.authorName,
|
||||
contentHtml: storyData.contentHtml,
|
||||
sourceUrl: storyData.sourceUrl || '',
|
||||
tags: storyData.tags?.map(tag => tag.name) || [],
|
||||
seriesName: storyData.seriesName || '',
|
||||
volume: storyData.volume?.toString() || '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load story:', error);
|
||||
router.push('/library');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (storyId) {
|
||||
loadStory();
|
||||
}
|
||||
}, [storyId, router]);
|
||||
|
||||
const handleInputChange = (field: string) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: e.target.value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (html: string) => {
|
||||
setFormData(prev => ({ ...prev, contentHtml: html }));
|
||||
if (errors.contentHtml) {
|
||||
setErrors(prev => ({ ...prev, contentHtml: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagsChange = (tags: string[]) => {
|
||||
setFormData(prev => ({ ...prev, tags }));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
}
|
||||
|
||||
if (!formData.authorName.trim()) {
|
||||
newErrors.authorName = 'Author name is required';
|
||||
}
|
||||
|
||||
if (!formData.contentHtml.trim()) {
|
||||
newErrors.contentHtml = 'Story content is required';
|
||||
}
|
||||
|
||||
if (formData.seriesName && !formData.volume) {
|
||||
newErrors.volume = 'Volume number is required when series is specified';
|
||||
}
|
||||
|
||||
if (formData.volume && !formData.seriesName.trim()) {
|
||||
newErrors.seriesName = 'Series name is required when volume is specified';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm() || !story) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Update the story with JSON data
|
||||
const updateData = {
|
||||
title: formData.title,
|
||||
summary: formData.summary || undefined,
|
||||
contentHtml: formData.contentHtml,
|
||||
sourceUrl: formData.sourceUrl || undefined,
|
||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||
authorId: story.authorId, // Keep existing author ID
|
||||
seriesId: story.seriesId, // Keep existing series ID for now
|
||||
tagNames: formData.tags,
|
||||
};
|
||||
|
||||
const updatedStory = await storyApi.updateStory(storyId, updateData);
|
||||
|
||||
// If there's a new cover image, upload it separately
|
||||
if (coverImage) {
|
||||
await storyApi.uploadCover(storyId, coverImage);
|
||||
}
|
||||
|
||||
router.push(`/stories/${storyId}`);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update story:', error);
|
||||
const errorMessage = error.response?.data?.message || 'Failed to update story';
|
||||
setErrors({ submit: errorMessage });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!story || !confirm('Are you sure you want to delete this story? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await storyApi.deleteStory(storyId);
|
||||
router.push('/library');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete story:', error);
|
||||
setErrors({ submit: 'Failed to delete story' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold theme-header">Edit Story</h1>
|
||||
<p className="theme-text mt-2">
|
||||
Make changes to "{story.title}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Title */}
|
||||
<Input
|
||||
label="Title *"
|
||||
value={formData.title}
|
||||
onChange={handleInputChange('title')}
|
||||
placeholder="Enter the story title"
|
||||
error={errors.title}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Author - Display only, not editable in edit mode for simplicity */}
|
||||
<Input
|
||||
label="Author *"
|
||||
value={formData.authorName}
|
||||
onChange={handleInputChange('authorName')}
|
||||
placeholder="Enter the author's name"
|
||||
error={errors.authorName}
|
||||
disabled
|
||||
/>
|
||||
<p className="text-sm theme-text mt-1">
|
||||
Author changes should be done through Author management
|
||||
</p>
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Summary
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.summary}
|
||||
onChange={handleInputChange('summary')}
|
||||
placeholder="Brief summary or description of the story..."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-sm theme-text mt-1">
|
||||
Optional summary that will be displayed on the story detail page
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cover Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Cover Image
|
||||
</label>
|
||||
<ImageUpload
|
||||
onImageSelect={setCoverImage}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
maxSizeMB={5}
|
||||
aspectRatio="3:4"
|
||||
placeholder="Drop a new cover image here or click to select"
|
||||
/>
|
||||
{story.coverPath && !coverImage && (
|
||||
<p className="text-sm theme-text mt-2">
|
||||
Current cover will be kept unless you upload a new one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Story Content *
|
||||
</label>
|
||||
<RichTextEditor
|
||||
value={formData.contentHtml}
|
||||
onChange={handleContentChange}
|
||||
placeholder="Edit your story content here..."
|
||||
error={errors.contentHtml}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<TagInput
|
||||
tags={formData.tags}
|
||||
onChange={handleTagsChange}
|
||||
placeholder="Edit tags to categorize your story..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Series and Volume */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Series (optional)"
|
||||
value={formData.seriesName}
|
||||
onChange={handleInputChange('seriesName')}
|
||||
placeholder="Enter series name if part of a series"
|
||||
error={errors.seriesName}
|
||||
disabled
|
||||
/>
|
||||
<p className="text-sm theme-text mt-1">
|
||||
Series changes not yet supported in edit mode
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Volume/Part (optional)"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.volume}
|
||||
onChange={handleInputChange('volume')}
|
||||
placeholder="Enter volume/part number"
|
||||
error={errors.volume}
|
||||
disabled={!formData.seriesName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source URL */}
|
||||
<Input
|
||||
label="Source URL (optional)"
|
||||
type="url"
|
||||
value={formData.sourceUrl}
|
||||
onChange={handleInputChange('sourceUrl')}
|
||||
placeholder="https://example.com/original-story-url"
|
||||
/>
|
||||
|
||||
{/* Submit Error */}
|
||||
{errors.submit && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Delete Story
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/stories/${storyId}`)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saving}
|
||||
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
274
frontend/src/app/stories/[id]/page.tsx
Normal file
274
frontend/src/app/stories/[id]/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { storyApi, seriesApi } from '../../../lib/api';
|
||||
import { Story } from '../../../types/api';
|
||||
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
||||
import Button from '../../../components/ui/Button';
|
||||
import StoryRating from '../../../components/stories/StoryRating';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
export default function StoryReadingPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [story, setStory] = useState<Story | null>(null);
|
||||
const [seriesStories, setSeriesStories] = useState<Story[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [readingProgress, setReadingProgress] = useState(0);
|
||||
|
||||
const storyId = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
const loadStory = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const storyData = await storyApi.getStory(storyId);
|
||||
setStory(storyData);
|
||||
|
||||
// Load series stories if part of a series
|
||||
if (storyData.seriesId) {
|
||||
const seriesData = await seriesApi.getSeriesStories(storyData.seriesId);
|
||||
setSeriesStories(seriesData);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load story:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load story');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (storyId) {
|
||||
loadStory();
|
||||
}
|
||||
}, [storyId]);
|
||||
|
||||
// Track reading progress
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const article = document.querySelector('[data-reading-content]') as HTMLElement;
|
||||
if (article) {
|
||||
const scrolled = window.scrollY;
|
||||
const articleTop = article.offsetTop;
|
||||
const articleHeight = article.scrollHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
const progress = Math.min(100, Math.max(0,
|
||||
((scrolled - articleTop + windowHeight) / articleHeight) * 100
|
||||
));
|
||||
|
||||
setReadingProgress(progress);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [story]);
|
||||
|
||||
const handleRatingUpdate = async (newRating: number) => {
|
||||
if (!story) return;
|
||||
|
||||
try {
|
||||
await storyApi.updateRating(story.id, newRating);
|
||||
setStory(prev => prev ? { ...prev, rating: newRating } : null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update rating:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const findNextStory = (): Story | null => {
|
||||
if (!story?.seriesId || seriesStories.length <= 1) return null;
|
||||
|
||||
const currentIndex = seriesStories.findIndex(s => s.id === story.id);
|
||||
return currentIndex < seriesStories.length - 1 ? seriesStories[currentIndex + 1] : null;
|
||||
};
|
||||
|
||||
const findPreviousStory = (): Story | null => {
|
||||
if (!story?.seriesId || seriesStories.length <= 1) return null;
|
||||
|
||||
const currentIndex = seriesStories.findIndex(s => s.id === story.id);
|
||||
return currentIndex > 0 ? seriesStories[currentIndex - 1] : null;
|
||||
};
|
||||
|
||||
const nextStory = findNextStory();
|
||||
const previousStory = findPreviousStory();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen theme-bg flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !story) {
|
||||
return (
|
||||
<div className="min-h-screen theme-bg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold theme-header mb-4">
|
||||
{error || 'Story not found'}
|
||||
</h1>
|
||||
<Button href="/library">
|
||||
Return to Library
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedContent = DOMPurify.sanitize(story.contentHtml);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen theme-bg">
|
||||
{/* Progress Bar */}
|
||||
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-200 dark:bg-gray-700 z-50">
|
||||
<div
|
||||
className="h-full theme-accent-bg transition-all duration-200 ease-out"
|
||||
style={{ width: `${readingProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="sticky top-1 z-40 theme-card theme-shadow">
|
||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/library" className="theme-text hover:theme-accent">
|
||||
← Library
|
||||
</Link>
|
||||
<Link href={`/stories/${story.id}/detail`} className="theme-text hover:theme-accent">
|
||||
📄 Details
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<StoryRating
|
||||
rating={story.rating || 0}
|
||||
onRatingChange={handleRatingUpdate}
|
||||
/>
|
||||
|
||||
<Link href={`/stories/${story.id}/edit`}>
|
||||
<Button size="sm" variant="ghost">
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Story Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
<article data-reading-content>
|
||||
|
||||
{/* Title and Metadata */}
|
||||
<header className="mb-8 text-center">
|
||||
<h1 className="text-4xl font-bold theme-header mb-4">
|
||||
{story.title}
|
||||
</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href={`/authors/${story.authorId}`}
|
||||
className="text-xl theme-accent hover:underline"
|
||||
>
|
||||
by {story.authorName}
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-center items-center gap-4 text-sm theme-text">
|
||||
<span>{story.wordCount.toLocaleString()} words</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(story.createdAt).toLocaleDateString()}</span>
|
||||
{story.seriesName && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{story.seriesName} #{story.volume}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{story.tags && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{story.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="px-3 py-1 text-sm theme-accent-bg text-white rounded-full"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source URL */}
|
||||
{story.sourceUrl && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={story.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm theme-accent hover:underline"
|
||||
>
|
||||
Original Source ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Story Content */}
|
||||
<div
|
||||
className="reading-content"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
{/* Series Navigation */}
|
||||
{(previousStory || nextStory) && (
|
||||
<nav className="mt-12 pt-8 border-t theme-border">
|
||||
<h3 className="text-lg font-semibold theme-header mb-4 text-center">
|
||||
Continue Reading in {story.seriesName}
|
||||
</h3>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
{previousStory ? (
|
||||
<Link
|
||||
href={`/stories/${previousStory.id}`}
|
||||
className="flex-1 max-w-md p-4 theme-card theme-shadow rounded-lg hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="text-sm theme-text mb-1">← Previous</div>
|
||||
<div className="font-semibold theme-header">{previousStory.title}</div>
|
||||
<div className="text-sm theme-text">Part {previousStory.volume}</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex-1 max-w-md"></div>
|
||||
)}
|
||||
|
||||
{nextStory ? (
|
||||
<Link
|
||||
href={`/stories/${nextStory.id}`}
|
||||
className="flex-1 max-w-md p-4 theme-card theme-shadow rounded-lg hover:shadow-lg transition-shadow text-right"
|
||||
>
|
||||
<div className="text-sm theme-text mb-1">Next →</div>
|
||||
<div className="font-semibold theme-header">{nextStory.title}</div>
|
||||
<div className="text-sm theme-text">Part {nextStory.volume}</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex-1 max-w-md"></div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Back to Library */}
|
||||
<div className="text-center mt-12">
|
||||
<Button href="/library" variant="ghost">
|
||||
← Return to Library
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user