423 lines
13 KiB
TypeScript
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>
|
|
);
|
|
} |