diff --git a/.env.example b/.env.example index 35affa5..996cfda 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,12 @@ # ZarinPal Configuration 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 NODE_ENV=development diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index d60eb36..534f50f 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -147,11 +147,21 @@ export default function AlbumDetailPage() { transition={{ duration: 0.6 }} className="relative" > -
-
-
- {album.title} -
+
+ {album.coverImage ? ( + {album.title} + ) : ( +
+
+
+ {album.title} +
+
+ )}
{isPurchased && ( @@ -250,29 +260,29 @@ export default function AlbumDetailPage() { initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} 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)} > -
- +
+ {String(index + 1).padStart(2, '0')} -
-

+
+

{song.title}

-
+
{!isPurchased && ( - + Preview )} - + {song.duration} - +
))} diff --git a/app/api/s3/presigned-url/route.ts b/app/api/s3/presigned-url/route.ts new file mode 100644 index 0000000..9137ded --- /dev/null +++ b/app/api/s3/presigned-url/route.ts @@ -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 } + ); + } +} diff --git a/components/AddAlbumModal.tsx b/components/AddAlbumModal.tsx index 0260562..8d3ea4f 100644 --- a/components/AddAlbumModal.tsx +++ b/components/AddAlbumModal.tsx @@ -25,6 +25,10 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro 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); const handleAddSong = () => { setSongs([...songs, { id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]); @@ -42,10 +46,7 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro setSongs(updatedSongs); }; - const handleCoverImageChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + const uploadFile = async (file: File) => { setCoverImageFile(file); setIsUploading(true); setError(''); @@ -74,6 +75,74 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro } }; + const handleCoverImageChange = async (e: React.ChangeEvent) => { + 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, 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(''); @@ -215,43 +284,114 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro -
-
- handleSongChange(index, 'fullUrl', e.target.value)} - placeholder="Song URL (e.g., 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" - /> - handleSongChange(index, 'previewUrl', e.target.value)} - placeholder="Preview URL (optional - 30s clip)" - 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" - /> + + {/* Full Song Audio */} +
+ +
+ + +
+ {songUploadMethod[index]?.full === 'file' ? ( +
+ {uploadingSong?.index === index && uploadingSong.field === 'full' ? ( +
+
+

Uploading...

+
+ ) : song.fullUrl ? ( +
+ {song.fullUrl.split('/').pop()} + +
+ ) : ( + + )} +
+ ) : ( + 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" + /> + )} +
+ + {/* Preview Audio */} +
+ +
+ + +
+ {songUploadMethod[index]?.preview === 'file' ? ( +
+ {uploadingSong?.index === index && uploadingSong.field === 'preview' ? ( +
+
+

Uploading...

+
+ ) : song.previewUrl ? ( +
+ {song.previewUrl.split('/').pop()} + +
+ ) : ( + + )} +
+ ) : ( + 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" + /> + )} +
))}
diff --git a/components/AlbumCard.tsx b/components/AlbumCard.tsx index bfeaf2e..40e5925 100644 --- a/components/AlbumCard.tsx +++ b/components/AlbumCard.tsx @@ -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" > {/* Album Cover */} -
-
- {album.title} -
+
+ {album.coverImage ? ( + {album.title} + ) : ( +
+
+ {album.title} +
+
+ )} {/* Overlay on hover */}
diff --git a/components/EditAlbumModal.tsx b/components/EditAlbumModal.tsx index 944512d..e24f39b 100644 --- a/components/EditAlbumModal.tsx +++ b/components/EditAlbumModal.tsx @@ -26,6 +26,10 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA 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(() => { @@ -59,10 +63,7 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA setSongs(updatedSongs); }; - const handleCoverImageChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + const uploadFile = async (file: File) => { setCoverImageFile(file); setIsUploading(true); setError(''); @@ -91,6 +92,74 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA } }; + const handleCoverImageChange = async (e: React.ChangeEvent) => { + 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, 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(''); @@ -224,43 +293,114 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA -
-
- handleSongChange(index, 'fullUrl', e.target.value)} - placeholder="Song URL (e.g., 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" - /> - handleSongChange(index, 'previewUrl', e.target.value)} - placeholder="Preview URL (optional - 30s clip)" - 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" - /> + + {/* Full Song Audio */} +
+ +
+ + +
+ {songUploadMethod[index]?.full === 'file' ? ( +
+ {uploadingSong?.index === index && uploadingSong.field === 'full' ? ( +
+
+

Uploading...

+
+ ) : song.fullUrl ? ( +
+ {song.fullUrl.split('/').pop()} + +
+ ) : ( + + )} +
+ ) : ( + 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" + /> + )} +
+ + {/* Preview Audio */} +
+ +
+ + +
+ {songUploadMethod[index]?.preview === 'file' ? ( +
+ {uploadingSong?.index === index && uploadingSong.field === 'preview' ? ( +
+
+

Uploading...

+
+ ) : song.previewUrl ? ( +
+ {song.previewUrl.split('/').pop()} + +
+ ) : ( + + )} +
+ ) : ( + 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" + /> + )} +
))}
diff --git a/components/MusicPlayer.tsx b/components/MusicPlayer.tsx index e6d43da..fce73a9 100644 --- a/components/MusicPlayer.tsx +++ b/components/MusicPlayer.tsx @@ -149,9 +149,9 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
-
- {album.title} -
+ {/*
*/} + {album.title} + {/*
*/}
{/* Preview Badge */} diff --git a/lib/presigned-url.ts b/lib/presigned-url.ts new file mode 100644 index 0000000..55b169a --- /dev/null +++ b/lib/presigned-url.ts @@ -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(); + +/** + * 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 { + // 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> { + // Separate keys that need pre-signed URLs from full URLs + const s3Keys: string[] = []; + const result: Record = {}; + + 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; + + // 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); +} diff --git a/lib/s3.ts b/lib/s3.ts index e0172cf..5f83b34 100644 --- a/lib/s3.ts +++ b/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'; // Initialize S3 client @@ -17,13 +17,14 @@ export interface UploadFileParams { file: File; key: string; contentType?: string; + makePublic?: boolean; // Set to false for private files } /** * Upload a file to S3 */ export async function uploadFileToS3(params: UploadFileParams): Promise { - const { file, key, contentType } = params; + const { file, key, contentType, makePublic = true } = params; // Convert File to Buffer const arrayBuffer = await file.arrayBuffer(); @@ -33,14 +34,18 @@ export async function uploadFileToS3(params: UploadFileParams): Promise Bucket: BUCKET_NAME, Key: key, Body: buffer, - ACL: "public-read", + ...(makePublic && { ACL: "public-read" }), // Only set ACL if makePublic is true ContentType: contentType || file.type, }); await s3Client.send(command); - // Return the public URL - return `https://${BUCKET_NAME}.s3.ir-thr-at1.arvanstorage.ir/${key}`; + // Return the public URL if public, otherwise return the 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; } +/** + * 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 { + 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> { + 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 */ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..635dd3d Binary files /dev/null and b/public/favicon.ico differ