401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
'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 TagSuggestions from '../../../../components/tags/TagSuggestions';
|
|
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
|
import ImageUpload from '../../../../components/ui/ImageUpload';
|
|
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
|
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: '',
|
|
authorId: undefined as string | undefined,
|
|
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,
|
|
authorId: storyData.authorId,
|
|
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 handleAddSuggestedTag = (tagName: string) => {
|
|
if (!formData.tags.includes(tagName.toLowerCase())) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
tags: [...prev.tags, tagName.toLowerCase()]
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
authorName,
|
|
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
|
|
}));
|
|
|
|
// Clear error when user changes author
|
|
if (errors.authorName) {
|
|
setErrors(prev => ({ ...prev, authorName: '' }));
|
|
}
|
|
};
|
|
|
|
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 && formData.volume ? parseInt(formData.volume) : undefined,
|
|
seriesName: formData.seriesName, // Send empty string to explicitly clear series
|
|
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
|
|
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
|
tagNames: formData.tags,
|
|
};
|
|
|
|
const updatedStory = await storyApi.updateStory(storyId, updateData);
|
|
|
|
// If there's a new cover image, upload it separately
|
|
if (coverImage) {
|
|
await storyApi.uploadCover(storyId, coverImage);
|
|
}
|
|
|
|
router.push(`/stories/${storyId}`);
|
|
} catch (error: any) {
|
|
console.error('Failed to update story:', error);
|
|
const errorMessage = error.response?.data?.message || 'Failed to update story';
|
|
setErrors({ submit: errorMessage });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!story || !confirm('Are you sure you want to delete this story? This action cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSaving(true);
|
|
await storyApi.deleteStory(storyId);
|
|
router.push('/library');
|
|
} catch (error) {
|
|
console.error('Failed to delete story:', error);
|
|
setErrors({ submit: 'Failed to delete story' });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="flex items-center justify-center py-20">
|
|
<LoadingSpinner size="lg" />
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
if (!story) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="text-center py-20">
|
|
<h1 className="text-2xl font-bold theme-header mb-4">Story Not Found</h1>
|
|
<Button href="/library">Back to Library</Button>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppLayout>
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold theme-header">Edit Story</h1>
|
|
<p className="theme-text mt-2">
|
|
Make changes to "{story.title}"
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Title */}
|
|
<Input
|
|
label="Title *"
|
|
value={formData.title}
|
|
onChange={handleInputChange('title')}
|
|
placeholder="Enter the story title"
|
|
error={errors.title}
|
|
required
|
|
/>
|
|
|
|
{/* Author Selector */}
|
|
<AuthorSelector
|
|
label="Author *"
|
|
value={formData.authorName}
|
|
onChange={handleAuthorChange}
|
|
placeholder="Select or enter author 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"
|
|
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..."
|
|
/>
|
|
|
|
{/* Tag Suggestions */}
|
|
<TagSuggestions
|
|
title={formData.title}
|
|
content={formData.contentHtml}
|
|
summary={formData.summary}
|
|
currentTags={formData.tags}
|
|
onAddTag={handleAddSuggestedTag}
|
|
disabled={saving}
|
|
/>
|
|
</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}
|
|
/>
|
|
</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>
|
|
);
|
|
} |