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

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -10,6 +10,14 @@ const nextConfig = {
},
images: {
domains: ['localhost'],
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '80',
pathname: '/images/**',
},
],
},
};

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -1,9 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
@@ -14,6 +14,22 @@ module.exports = {
maxWidth: {
'reading': '800px',
},
colors: {
// Light Mode
light: {
background: '#FAFAF8',
text: '#2C3E50',
header: '#0A1628',
accent: '#2A4D5C',
},
// Dark Mode
dark: {
background: '#0A1628',
text: '#F5E6D3',
header: '#F5E6D3',
accent: '#D4A574',
},
},
},
},
plugins: [],

File diff suppressed because one or more lines are too long