Files
storycove/frontend/src/app/authors/[id]/edit/page.tsx
2025-08-08 14:09:14 +02:00

423 lines
13 KiB
TypeScript

'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 multiple parameters with same name
const validUrls = formData.urls.filter(url => url.trim());
validUrls.forEach((url) => {
updateFormData.append('urls', 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"
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>
);
}