inital working version
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/assets/logo/logo.png
Normal file
|
After Width: | Height: | Size: 891 KiB |
BIN
frontend/src/assets/logo/logo_dark.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/src/assets/logo/logo_dark_backup.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
frontend/src/assets/logo/logo_dark_favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src/assets/logo/logo_dark_large.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
frontend/src/assets/logo/logo_dark_medium.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/src/assets/logo/logo_dark_small.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
frontend/src/assets/logo/logo_favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/src/assets/logo/logo_large.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
frontend/src/assets/logo/logo_medium.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/src/assets/logo/logo_small.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
21
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import Header from './Header';
|
||||
import ProtectedRoute from './ProtectedRoute';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen theme-bg">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
147
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTheme } from '../../lib/theme';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
export default function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="theme-card theme-shadow border-b theme-border sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo and Brand */}
|
||||
<Link href="/library" className="flex items-center space-x-3">
|
||||
<Image
|
||||
src={theme === 'dark' ? '/logo-dark-medium.png' : '/logo-medium.png'}
|
||||
alt="StoryCove"
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
/>
|
||||
<span className="text-xl font-bold theme-header hidden sm:block">
|
||||
StoryCove
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
<Link
|
||||
href="/library"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||
>
|
||||
Library
|
||||
</Link>
|
||||
<Link
|
||||
href="/authors"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
<Link
|
||||
href="/add-story"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||
>
|
||||
Add Story
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg theme-text hover:theme-accent transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'light' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
|
||||
{/* Settings */}
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 rounded-lg theme-text hover:theme-accent transition-colors"
|
||||
aria-label="Settings"
|
||||
>
|
||||
⚙️
|
||||
</Link>
|
||||
|
||||
{/* Logout */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="md:hidden p-2 rounded-lg theme-text hover:theme-accent transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isMenuOpen ? '✕' : '☰'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden border-t theme-border py-4">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<Link
|
||||
href="/library"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Library
|
||||
</Link>
|
||||
<Link
|
||||
href="/authors"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
<Link
|
||||
href="/add-story"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Add Story
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1 text-left"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { FullPageSpinner } from '../ui/LoadingSpinner';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return <FullPageSpinner />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <FullPageSpinner />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
184
frontend/src/components/stories/RichTextEditor.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { Textarea } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Write your story here...',
|
||||
error
|
||||
}: RichTextEditorProps) {
|
||||
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
||||
const [htmlValue, setHtmlValue] = useState(value);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleVisualChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const plainText = e.target.value;
|
||||
// Convert plain text to basic HTML paragraphs
|
||||
const htmlContent = plainText
|
||||
.split('\n\n')
|
||||
.filter(paragraph => paragraph.trim())
|
||||
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('\n');
|
||||
|
||||
onChange(htmlContent);
|
||||
setHtmlValue(htmlContent);
|
||||
};
|
||||
|
||||
const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const html = e.target.value;
|
||||
setHtmlValue(html);
|
||||
onChange(html);
|
||||
};
|
||||
|
||||
const getPlainText = (html: string): string => {
|
||||
// Simple HTML to plain text conversion
|
||||
return html
|
||||
.replace(/<\/p>/gi, '\n\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const formatText = (tag: string) => {
|
||||
if (viewMode === 'visual') {
|
||||
// For visual mode, we'll just show formatting helpers
|
||||
// In a real implementation, you'd want a proper WYSIWYG editor
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = htmlValue.substring(start, end);
|
||||
|
||||
if (selectedText) {
|
||||
const beforeText = htmlValue.substring(0, start);
|
||||
const afterText = htmlValue.substring(end);
|
||||
const formattedText = `<${tag}>${selectedText}</${tag}>`;
|
||||
const newValue = beforeText + formattedText + afterText;
|
||||
|
||||
setHtmlValue(newValue);
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between p-2 theme-card border theme-border rounded-t-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setViewMode('visual')}
|
||||
className={viewMode === 'visual' ? 'theme-accent-bg text-white' : ''}
|
||||
>
|
||||
Visual
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setViewMode('html')}
|
||||
className={viewMode === 'html' ? 'theme-accent-bg text-white' : ''}
|
||||
>
|
||||
HTML
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'html' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('strong')}
|
||||
title="Bold"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('em')}
|
||||
title="Italic"
|
||||
>
|
||||
<em>I</em>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('p')}
|
||||
title="Paragraph"
|
||||
>
|
||||
P
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="border theme-border rounded-b-lg overflow-hidden">
|
||||
{viewMode === 'visual' ? (
|
||||
<Textarea
|
||||
value={getPlainText(value)}
|
||||
onChange={handleVisualChange}
|
||||
placeholder={placeholder}
|
||||
rows={12}
|
||||
className="border-0 rounded-none focus:ring-0"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={htmlValue}
|
||||
onChange={handleHtmlChange}
|
||||
placeholder="<p>Write your HTML content here...</p>"
|
||||
rows={12}
|
||||
className="border-0 rounded-none focus:ring-0 font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview for HTML mode */}
|
||||
{viewMode === 'html' && value && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto"
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs theme-text">
|
||||
<p>
|
||||
<strong>Visual mode:</strong> Write in plain text, paragraphs will be automatically formatted.
|
||||
</p>
|
||||
<p>
|
||||
<strong>HTML mode:</strong> Write HTML directly for advanced formatting.
|
||||
Allowed tags: p, br, strong, em, ul, ol, li, h1-h6, blockquote.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
frontend/src/components/stories/StoryCard.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Story } from '../../types/api';
|
||||
import { storyApi, getImageUrl } from '../../lib/api';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface StoryCardProps {
|
||||
story: Story;
|
||||
viewMode: 'grid' | 'list';
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function StoryCard({ story, viewMode, onUpdate }: StoryCardProps) {
|
||||
const [rating, setRating] = useState(story.rating || 0);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const handleRatingClick = async (newRating: number) => {
|
||||
if (updating) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
await storyApi.updateRating(story.id, newRating);
|
||||
setRating(newRating);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to update rating:', error);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatWordCount = (wordCount: number) => {
|
||||
return wordCount.toLocaleString() + ' words';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div className="theme-card theme-shadow rounded-lg p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex gap-4">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/stories/${story.id}/detail`}>
|
||||
<div className="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded overflow-hidden">
|
||||
{story.coverPath ? (
|
||||
<Image
|
||||
src={getImageUrl(story.coverPath)}
|
||||
alt={story.title}
|
||||
width={64}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center theme-text text-xs">
|
||||
📖
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link href={`/stories/${story.id}/detail`}>
|
||||
<h3 className="text-lg font-semibold theme-header hover:theme-accent transition-colors truncate">
|
||||
{story.title}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<Link href={`/authors/${story.authorId}`}>
|
||||
<p className="theme-text hover:theme-accent transition-colors">
|
||||
{story.authorName}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-sm theme-text">
|
||||
<span>{formatWordCount(story.wordCount)}</span>
|
||||
<span>{formatDate(story.createdAt)}</span>
|
||||
{story.seriesName && (
|
||||
<span>
|
||||
{story.seriesName} #{story.volume}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{story.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="px-2 py-1 text-xs rounded theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{story.tags.length > 3 && (
|
||||
<span className="px-2 py-1 text-xs theme-text">
|
||||
+{story.tags.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
{/* Rating */}
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
className={`text-lg ${
|
||||
star <= rating
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'
|
||||
} hover:text-yellow-400 transition-colors ${
|
||||
updating ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
disabled={updating}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link href={`/stories/${story.id}`}>
|
||||
<Button size="sm" className="w-full">
|
||||
Read
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/stories/${story.id}/edit`}>
|
||||
<Button size="sm" variant="ghost" className="w-full">
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid view
|
||||
return (
|
||||
<div className="theme-card theme-shadow rounded-lg overflow-hidden hover:shadow-lg transition-shadow group">
|
||||
{/* Cover Image */}
|
||||
<Link href={`/stories/${story.id}`}>
|
||||
<div className="aspect-[3/4] bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
{story.coverPath ? (
|
||||
<Image
|
||||
src={getImageUrl(story.coverPath)}
|
||||
alt={story.title}
|
||||
width={300}
|
||||
height={400}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center theme-text text-6xl">
|
||||
📖
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Title and Author */}
|
||||
<Link href={`/stories/${story.id}`}>
|
||||
<h3 className="font-semibold theme-header hover:theme-accent transition-colors line-clamp-2 mb-1">
|
||||
{story.title}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<Link href={`/authors/${story.authorId}`}>
|
||||
<p className="text-sm theme-text hover:theme-accent transition-colors mb-2">
|
||||
{story.authorName}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
{/* Rating */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
className={`text-sm ${
|
||||
star <= rating
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'
|
||||
} hover:text-yellow-400 transition-colors ${
|
||||
updating ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
disabled={updating}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-xs theme-text space-y-1">
|
||||
<div>{formatWordCount(story.wordCount)}</div>
|
||||
<div>{formatDate(story.createdAt)}</div>
|
||||
{story.seriesName && (
|
||||
<div>
|
||||
{story.seriesName} #{story.volume}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{story.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="px-2 py-1 text-xs rounded theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{story.tags.length > 2 && (
|
||||
<span className="px-2 py-1 text-xs theme-text">
|
||||
+{story.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Link href={`/stories/${story.id}`} className="flex-1">
|
||||
<Button size="sm" className="w-full">
|
||||
Read
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/stories/${story.id}/edit`}>
|
||||
<Button size="sm" variant="ghost">
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/stories/StoryRating.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface StoryRatingProps {
|
||||
rating: number;
|
||||
onRatingChange: (rating: number) => void;
|
||||
readonly?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export default function StoryRating({
|
||||
rating,
|
||||
onRatingChange,
|
||||
readonly = false,
|
||||
size = 'md'
|
||||
}: StoryRatingProps) {
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-lg',
|
||||
lg: 'text-2xl',
|
||||
};
|
||||
|
||||
const handleRatingClick = async (newRating: number) => {
|
||||
if (readonly || updating) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
await onRatingChange(newRating);
|
||||
} catch (error) {
|
||||
console.error('Failed to update rating:', error);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayRating = hoveredRating || rating;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
onMouseEnter={() => !readonly && setHoveredRating(star)}
|
||||
onMouseLeave={() => !readonly && setHoveredRating(0)}
|
||||
disabled={readonly || updating}
|
||||
className={`${sizeClasses[size]} ${
|
||||
star <= displayRating
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'
|
||||
} ${
|
||||
readonly
|
||||
? 'cursor-default'
|
||||
: updating
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer hover:text-yellow-400'
|
||||
} transition-colors`}
|
||||
aria-label={`Rate ${star} star${star !== 1 ? 's' : ''}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!readonly && (
|
||||
<span className="ml-2 text-sm theme-text">
|
||||
{rating > 0 ? `(${rating}/5)` : 'Rate this story'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{updating && (
|
||||
<span className="ml-2 text-sm theme-text">Saving...</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/stories/TagFilter.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { Tag } from '../../types/api';
|
||||
|
||||
interface TagFilterProps {
|
||||
tags: Tag[];
|
||||
selectedTags: string[];
|
||||
onTagToggle: (tagName: string) => void;
|
||||
}
|
||||
|
||||
export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilterProps) {
|
||||
if (!Array.isArray(tags) || tags.length === 0) return null;
|
||||
|
||||
// Sort tags by usage count (descending) and then alphabetically
|
||||
const sortedTags = [...tags].sort((a, b) => {
|
||||
const aCount = a.storyCount || 0;
|
||||
const bCount = b.storyCount || 0;
|
||||
if (bCount !== aCount) {
|
||||
return bCount - aCount;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium theme-header">Filter by Tags:</h3>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||
{sortedTags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.name);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
isSelected
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount || 0})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="text-sm theme-text">
|
||||
Filtering by: {selectedTags.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/stories/TagInput.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { tagApi } from '../../lib/api';
|
||||
|
||||
interface TagInputProps {
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }: TagInputProps) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
if (inputValue.length > 0) {
|
||||
try {
|
||||
const suggestionList = await tagApi.getTagAutocomplete(inputValue);
|
||||
// Filter out already selected tags
|
||||
const filteredSuggestions = suggestionList.filter(
|
||||
suggestion => !tags.includes(suggestion)
|
||||
);
|
||||
setSuggestions(filteredSuggestions);
|
||||
setShowSuggestions(filteredSuggestions.length > 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tag suggestions:', error);
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(fetchSuggestions, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [inputValue, tags]);
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const trimmedTag = tag.trim().toLowerCase();
|
||||
if (trimmedTag && !tags.includes(trimmedTag)) {
|
||||
onChange([...tags, trimmedTag]);
|
||||
}
|
||||
setInputValue('');
|
||||
setShowSuggestions(false);
|
||||
setActiveSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
onChange(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
if (activeSuggestionIndex >= 0 && suggestions[activeSuggestionIndex]) {
|
||||
addTag(suggestions[activeSuggestionIndex]);
|
||||
} else if (inputValue.trim()) {
|
||||
addTag(inputValue);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Backspace':
|
||||
if (!inputValue && tags.length > 0) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveSuggestionIndex(prev =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveSuggestionIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
setShowSuggestions(false);
|
||||
setActiveSuggestionIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
addTag(suggestion);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="min-h-[2.5rem] w-full px-3 py-2 border rounded-lg theme-card theme-text theme-border focus-within:outline-none focus-within:ring-2 focus-within:ring-theme-accent focus-within:border-transparent">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Existing Tags */}
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-1 text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="ml-1 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => inputValue && setShowSuggestions(suggestions.length > 0)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||
placeholder={tags.length === 0 ? placeholder : ''}
|
||||
className="flex-1 min-w-[120px] bg-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border theme-border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
index === activeSuggestionIndex
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
|
||||
: 'theme-text'
|
||||
} ${index === 0 ? 'rounded-t-lg' : ''} ${
|
||||
index === suggestions.length - 1 ? 'rounded-b-lg' : ''
|
||||
}`}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Type and press Enter or comma to add tags
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
href?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'primary', size = 'md', loading = false, href, className = '', children, disabled, ...props }, ref) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'theme-accent-bg text-white hover:theme-accent-bg focus:ring-theme-accent',
|
||||
secondary: 'theme-card theme-text border theme-border hover:bg-opacity-80 focus:ring-theme-accent',
|
||||
ghost: 'theme-text hover:bg-gray-100 dark:hover:bg-gray-800 focus:ring-theme-accent',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const combinedClasses = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={combinedClasses}>
|
||||
{loading && <LoadingSpinner size="sm" className="mr-2" />}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={combinedClasses}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && <LoadingSpinner size="sm" className="mr-2" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export default Button;
|
||||
137
frontend/src/components/ui/ImageUpload.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface ImageUploadProps {
|
||||
onImageSelect: (file: File | null) => void;
|
||||
accept?: string;
|
||||
maxSizeMB?: number;
|
||||
aspectRatio?: string;
|
||||
placeholder?: string;
|
||||
currentImageUrl?: string;
|
||||
}
|
||||
|
||||
export default function ImageUpload({
|
||||
onImageSelect,
|
||||
accept = 'image/*',
|
||||
maxSizeMB = 5,
|
||||
aspectRatio = '1:1',
|
||||
placeholder = 'Drop an image here or click to select',
|
||||
currentImageUrl,
|
||||
}: ImageUploadProps) {
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: any[]) => {
|
||||
setError(null);
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
const rejection = rejectedFiles[0];
|
||||
if (rejection.errors?.[0]?.code === 'file-too-large') {
|
||||
setError(`File is too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
} else if (rejection.errors?.[0]?.code === 'file-invalid-type') {
|
||||
setError('Invalid file type. Please select an image file.');
|
||||
} else {
|
||||
setError('File rejected. Please try another file.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const file = acceptedFiles[0];
|
||||
if (file) {
|
||||
// Create preview
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setPreview(previewUrl);
|
||||
onImageSelect(file);
|
||||
}
|
||||
}, [onImageSelect, maxSizeMB]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': accept.split(',').map(type => type.trim()),
|
||||
},
|
||||
maxFiles: 1,
|
||||
maxSize: maxSizeMB * 1024 * 1024, // Convert MB to bytes
|
||||
});
|
||||
|
||||
const clearImage = () => {
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
onImageSelect(null);
|
||||
};
|
||||
|
||||
const aspectRatioClass = {
|
||||
'1:1': 'aspect-square',
|
||||
'3:4': 'aspect-[3/4]',
|
||||
'4:3': 'aspect-[4/3]',
|
||||
'16:9': 'aspect-video',
|
||||
}[aspectRatio] || 'aspect-square';
|
||||
|
||||
const displayImage = preview || currentImageUrl;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||
: error
|
||||
? 'border-red-300 bg-red-50 dark:bg-red-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
} ${displayImage ? 'p-0 border-0' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{displayImage ? (
|
||||
<div className={`relative ${aspectRatioClass} rounded-lg overflow-hidden group`}>
|
||||
<Image
|
||||
src={displayImage}
|
||||
alt="Preview"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all duration-200 flex items-center justify-center">
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearImage();
|
||||
}}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<span className="text-white text-sm">
|
||||
or click to change
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-4xl theme-text">📸</div>
|
||||
<div className="theme-text">
|
||||
{isDragActive ? (
|
||||
<p>Drop the image here...</p>
|
||||
) : (
|
||||
<p>{placeholder}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Supports JPEG, PNG, WebP up to {maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { InputHTMLAttributes, forwardRef, TextareaHTMLAttributes } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, className = '', ...props }, ref) => {
|
||||
const baseClasses = 'w-full px-3 py-2 border rounded-lg theme-card theme-text theme-border focus:outline-none focus:ring-2 focus:ring-theme-accent focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium theme-header mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`${baseClasses} ${error ? 'border-red-500' : ''} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ label, error, className = '', rows = 4, ...props }, ref) => {
|
||||
const baseClasses = 'w-full px-3 py-2 border rounded-lg theme-card theme-text theme-border focus:outline-none focus:ring-2 focus:ring-theme-accent focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed resize-vertical';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium theme-header mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
className={`${baseClasses} ${error ? 'border-red-500' : ''} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Input, Textarea };
|
||||
29
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`inline-block ${sizeClasses[size]} ${className}`}>
|
||||
<div className="animate-spin rounded-full border-2 border-gray-300 border-t-theme-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullPageSpinner() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center theme-bg">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="lg" className="mb-4" />
|
||||
<p className="theme-text">Loading StoryCove...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { authApi } from '../lib/api';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
login: (password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is already authenticated on app load
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const authenticated = authApi.isAuthenticated();
|
||||
setIsAuthenticated(authenticated);
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (password: string) => {
|
||||
try {
|
||||
await authApi.login(password);
|
||||
setIsAuthenticated(true);
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authApi.logout();
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, login, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
236
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import axios from 'axios';
|
||||
import { AuthResponse, Story, Author, Tag, Series, SearchResult, PagedResult } from '../types/api';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
||||
|
||||
// Create axios instance with default config
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
withCredentials: true, // Include cookies for JWT
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add JWT token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor to handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear invalid token and redirect to login
|
||||
localStorage.removeItem('auth-token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth endpoints
|
||||
export const authApi = {
|
||||
login: async (password: string): Promise<AuthResponse> => {
|
||||
const response = await api.post('/auth/login', { password });
|
||||
// Store token in localStorage (httpOnly cookie is preferred but this is for backup)
|
||||
if (response.data.token) {
|
||||
localStorage.setItem('auth-token', response.data.token);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
localStorage.removeItem('auth-token');
|
||||
// Could call backend logout endpoint if implemented
|
||||
},
|
||||
|
||||
isAuthenticated: (): boolean => {
|
||||
return !!localStorage.getItem('auth-token');
|
||||
},
|
||||
};
|
||||
|
||||
// Story endpoints
|
||||
export const storyApi = {
|
||||
getStories: async (params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
}): Promise<PagedResult<Story>> => {
|
||||
const response = await api.get('/stories', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStory: async (id: string): Promise<Story> => {
|
||||
const response = await api.get(`/stories/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createStory: async (storyData: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
contentHtml: string;
|
||||
sourceUrl?: string;
|
||||
volume?: number;
|
||||
authorId?: string;
|
||||
authorName?: string;
|
||||
seriesId?: string;
|
||||
tagNames?: string[];
|
||||
}): Promise<Story> => {
|
||||
const response = await api.post('/stories', storyData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateStory: async (id: string, storyData: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
contentHtml: string;
|
||||
sourceUrl?: string;
|
||||
volume?: number;
|
||||
authorId?: string;
|
||||
seriesId?: string;
|
||||
tagNames?: string[];
|
||||
}): Promise<Story> => {
|
||||
const response = await api.put(`/stories/${id}`, storyData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteStory: async (id: string): Promise<void> => {
|
||||
await api.delete(`/stories/${id}`);
|
||||
},
|
||||
|
||||
updateRating: async (id: string, rating: number): Promise<void> => {
|
||||
await api.post(`/stories/${id}/rating`, { rating });
|
||||
},
|
||||
|
||||
uploadCover: async (id: string, coverImage: File): Promise<{ imagePath: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', coverImage);
|
||||
const response = await api.post(`/stories/${id}/cover`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeCover: async (id: string): Promise<void> => {
|
||||
await api.delete(`/stories/${id}/cover`);
|
||||
},
|
||||
|
||||
addTag: async (storyId: string, tagId: string): Promise<Story> => {
|
||||
const response = await api.post(`/stories/${storyId}/tags/${tagId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeTag: async (storyId: string, tagId: string): Promise<Story> => {
|
||||
const response = await api.delete(`/stories/${storyId}/tags/${tagId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Author endpoints
|
||||
export const authorApi = {
|
||||
getAuthors: async (params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
}): Promise<PagedResult<Author>> => {
|
||||
const response = await api.get('/authors', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAuthor: async (id: string): Promise<Author> => {
|
||||
const response = await api.get(`/authors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateAuthor: async (id: string, formData: FormData): Promise<Author> => {
|
||||
const response = await api.put(`/authors/${id}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadAvatar: async (id: string, avatarImage: File): Promise<{ imagePath: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', avatarImage);
|
||||
const response = await api.post(`/authors/${id}/avatar`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeAvatar: async (id: string): Promise<void> => {
|
||||
await api.delete(`/authors/${id}/avatar`);
|
||||
},
|
||||
};
|
||||
|
||||
// Tag endpoints
|
||||
export const tagApi = {
|
||||
getTags: async (params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
}): Promise<PagedResult<Tag>> => {
|
||||
const response = await api.get('/tags', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTagAutocomplete: async (query: string): Promise<string[]> => {
|
||||
const response = await api.get('/tags/autocomplete', { params: { query } });
|
||||
// Backend returns TagDto[], extract just the names
|
||||
return response.data.map((tag: Tag) => tag.name);
|
||||
},
|
||||
};
|
||||
|
||||
// Series endpoints
|
||||
export const seriesApi = {
|
||||
getSeries: async (params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
}): Promise<PagedResult<Series>> => {
|
||||
const response = await api.get('/series', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSeriesStories: async (id: string): Promise<Story[]> => {
|
||||
const response = await api.get(`/stories/series/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Search endpoints
|
||||
export const searchApi = {
|
||||
search: async (params: {
|
||||
query: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
authors?: string[];
|
||||
tags?: string[];
|
||||
minRating?: number;
|
||||
maxRating?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
}): Promise<SearchResult> => {
|
||||
const response = await api.get('/stories/search', { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Image utility
|
||||
export const getImageUrl = (path: string): string => {
|
||||
if (!path) return '';
|
||||
// Images are served directly by nginx at /images/
|
||||
return `/images/${path}`;
|
||||
};
|
||||
37
frontend/src/lib/theme.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage for saved preference
|
||||
const savedTheme = localStorage.getItem('storycove-theme') as Theme;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
} else {
|
||||
// Check system preference
|
||||
const systemPreference = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
setTheme(systemPreference);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme to document
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('storycove-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return { theme, setTheme, toggleTheme };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface Story {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
contentHtml: string;
|
||||
@@ -11,8 +12,8 @@ export interface Story {
|
||||
seriesName?: string;
|
||||
volume?: number;
|
||||
rating?: number;
|
||||
coverImagePath?: string;
|
||||
tags: string[];
|
||||
coverPath?: string;
|
||||
tags: Tag[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -23,8 +24,8 @@ export interface Author {
|
||||
notes?: string;
|
||||
authorRating?: number;
|
||||
avatarImagePath?: string;
|
||||
urls: AuthorUrl[];
|
||||
stories: Story[];
|
||||
urls: string[];
|
||||
storyCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -38,7 +39,9 @@ export interface AuthorUrl {
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
storyCount: number;
|
||||
storyCount?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
@@ -53,8 +56,22 @@ export interface AuthResponse {
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
stories: Story[];
|
||||
totalCount: number;
|
||||
results: Story[];
|
||||
totalHits: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
query: string;
|
||||
searchTimeMs: number;
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
numberOfElements: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
empty: boolean;
|
||||
}
|
||||