inital working version

This commit is contained in:
Stefan Hardegger
2025-07-22 21:49:40 +02:00
parent bebb799784
commit 59d29dceaf
98 changed files with 8027 additions and 856 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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;
}
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &quot;{story.title}&quot;
</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>
);
}

View 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>
);
}