podzahr/components/EditAlbumModal.tsx
nfel 9478aa319f
main: added presigned url + favicon
Signed-off-by: nfel <nfilsaraee@gmail.com>
2026-01-01 19:06:11 +03:30

746 lines
32 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaTimes, FaPlus, FaTrash, FaUpload, FaImage } from 'react-icons/fa';
import { Album, Song } from '@/lib/types';
interface EditAlbumModalProps {
show: boolean;
album: Album | null;
onClose: () => void;
onUpdate: (albumId: string, album: Album) => void;
}
export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditAlbumModalProps) {
const [title, setTitle] = useState('');
const [year, setYear] = useState('');
const [genre, setGenre] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('');
const [coverImage, setCoverImage] = useState('');
const [coverImageFile, setCoverImageFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [songs, setSongs] = useState<Song[]>([{ id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
const [error, setError] = useState('');
const [tag, setTag] = useState<'Album' | 'EP' | 'Demo' | 'Deluxe' | 'Single'>('Album');
const [format, setFormat] = useState<'mp3' | 'm4a' | 'flac' | 'wav'>('mp3');
const [bitrate, setBitrate] = useState('320kbps');
const [uploadMethod, setUploadMethod] = useState<'file' | 'url'>('file');
const [isDragging, setIsDragging] = useState(false);
const [songUploadMethod, setSongUploadMethod] = useState<{ [key: number]: { preview: 'file' | 'url', full: 'file' | 'url' } }>({});
const [uploadingSong, setUploadingSong] = useState<{ index: number, field: 'preview' | 'full' } | null>(null);
// Populate form when album changes
useEffect(() => {
if (album) {
setTitle(album.title);
setYear(album.year.toString());
setGenre(album.genre);
setDescription(album.description);
setPrice(album.price.toString());
setCoverImage(album.coverImage || '');
setSongs(album.songs.map((song) => ({ ...song })));
setTag(album.tag);
setFormat(album.format);
setBitrate(album.bitrate);
}
}, [album]);
const handleAddSong = () => {
setSongs([...songs, { id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
};
const handleRemoveSong = (index: number) => {
if (songs.length > 1) {
setSongs(songs.filter((_, i) => i !== index));
}
};
const handleSongChange = (index: number, field: 'title' | 'duration' | 'previewUrl' | 'fullUrl', value: string) => {
const updatedSongs = [...songs];
updatedSongs[index][field] = value;
setSongs(updatedSongs);
};
const uploadFile = async (file: File) => {
setCoverImageFile(file);
setIsUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', 'album-covers');
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
setCoverImage(data.url);
} catch (err) {
setError('Failed to upload cover image');
setCoverImageFile(null);
} finally {
setIsUploading(false);
}
};
const handleCoverImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
await uploadFile(file);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
setError('Invalid file type. Only images are allowed.');
return;
}
await uploadFile(file);
};
const uploadAudioFile = async (file: File, index: number, field: 'preview' | 'full') => {
setUploadingSong({ index, field });
setError('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', field === 'preview' ? 'audio/previews' : 'audio/full');
const response = await fetch('/api/upload/audio', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
const updatedSongs = [...songs];
updatedSongs[index][field === 'preview' ? 'previewUrl' : 'fullUrl'] = data.url;
setSongs(updatedSongs);
} catch (err) {
setError(`Failed to upload ${field} audio file`);
} finally {
setUploadingSong(null);
}
};
const handleAudioFileChange = async (e: React.ChangeEvent<HTMLInputElement>, index: number, field: 'preview' | 'full') => {
const file = e.target.files?.[0];
if (!file) return;
await uploadAudioFile(file, index, field);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!album) return;
// Validation
if (!title.trim()) {
setError('Title is required');
return;
}
const yearNum = parseInt(year);
if (!year || yearNum < 1900 || yearNum > new Date().getFullYear() + 1) {
setError('Please enter a valid year');
return;
}
if (!genre.trim()) {
setError('Genre is required');
return;
}
if (!description.trim()) {
setError('Description is required');
return;
}
const priceNum = parseFloat(price);
if (!price || priceNum <= 0) {
setError('Please enter a valid price');
return;
}
// Validate songs
for (let i = 0; i < songs.length; i++) {
if (!songs[i].title.trim()) {
setError(`Song ${i + 1} title is required`);
return;
}
if (!songs[i].duration.trim()) {
setError(`Song ${i + 1} duration is required`);
return;
}
}
// Update album
const albumSongs: Song[] = songs.map((song, index) => ({
id: song.id || `${album.id}-${index + 1}`,
title: song.title,
duration: song.duration,
previewUrl: song.previewUrl || '/audio/preview-1.mp3',
fullUrl: song.fullUrl || '/audio/default-full.mp3',
}));
const updatedAlbum: Album = {
id: album.id,
title,
coverImage: coverImage || album.coverImage || '/albums/default-cover.jpg',
year: yearNum,
genre,
description,
price: priceNum,
songs: albumSongs,
tag,
format,
bitrate,
};
onUpdate(album.id, updatedAlbum);
handleClose();
};
const handleClose = () => {
setError('');
onClose();
};
if (!album) return null;
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={handleClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass-effect rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-8 border-2 border-accent-orange/30"
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-orange to-accent-cyan">
Edit Album
</h2>
<button
onClick={handleClose}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
aria-label="Close"
>
<FaTimes className="text-xl text-gray-400" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Album Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter album title"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
{/* Cover Image Upload */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Album Cover Image
</label>
{/* Method Tabs */}
<div className="flex gap-2 mb-3">
<button
type="button"
onClick={() => setUploadMethod('file')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
uploadMethod === 'file'
? 'bg-accent-orange text-white'
: 'bg-white/5 text-gray-400 hover:bg-white/10'
} rounded-lg`}
>
<FaUpload className="inline mr-2" />
Upload File
</button>
<button
type="button"
onClick={() => setUploadMethod('url')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
uploadMethod === 'url'
? 'bg-accent-orange text-white'
: 'bg-white/5 text-gray-400 hover:bg-white/10'
} rounded-lg`}
>
<FaImage className="inline mr-2" />
Enter URL
</button>
</div>
{uploadMethod === 'file' ? (
<>
{/* Drag & Drop Zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative border-2 border-dashed rounded-lg p-8 transition-all ${
isDragging
? 'border-accent-orange bg-accent-orange/10'
: 'border-white/20 hover:border-white/40'
}`}
>
{isUploading ? (
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-accent-orange border-t-transparent mx-auto mb-4"></div>
<p className="text-gray-400">Uploading...</p>
</div>
) : coverImage ? (
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-lg overflow-hidden border-2 border-white/20 flex-shrink-0">
<img src={coverImage} alt="Cover preview" className="w-full h-full object-cover" />
</div>
<div className="flex-1">
<p className="text-accent-orange font-medium mb-1">Image Set</p>
<p className="text-xs text-gray-500 break-all">{coverImage}</p>
</div>
<button
type="button"
onClick={() => setCoverImage('')}
className="text-gray-400 hover:text-white transition-colors"
>
<FaTimes />
</button>
</div>
) : (
<div className="text-center">
<FaUpload className="text-4xl text-gray-400 mx-auto mb-4" />
<p className="text-gray-300 mb-2">Drag & drop your image here</p>
<p className="text-sm text-gray-500 mb-4">or</p>
<label className="inline-block px-6 py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg cursor-pointer transition-colors">
<span className="text-gray-300 font-medium">Browse Files</span>
<input
type="file"
accept="image/*"
onChange={handleCoverImageChange}
className="hidden"
disabled={isUploading}
/>
</label>
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Supported: JPG, PNG, WEBP, GIF (max 5MB)
</p>
</>
) : (
<>
{/* URL Input */}
<div className="space-y-3">
<input
type="url"
value={coverImage}
onChange={(e) => setCoverImage(e.target.value)}
placeholder="https://example.com/album-cover.jpg"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
/>
{coverImage && (
<div className="w-32 h-32 rounded-lg overflow-hidden border-2 border-white/20">
<img src={coverImage} alt="Cover preview" className="w-full h-full object-cover" />
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Enter the direct URL to the album cover image
</p>
</>
)}
</div>
{/* Year and Genre */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Year *
</label>
<input
type="number"
value={year}
onChange={(e) => setYear(e.target.value)}
placeholder="2024"
min="1900"
max={new Date().getFullYear() + 1}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Genre *
</label>
<input
type="text"
value={genre}
onChange={(e) => setGenre(e.target.value)}
placeholder="Progressive Rock"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter album description"
rows={3}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 resize-none"
required
/>
</div>
{/* Price */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Price ($) *
</label>
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="9.99"
min="0.01"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
{/* Tag, Format, and Bitrate */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Tag *
</label>
<select
value={tag}
onChange={(e) => setTag(e.target.value as any)}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white"
required
>
<option value="Album">Album</option>
<option value="EP">EP</option>
<option value="Demo">Demo</option>
<option value="Deluxe">Deluxe</option>
<option value="Single">Single</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Format *
</label>
<select
value={format}
onChange={(e) => setFormat(e.target.value as any)}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white"
required
>
<option value="mp3">MP3</option>
<option value="m4a">M4A</option>
<option value="flac">FLAC</option>
<option value="wav">WAV</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Bitrate *
</label>
<input
type="text"
value={bitrate}
onChange={(e) => setBitrate(e.target.value)}
placeholder="320kbps"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
</div>
{/* Songs */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-300">
Songs *
</label>
<button
type="button"
onClick={handleAddSong}
className="px-3 py-1 bg-accent-orange/20 hover:bg-accent-orange/30 text-accent-orange rounded-lg text-sm flex items-center gap-1 transition-colors"
>
<FaPlus className="text-xs" />
Add Song
</button>
</div>
<div className="space-y-3">
{songs.map((song, index) => (
<div key={index} className="p-4 bg-white/5 rounded-lg border border-white/10 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300">Song {index + 1}</span>
{songs.length > 1 && (
<button
type="button"
onClick={() => handleRemoveSong(index)}
className="p-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
aria-label="Remove song"
>
<FaTrash className="text-xs" />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={song.title}
onChange={(e) => handleSongChange(index, 'title', e.target.value)}
placeholder="Song title"
className="px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm"
required
/>
<input
type="text"
value={song.duration}
onChange={(e) => handleSongChange(index, 'duration', e.target.value)}
placeholder="3:45"
className="px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm"
required
/>
</div>
{/* Full Song Audio */}
<div className="space-y-1">
<label className="text-xs text-gray-400">Full Song Audio</label>
<div className="flex gap-1 mb-1">
<button
type="button"
onClick={() => setSongUploadMethod({ ...songUploadMethod, [index]: { ...songUploadMethod[index], full: 'file' } })}
className={`px-2 py-1 text-xs font-medium transition-colors ${
songUploadMethod[index]?.full === 'file'
? 'bg-accent-orange text-white'
: 'bg-white/5 text-gray-400'
} rounded`}
>
Upload
</button>
<button
type="button"
onClick={() => setSongUploadMethod({ ...songUploadMethod, [index]: { ...songUploadMethod[index], full: 'url' } })}
className={`px-2 py-1 text-xs font-medium transition-colors ${
songUploadMethod[index]?.full === 'url'
? 'bg-accent-orange text-white'
: 'bg-white/5 text-gray-400'
} rounded`}
>
URL
</button>
</div>
{songUploadMethod[index]?.full === 'file' ? (
<div className="relative">
{uploadingSong?.index === index && uploadingSong.field === 'full' ? (
<div className="px-3 py-6 bg-white/5 border border-white/10 rounded-lg text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-orange border-t-transparent mx-auto"></div>
<p className="text-xs text-gray-400 mt-2">Uploading...</p>
</div>
) : song.fullUrl ? (
<div className="px-3 py-2 bg-white/5 border border-white/10 rounded-lg flex items-center justify-between">
<span className="text-xs text-accent-orange truncate">{song.fullUrl.split('/').pop()}</span>
<button
type="button"
onClick={() => handleSongChange(index, 'fullUrl', '')}
className="text-gray-400 hover:text-white"
>
<FaTimes className="text-xs" />
</button>
</div>
) : (
<label className="block px-3 py-6 bg-white/5 border-2 border-dashed border-white/20 hover:border-accent-orange rounded-lg cursor-pointer transition-colors text-center">
<FaUpload className="text-gray-400 mx-auto mb-1" />
<span className="text-xs text-gray-400">Click or drop audio file</span>
<input
type="file"
accept="audio/*"
onChange={(e) => handleAudioFileChange(e, index, 'full')}
className="hidden"
/>
</label>
)}
</div>
) : (
<input
type="url"
value={song.fullUrl}
onChange={(e) => handleSongChange(index, 'fullUrl', e.target.value)}
placeholder="https://example.com/song.mp3"
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm"
/>
)}
</div>
{/* Preview Audio */}
<div className="space-y-1">
<label className="text-xs text-gray-400">Preview (30s clip - optional)</label>
<div className="flex gap-1 mb-1">
<button
type="button"
onClick={() => setSongUploadMethod({ ...songUploadMethod, [index]: { ...songUploadMethod[index], preview: 'file' } })}
className={`px-2 py-1 text-xs font-medium transition-colors ${
songUploadMethod[index]?.preview === 'file'
? 'bg-accent-orange text-white'
: 'bg-white/5 text-gray-400'
} rounded`}
>
Upload
</button>
<button
type="button"
onClick={() => setSongUploadMethod({ ...songUploadMethod, [index]: { ...songUploadMethod[index], preview: 'url' } })}
className={`px-2 py-1 text-xs font-medium transition-colors ${
songUploadMethod[index]?.preview === 'url'
? 'bg-accent-orange text-white'
: 'bg-white/5 text-gray-400'
} rounded`}
>
URL
</button>
</div>
{songUploadMethod[index]?.preview === 'file' ? (
<div className="relative">
{uploadingSong?.index === index && uploadingSong.field === 'preview' ? (
<div className="px-3 py-6 bg-white/5 border border-white/10 rounded-lg text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-orange border-t-transparent mx-auto"></div>
<p className="text-xs text-gray-400 mt-2">Uploading...</p>
</div>
) : song.previewUrl ? (
<div className="px-3 py-2 bg-white/5 border border-white/10 rounded-lg flex items-center justify-between">
<span className="text-xs text-accent-orange truncate">{song.previewUrl.split('/').pop()}</span>
<button
type="button"
onClick={() => handleSongChange(index, 'previewUrl', '')}
className="text-gray-400 hover:text-white"
>
<FaTimes className="text-xs" />
</button>
</div>
) : (
<label className="block px-3 py-6 bg-white/5 border-2 border-dashed border-white/20 hover:border-accent-orange rounded-lg cursor-pointer transition-colors text-center">
<FaUpload className="text-gray-400 mx-auto mb-1" />
<span className="text-xs text-gray-400">Click or drop audio file</span>
<input
type="file"
accept="audio/*"
onChange={(e) => handleAudioFileChange(e, index, 'preview')}
className="hidden"
/>
</label>
)}
</div>
) : (
<input
type="url"
value={song.previewUrl}
onChange={(e) => handleSongChange(index, 'previewUrl', e.target.value)}
placeholder="https://example.com/preview.mp3"
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm"
/>
)}
</div>
</div>
))}
</div>
</div>
{/* Error Message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm"
>
{error}
</motion.div>
)}
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={handleClose}
className="flex-1 py-3 bg-white/10 hover:bg-white/20 rounded-lg font-semibold text-white transition-all"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-3 bg-gradient-to-r from-accent-orange to-accent-orange/80 hover:from-accent-orange/90 hover:to-accent-orange/70 rounded-lg font-semibold text-white transition-all glow-orange"
>
Update Album
</button>
</div>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}