main: added presigned url + favicon
Signed-off-by: nfel <nfilsaraee@gmail.com>
This commit is contained in:
parent
9fd79a2d4e
commit
9478aa319f
@ -1,5 +1,12 @@
|
|||||||
# ZarinPal Configuration
|
# ZarinPal Configuration
|
||||||
ZARINPAL_MERCHANT_ID=your-merchant-id-here
|
ZARINPAL_MERCHANT_ID=your-merchant-id-here
|
||||||
|
|
||||||
|
# S3 / Object Storage Configuration
|
||||||
|
AWS_REGION=ir-thr-at1
|
||||||
|
AWS_S3_ENDPOINT=https://s3.ir-thr-at1.arvanstorage.ir
|
||||||
|
AWS_S3_BUCKET=your-bucket-name
|
||||||
|
AWS_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
AWS_SECRET_ACCESS_KEY=your-secret-access-key
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|||||||
@ -147,11 +147,21 @@ export default function AlbumDetailPage() {
|
|||||||
transition={{ duration: 0.6 }}
|
transition={{ duration: 0.6 }}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<div className="aspect-square overflow-hidden bg-paper-brown flex items-center justify-center border-4 border-paper-dark shadow-paper-lg">
|
<div className="aspect-square overflow-hidden bg-paper-brown border-4 border-paper-dark shadow-paper-lg">
|
||||||
<div className="absolute inset-0 cardboard-texture"></div>
|
{album.coverImage ? (
|
||||||
<div className="relative z-10 text-6xl md:text-8xl font-bold text-paper-dark/20 p-8 text-center">
|
<img
|
||||||
{album.title}
|
src={album.coverImage}
|
||||||
</div>
|
alt={album.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center relative">
|
||||||
|
<div className="absolute inset-0 cardboard-texture"></div>
|
||||||
|
<div className="relative z-10 text-6xl md:text-8xl font-bold text-paper-dark/20 p-8 text-center">
|
||||||
|
{album.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPurchased && (
|
{isPurchased && (
|
||||||
@ -250,29 +260,29 @@ export default function AlbumDetailPage() {
|
|||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||||
className="flex items-center justify-between p-4 hover:bg-paper-sand border-2 border-transparent hover:border-paper-brown transition-all group cursor-pointer"
|
className="flex items-center justify-between p-3 md:p-4 hover:bg-paper-sand border-2 border-transparent hover:border-paper-brown transition-all group cursor-pointer"
|
||||||
onClick={() => setCurrentAlbum(album)}
|
onClick={() => setCurrentAlbum(album)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 flex-1">
|
<div className="flex items-center gap-2 md:gap-4 flex-1 min-w-0">
|
||||||
<span className="text-paper-gray font-mono text-sm w-8">
|
<span className="text-paper-gray font-mono text-xs md:text-sm w-6 md:w-8 flex-shrink-0">
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-paper-dark font-medium group-hover:font-bold transition-all">
|
<h3 className="text-paper-dark font-medium group-hover:font-bold transition-all truncate text-sm md:text-base">
|
||||||
{song.title}
|
{song.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-2 md:gap-6 flex-shrink-0">
|
||||||
{!isPurchased && (
|
{!isPurchased && (
|
||||||
<span className="text-xs text-paper-dark px-2 py-1 bg-paper-brown/20 border border-paper-brown">
|
<span className="hidden sm:inline text-xs text-paper-dark px-2 py-1 bg-paper-brown/20 border border-paper-brown whitespace-nowrap">
|
||||||
Preview
|
Preview
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-paper-brown text-sm font-mono">
|
<span className="text-paper-brown text-xs md:text-sm font-mono whitespace-nowrap">
|
||||||
{song.duration}
|
{song.duration}
|
||||||
</span>
|
</span>
|
||||||
<FaPlay className="text-paper-dark opacity-0 group-hover:opacity-100 transition-opacity" />
|
<FaPlay className="text-paper-dark opacity-0 group-hover:opacity-100 transition-opacity text-xs md:text-base flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
69
app/api/s3/presigned-url/route.ts
Normal file
69
app/api/s3/presigned-url/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPresignedUrl, getMultiplePresignedUrls } from '@/lib/s3';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate pre-signed URL(s) for S3 objects
|
||||||
|
* POST /api/s3/presigned-url
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - Single key: { "key": "path/to/file.mp3", "expiresIn": 3600 }
|
||||||
|
* - Multiple keys: { "keys": ["path/file1.mp3", "path/file2.mp3"], "expiresIn": 3600 }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { key, keys, expiresIn = 3600 } = body;
|
||||||
|
|
||||||
|
// Validate expiration time (max 7 days)
|
||||||
|
if (expiresIn > 604800) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'expiresIn cannot exceed 7 days (604800 seconds)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single key
|
||||||
|
if (key) {
|
||||||
|
if (typeof key !== 'string' || !key.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid key provided' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await getPresignedUrl(key, expiresIn);
|
||||||
|
return NextResponse.json({ url, expiresIn }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle multiple keys
|
||||||
|
if (keys) {
|
||||||
|
if (!Array.isArray(keys) || keys.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'keys must be a non-empty array' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length > 100) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot generate more than 100 URLs at once' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = await getMultiplePresignedUrls(keys, expiresIn);
|
||||||
|
return NextResponse.json({ urls, expiresIn }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Either "key" or "keys" must be provided' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Presigned URL generation error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to generate presigned URL' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,10 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro
|
|||||||
const [tag, setTag] = useState<'Album' | 'EP' | 'Demo' | 'Deluxe' | 'Single'>('Album');
|
const [tag, setTag] = useState<'Album' | 'EP' | 'Demo' | 'Deluxe' | 'Single'>('Album');
|
||||||
const [format, setFormat] = useState<'mp3' | 'm4a' | 'flac' | 'wav'>('mp3');
|
const [format, setFormat] = useState<'mp3' | 'm4a' | 'flac' | 'wav'>('mp3');
|
||||||
const [bitrate, setBitrate] = useState('320kbps');
|
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);
|
||||||
|
|
||||||
const handleAddSong = () => {
|
const handleAddSong = () => {
|
||||||
setSongs([...songs, { id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
|
setSongs([...songs, { id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
|
||||||
@ -42,10 +46,7 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro
|
|||||||
setSongs(updatedSongs);
|
setSongs(updatedSongs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCoverImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadFile = async (file: File) => {
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setCoverImageFile(file);
|
setCoverImageFile(file);
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@ -74,6 +75,74 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@ -215,43 +284,114 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro
|
|||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Album Cover Image
|
Album Cover Image
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="flex-1 cursor-pointer">
|
{/* Method Tabs */}
|
||||||
<div className="flex items-center justify-center gap-2 px-4 py-3 bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-colors">
|
<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-cyan 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-cyan 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-cyan bg-accent-cyan/10'
|
||||||
|
: 'border-white/20 hover:border-white/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<>
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-accent-cyan border-t-transparent"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-accent-cyan border-t-transparent mx-auto mb-4"></div>
|
||||||
<span className="text-gray-400 text-sm">Uploading...</span>
|
<p className="text-gray-400">Uploading...</p>
|
||||||
</>
|
</div>
|
||||||
) : coverImage ? (
|
) : coverImage ? (
|
||||||
<>
|
<div className="flex items-center gap-4">
|
||||||
<FaImage className="text-accent-cyan" />
|
<div className="w-20 h-20 rounded-lg overflow-hidden border-2 border-white/20 flex-shrink-0">
|
||||||
<span className="text-accent-cyan text-sm">Image Uploaded</span>
|
<img src={coverImage} alt="Cover preview" className="w-full h-full object-cover" />
|
||||||
</>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-accent-cyan font-medium mb-1">Image Uploaded Successfully</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-gray-400" />
|
<FaUpload className="text-4xl text-gray-400 mx-auto mb-4" />
|
||||||
<span className="text-gray-400 text-sm">Choose Image</span>
|
<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>
|
</div>
|
||||||
<input
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
type="file"
|
Supported: JPG, PNG, WEBP, GIF (max 5MB)
|
||||||
accept="image/*"
|
</p>
|
||||||
onChange={handleCoverImageChange}
|
</>
|
||||||
className="hidden"
|
) : (
|
||||||
disabled={isUploading}
|
<>
|
||||||
/>
|
{/* URL Input */}
|
||||||
</label>
|
<div className="space-y-3">
|
||||||
{coverImage && (
|
<input
|
||||||
<div className="w-16 h-16 rounded-lg overflow-hidden border border-white/10">
|
type="url"
|
||||||
<img src={coverImage} alt="Cover preview" className="w-full h-full object-cover" />
|
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-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/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>
|
</div>
|
||||||
)}
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
</div>
|
Enter the direct URL to the album cover image
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
</p>
|
||||||
Upload an album cover image (max 5MB, JPG/PNG/WEBP)
|
</>
|
||||||
</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Year and Genre */}
|
{/* Year and Genre */}
|
||||||
@ -417,20 +557,144 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="url"
|
{/* Full Song Audio */}
|
||||||
value={song.fullUrl}
|
<div className="space-y-1">
|
||||||
onChange={(e) => handleSongChange(index, 'fullUrl', e.target.value)}
|
<label className="text-xs text-gray-400">Full Song Audio</label>
|
||||||
placeholder="Song URL (e.g., https://example.com/song.mp3)"
|
<div className="flex gap-1 mb-1">
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500 text-sm"
|
<button
|
||||||
/>
|
type="button"
|
||||||
<input
|
onClick={() => setSongUploadMethod({ ...songUploadMethod, [index]: { ...songUploadMethod[index], full: 'file' } })}
|
||||||
type="url"
|
className={`px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
value={song.previewUrl}
|
songUploadMethod[index]?.full === 'file'
|
||||||
onChange={(e) => handleSongChange(index, 'previewUrl', e.target.value)}
|
? 'bg-accent-cyan text-white'
|
||||||
placeholder="Preview URL (optional - 30s clip)"
|
: 'bg-white/5 text-gray-400'
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500 text-sm"
|
} 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-cyan 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-cyan 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-cyan 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-cyan 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-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/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-cyan 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-cyan 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-cyan 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-cyan 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-cyan 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-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -47,10 +47,20 @@ export default function AlbumCard({ album, isPurchased, onPlay, onPurchase }: Al
|
|||||||
className="paper-card-light overflow-hidden group cursor-pointer hover:shadow-paper-lg transition-shadow"
|
className="paper-card-light overflow-hidden group cursor-pointer hover:shadow-paper-lg transition-shadow"
|
||||||
>
|
>
|
||||||
{/* Album Cover */}
|
{/* Album Cover */}
|
||||||
<div className="relative aspect-square bg-paper-brown flex items-center justify-center overflow-hidden border-b-2 border-paper-gray">
|
<div className="relative aspect-square bg-paper-brown overflow-hidden border-b-2 border-paper-gray">
|
||||||
<div className="relative z-10 text-6xl font-bold text-paper-dark/20 p-8 text-center">
|
{album.coverImage ? (
|
||||||
{album.title}
|
<img
|
||||||
</div>
|
src={album.coverImage}
|
||||||
|
alt={album.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="relative z-10 text-6xl font-bold text-paper-dark/20 p-8 text-center">
|
||||||
|
{album.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Overlay on hover */}
|
{/* Overlay on hover */}
|
||||||
<div className="absolute inset-0 bg-paper-dark/80 opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center gap-3">
|
<div className="absolute inset-0 bg-paper-dark/80 opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center gap-3">
|
||||||
|
|||||||
@ -26,6 +26,10 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA
|
|||||||
const [tag, setTag] = useState<'Album' | 'EP' | 'Demo' | 'Deluxe' | 'Single'>('Album');
|
const [tag, setTag] = useState<'Album' | 'EP' | 'Demo' | 'Deluxe' | 'Single'>('Album');
|
||||||
const [format, setFormat] = useState<'mp3' | 'm4a' | 'flac' | 'wav'>('mp3');
|
const [format, setFormat] = useState<'mp3' | 'm4a' | 'flac' | 'wav'>('mp3');
|
||||||
const [bitrate, setBitrate] = useState('320kbps');
|
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
|
// Populate form when album changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -59,10 +63,7 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA
|
|||||||
setSongs(updatedSongs);
|
setSongs(updatedSongs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCoverImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadFile = async (file: File) => {
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setCoverImageFile(file);
|
setCoverImageFile(file);
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@ -91,6 +92,74 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@ -224,43 +293,114 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA
|
|||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Album Cover Image
|
Album Cover Image
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="flex-1 cursor-pointer">
|
{/* Method Tabs */}
|
||||||
<div className="flex items-center justify-center gap-2 px-4 py-3 bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-colors">
|
<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 ? (
|
{isUploading ? (
|
||||||
<>
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-accent-orange border-t-transparent"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-accent-orange border-t-transparent mx-auto mb-4"></div>
|
||||||
<span className="text-gray-400 text-sm">Uploading...</span>
|
<p className="text-gray-400">Uploading...</p>
|
||||||
</>
|
</div>
|
||||||
) : coverImage ? (
|
) : coverImage ? (
|
||||||
<>
|
<div className="flex items-center gap-4">
|
||||||
<FaImage className="text-accent-orange" />
|
<div className="w-20 h-20 rounded-lg overflow-hidden border-2 border-white/20 flex-shrink-0">
|
||||||
<span className="text-accent-orange text-sm">Change Image</span>
|
<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-gray-400" />
|
<FaUpload className="text-4xl text-gray-400 mx-auto mb-4" />
|
||||||
<span className="text-gray-400 text-sm">Choose Image</span>
|
<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>
|
</div>
|
||||||
<input
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
type="file"
|
Supported: JPG, PNG, WEBP, GIF (max 5MB)
|
||||||
accept="image/*"
|
</p>
|
||||||
onChange={handleCoverImageChange}
|
</>
|
||||||
className="hidden"
|
) : (
|
||||||
disabled={isUploading}
|
<>
|
||||||
/>
|
{/* URL Input */}
|
||||||
</label>
|
<div className="space-y-3">
|
||||||
{coverImage && (
|
<input
|
||||||
<div className="w-16 h-16 rounded-lg overflow-hidden border border-white/10">
|
type="url"
|
||||||
<img src={coverImage} alt="Cover preview" className="w-full h-full object-cover" />
|
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>
|
</div>
|
||||||
)}
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
</div>
|
Enter the direct URL to the album cover image
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
</p>
|
||||||
Upload an album cover image (max 5MB, JPG/PNG/WEBP)
|
</>
|
||||||
</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Year and Genre */}
|
{/* Year and Genre */}
|
||||||
@ -426,20 +566,144 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="url"
|
{/* Full Song Audio */}
|
||||||
value={song.fullUrl}
|
<div className="space-y-1">
|
||||||
onChange={(e) => handleSongChange(index, 'fullUrl', e.target.value)}
|
<label className="text-xs text-gray-400">Full Song Audio</label>
|
||||||
placeholder="Song URL (e.g., https://example.com/song.mp3)"
|
<div className="flex gap-1 mb-1">
|
||||||
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"
|
<button
|
||||||
/>
|
type="button"
|
||||||
<input
|
onClick={() => setSongUploadMethod({ ...songUploadMethod, [index]: { ...songUploadMethod[index], full: 'file' } })}
|
||||||
type="url"
|
className={`px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
value={song.previewUrl}
|
songUploadMethod[index]?.full === 'file'
|
||||||
onChange={(e) => handleSongChange(index, 'previewUrl', e.target.value)}
|
? 'bg-accent-orange text-white'
|
||||||
placeholder="Preview URL (optional - 30s clip)"
|
: 'bg-white/5 text-gray-400'
|
||||||
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"
|
} 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>
|
</div>
|
||||||
|
|||||||
@ -149,9 +149,9 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
<div className="absolute inset-0 bg-paper-brown"></div>
|
<div className="absolute inset-0 bg-paper-brown"></div>
|
||||||
<div className="absolute inset-0 cardboard-texture"></div>
|
<div className="absolute inset-0 cardboard-texture"></div>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-4xl md:text-5xl font-bold text-paper-dark/30 text-center px-6">
|
{/* <div className="text-4xl md:text-5xl font-bold text-paper-dark/30 text-center px-6"> */}
|
||||||
{album.title}
|
<img src={album.coverImage} alt={album.title} />
|
||||||
</div>
|
{/* </div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Badge */}
|
{/* Preview Badge */}
|
||||||
|
|||||||
167
lib/presigned-url.ts
Normal file
167
lib/presigned-url.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Client-side utility for generating pre-signed URLs from S3 keys
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PresignedUrlCache {
|
||||||
|
url: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache to avoid regenerating URLs that haven't expired
|
||||||
|
const urlCache = new Map<string, PresignedUrlCache>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is an S3 key (not a full URL)
|
||||||
|
*/
|
||||||
|
export function isS3Key(urlOrKey: string): boolean {
|
||||||
|
// If it starts with http:// or https://, it's already a URL
|
||||||
|
if (urlOrKey.startsWith('http://') || urlOrKey.startsWith('https://')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Otherwise, treat it as an S3 key
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a pre-signed URL for an S3 key
|
||||||
|
* @param key - The S3 object key
|
||||||
|
* @param expiresIn - Expiration time in seconds (default: 1 hour)
|
||||||
|
* @param useCache - Whether to use cached URLs (default: true)
|
||||||
|
* @returns Pre-signed URL or the original key if it's already a URL
|
||||||
|
*/
|
||||||
|
export async function getPresignedUrl(
|
||||||
|
key: string,
|
||||||
|
expiresIn: number = 3600,
|
||||||
|
useCache: boolean = true
|
||||||
|
): Promise<string> {
|
||||||
|
// If it's already a full URL, return it as-is
|
||||||
|
if (!isS3Key(key)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (useCache) {
|
||||||
|
const cached = urlCache.get(key);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/s3/presigned-url', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, expiresIn }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to generate presigned URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Cache the URL (expire 5 minutes before actual expiration for safety)
|
||||||
|
if (useCache) {
|
||||||
|
urlCache.set(key, {
|
||||||
|
url: data.url,
|
||||||
|
expiresAt: Date.now() + (expiresIn - 300) * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating presigned URL:', error);
|
||||||
|
// Fallback: return the key itself (might fail, but better than nothing)
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pre-signed URLs for multiple S3 keys at once
|
||||||
|
* @param keys - Array of S3 object keys
|
||||||
|
* @param expiresIn - Expiration time in seconds (default: 1 hour)
|
||||||
|
* @returns Map of key to pre-signed URL
|
||||||
|
*/
|
||||||
|
export async function getMultiplePresignedUrls(
|
||||||
|
keys: string[],
|
||||||
|
expiresIn: number = 3600
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
// Separate keys that need pre-signed URLs from full URLs
|
||||||
|
const s3Keys: string[] = [];
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (isS3Key(key)) {
|
||||||
|
// Check cache first
|
||||||
|
const cached = urlCache.get(key);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
result[key] = cached.url;
|
||||||
|
} else {
|
||||||
|
s3Keys.push(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Already a full URL
|
||||||
|
result[key] = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no keys need pre-signed URLs, return early
|
||||||
|
if (s3Keys.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/s3/presigned-url', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keys: s3Keys, expiresIn }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to generate presigned URLs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const urls = data.urls as Record<string, string>;
|
||||||
|
|
||||||
|
// Cache the URLs
|
||||||
|
const cacheExpiresAt = Date.now() + (expiresIn - 300) * 1000;
|
||||||
|
for (const [key, url] of Object.entries(urls)) {
|
||||||
|
urlCache.set(key, { url, expiresAt: cacheExpiresAt });
|
||||||
|
result[key] = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating presigned URLs:', error);
|
||||||
|
// Fallback: return the keys themselves
|
||||||
|
for (const key of s3Keys) {
|
||||||
|
result[key] = key;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the URL cache (useful when you want to force refresh)
|
||||||
|
*/
|
||||||
|
export function clearUrlCache(): void {
|
||||||
|
urlCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear expired URLs from cache
|
||||||
|
*/
|
||||||
|
export function cleanupUrlCache(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, cached] of urlCache.entries()) {
|
||||||
|
if (cached.expiresAt <= now) {
|
||||||
|
urlCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically cleanup cache every 5 minutes
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setInterval(cleanupUrlCache, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
50
lib/s3.ts
50
lib/s3.ts
@ -1,4 +1,4 @@
|
|||||||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
|
||||||
// Initialize S3 client
|
// Initialize S3 client
|
||||||
@ -17,13 +17,14 @@ export interface UploadFileParams {
|
|||||||
file: File;
|
file: File;
|
||||||
key: string;
|
key: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
makePublic?: boolean; // Set to false for private files
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a file to S3
|
* Upload a file to S3
|
||||||
*/
|
*/
|
||||||
export async function uploadFileToS3(params: UploadFileParams): Promise<string> {
|
export async function uploadFileToS3(params: UploadFileParams): Promise<string> {
|
||||||
const { file, key, contentType } = params;
|
const { file, key, contentType, makePublic = true } = params;
|
||||||
|
|
||||||
// Convert File to Buffer
|
// Convert File to Buffer
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
@ -33,14 +34,18 @@ export async function uploadFileToS3(params: UploadFileParams): Promise<string>
|
|||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
Key: key,
|
Key: key,
|
||||||
Body: buffer,
|
Body: buffer,
|
||||||
ACL: "public-read",
|
...(makePublic && { ACL: "public-read" }), // Only set ACL if makePublic is true
|
||||||
ContentType: contentType || file.type,
|
ContentType: contentType || file.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
await s3Client.send(command);
|
await s3Client.send(command);
|
||||||
|
|
||||||
// Return the public URL
|
// Return the public URL if public, otherwise return the key
|
||||||
return `https://${BUCKET_NAME}.s3.ir-thr-at1.arvanstorage.ir/${key}`;
|
if (makePublic) {
|
||||||
|
return `https://${BUCKET_NAME}.s3.ir-thr-at1.arvanstorage.ir/${key}`;
|
||||||
|
} else {
|
||||||
|
return key; // Return just the key for private files
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,6 +74,41 @@ export async function getPresignedUploadUrl(key: string, contentType: string): P
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a presigned URL for downloading/viewing a file
|
||||||
|
* @param key - The S3 object key
|
||||||
|
* @param expiresIn - Expiration time in seconds (default: 1 hour)
|
||||||
|
* @returns Pre-signed URL
|
||||||
|
*/
|
||||||
|
export async function getPresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = await getSignedUrl(s3Client, command, { expiresIn });
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate multiple presigned URLs for a list of keys
|
||||||
|
* @param keys - Array of S3 object keys
|
||||||
|
* @param expiresIn - Expiration time in seconds (default: 1 hour)
|
||||||
|
* @returns Map of key to pre-signed URL
|
||||||
|
*/
|
||||||
|
export async function getMultiplePresignedUrls(
|
||||||
|
keys: string[],
|
||||||
|
expiresIn: number = 3600
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const urlPromises = keys.map(async (key) => {
|
||||||
|
const url = await getPresignedUrl(key, expiresIn);
|
||||||
|
return [key, url];
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = await Promise.all(urlPromises);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique key for file uploads
|
* Generate a unique key for file uploads
|
||||||
*/
|
*/
|
||||||
|
|||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Loading…
x
Reference in New Issue
Block a user