inital working version
This commit is contained in:
423
frontend/src/app/authors/[id]/edit/page.tsx
Normal file
423
frontend/src/app/authors/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user