diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..836281a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,55 @@ +# dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# next.js +.next/ +out/ +build/ +dist/ + +# production +.vercel +.turbo + +# misc +.DS_Store +*.pem + +# debug +*.log + +# local env files +.env*.local +.env + +# git +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# testing +coverage +.nyc_output + +# docker +Dockerfile +docker-compose.yml +.dockerignore + +# README and docs +README.md + +# Database +data/ +*.db +*.db-shm +*.db-wal diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae2b358 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Application +NODE_ENV=production +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# AWS S3 Configuration (for file uploads) +AWS_REGION=ir-thr-at1 +AWS_S3_ENDPOINT=https://s3.ir-thr-at1.arvanstorage.ir +AWS_ACCESS_KEY_ID=your_access_key_id +AWS_SECRET_ACCESS_KEY=your_secret_access_key +AWS_S3_BUCKET=your_bucket_name diff --git a/.gitignore b/.gitignore index ed12896..4cc0559 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# database +/data/ +*.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..75cf9a2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Multi-stage build for Next.js application + +# Stage 1: Dependencies +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm ci + +# Stage 2: Builder +FROM node:20-alpine AS builder +WORKDIR /app + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Set environment variables for build +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +# Build the application +RUN npm run build + +# Stage 3: Runner +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create a non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy necessary files from builder +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# Change ownership to nextjs user +RUN chown -R nextjs:nodejs /app + +# Switch to non-root user +USER nextjs + +# Expose port 3000 +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Start the application +CMD ["node", "server.js"] diff --git a/app/admin/albums/page.tsx b/app/admin/albums/page.tsx new file mode 100644 index 0000000..7bb46a7 --- /dev/null +++ b/app/admin/albums/page.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { FaEdit, FaTrash, FaPlus } from 'react-icons/fa'; +import { useAlbums } from '@/lib/AlbumsContext'; +import { Album } from '@/lib/types'; +import AdminLayout from '@/components/AdminLayout'; +import AddAlbumModal from '@/components/AddAlbumModal'; +import EditAlbumModal from '@/components/EditAlbumModal'; +import DeleteConfirmationModal from '@/components/DeleteConfirmationModal'; + +export default function AdminAlbumsPage() { + const { albums, addAlbum, updateAlbum, deleteAlbum } = useAlbums(); + const [showAddModal, setShowAddModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [selectedAlbum, setSelectedAlbum] = useState(null); + + const handleAddAlbum = async (album: Album) => { + try { + await addAlbum(album); + setShowAddModal(false); + } catch (error) { + console.error('Error adding album:', error); + alert('Failed to add album'); + } + }; + + const handleEditAlbum = async (albumId: string, album: Album) => { + try { + await updateAlbum(albumId, album); + setShowEditModal(false); + setSelectedAlbum(null); + } catch (error) { + console.error('Error updating album:', error); + alert('Failed to update album'); + } + }; + + const handleDeleteAlbum = async () => { + if (selectedAlbum) { + try { + await deleteAlbum(selectedAlbum.id); + setShowDeleteModal(false); + setSelectedAlbum(null); + } catch (error) { + console.error('Error deleting album:', error); + alert('Failed to delete album'); + } + } + }; + + const openEditModal = (album: Album) => { + setSelectedAlbum(album); + setShowEditModal(true); + }; + + const openDeleteModal = (album: Album) => { + setSelectedAlbum(album); + setShowDeleteModal(true); + }; + + return ( + +
+ {/* Header */} +
+
+

Album Management

+

Manage your music catalog • {albums.length} albums

+
+ setShowAddModal(true)} + className="px-6 py-3 bg-gradient-to-r from-accent-cyan to-accent-cyan/80 hover:from-accent-cyan/90 hover:to-accent-cyan/70 rounded-lg font-semibold text-white transition-all glow-cyan flex items-center gap-2" + > + + Add New Album + +
+ + {/* Albums Grid */} +
+ {albums.map((album, index) => ( + +
+ {/* Album Cover */} +
+ + {album.title} + +
+ + {/* Album Info */} +
+

{album.title}

+

+ {album.year} • {album.genre} +

+

+ {album.description} +

+
+ {album.songs.length} tracks + ${album.price} +
+
+ + {/* Actions */} +
+ openEditModal(album)} + className="p-2 bg-accent-cyan/20 hover:bg-accent-cyan/30 rounded-lg text-accent-cyan transition-colors" + aria-label="Edit album" + > + + + openDeleteModal(album)} + className="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors" + aria-label="Delete album" + > + + +
+
+ + {/* Track List */} +
+

Tracks:

+
+ {album.songs.slice(0, 3).map((song, idx) => ( +
+ + {idx + 1}. {song.title} + + {song.duration} +
+ ))} + {album.songs.length > 3 && ( +

+{album.songs.length - 3} more tracks

+ )} +
+
+
+ ))} +
+ + {/* Modals */} + setShowAddModal(false)} onAdd={handleAddAlbum} /> + { + setShowEditModal(false); + setSelectedAlbum(null); + }} + onUpdate={handleEditAlbum} + /> + { + setShowDeleteModal(false); + setSelectedAlbum(null); + }} + onConfirm={handleDeleteAlbum} + /> +
+
+ ); +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..449843c --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { FaMusic, FaShoppingCart, FaDollarSign, FaUsers } from 'react-icons/fa'; +import { useAlbums } from '@/lib/AlbumsContext'; +import { Purchase } from '@/lib/types'; +import AdminLayout from '@/components/AdminLayout'; + +export default function AdminDashboard() { + const { albums } = useAlbums(); + const [purchases, setPurchases] = useState([]); + const [stats, setStats] = useState({ + totalAlbums: 0, + totalPurchases: 0, + totalRevenue: 0, + recentPurchases: [] as Purchase[], + }); + + useEffect(() => { + // Load purchases from localStorage + const savedPurchases = localStorage.getItem('purchases'); + if (savedPurchases) { + const parsedPurchases = JSON.parse(savedPurchases); + setPurchases(parsedPurchases); + + // Calculate stats + const totalRevenue = parsedPurchases.reduce((total: number, purchase: Purchase) => { + const album = albums.find((a) => a.id === purchase.albumId); + return total + (album?.price || 0); + }, 0); + + setStats({ + totalAlbums: albums.length, + totalPurchases: parsedPurchases.length, + totalRevenue, + recentPurchases: parsedPurchases.slice(-5).reverse(), + }); + } else { + setStats({ + totalAlbums: albums.length, + totalPurchases: 0, + totalRevenue: 0, + recentPurchases: [], + }); + } + }, []); + + const statCards = [ + { + icon: FaMusic, + label: 'Total Albums', + value: stats.totalAlbums, + color: 'from-accent-cyan to-accent-cyan/80', + iconBg: 'bg-accent-cyan/20', + }, + { + icon: FaShoppingCart, + label: 'Total Purchases', + value: stats.totalPurchases, + color: 'from-accent-orange to-accent-orange/80', + iconBg: 'bg-accent-orange/20', + }, + { + icon: FaDollarSign, + label: 'Total Revenue', + value: `$${stats.totalRevenue.toFixed(2)}`, + color: 'from-green-500 to-green-600', + iconBg: 'bg-green-500/20', + }, + { + icon: FaUsers, + label: 'Active Users', + value: stats.totalPurchases > 0 ? Math.ceil(stats.totalPurchases / 2) : 0, + color: 'from-purple-500 to-purple-600', + iconBg: 'bg-purple-500/20', + }, + ]; + + return ( + +
+ {/* Header */} +
+

Dashboard

+

Overview of your music store

+
+ + {/* Stats Grid */} +
+ {statCards.map((stat, index) => { + const Icon = stat.icon; + return ( + +
+
+ +
+
+

{stat.label}

+

{stat.value}

+
+
+
+ ); + })} +
+ + {/* Recent Purchases */} + +

Recent Purchases

+ {stats.recentPurchases.length > 0 ? ( +
+ {stats.recentPurchases.map((purchase) => { + const album = albums.find((a) => a.id === purchase.albumId); + return ( +
+
+

{album?.title || 'Unknown Album'}

+

+ {new Date(purchase.purchaseDate).toLocaleDateString()} at{' '} + {new Date(purchase.purchaseDate).toLocaleTimeString()} +

+
+
+

${album?.price || 0}

+

{purchase.transactionId.slice(0, 12)}...

+
+
+ ); + })} +
+ ) : ( +

No purchases yet

+ )} +
+
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..7adce96 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion } from 'framer-motion'; +import { FaLock, FaUser } from 'react-icons/fa'; +import { useAdmin } from '@/lib/AdminContext'; + +export default function AdminLoginPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const { login } = useAdmin(); + const router = useRouter(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + const success = login(username, password); + if (success) { + router.push('/admin/dashboard'); + } else { + setError('Invalid credentials. Try admin / admin123'); + } + }; + + return ( +
+ +
+ {/* Header */} +
+
+ +
+

+ Admin Panel +

+

Sign in to manage your music store

+
+ + {/* Login Form */} +
+
+ + setUsername(e.target.value)} + placeholder="admin" + 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" + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + 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" + required + /> +
+ + {error && ( + + {error} + + )} + + +
+ + {/* Demo Credentials */} +
+

+ Demo Credentials:
+ Username: admin
+ Password: admin123 +

+
+
+
+
+ ); +} diff --git a/app/admin/purchases/page.tsx b/app/admin/purchases/page.tsx new file mode 100644 index 0000000..2384824 --- /dev/null +++ b/app/admin/purchases/page.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { FaDownload, FaSearch } from 'react-icons/fa'; +import { useAlbums } from '@/lib/AlbumsContext'; +import { Purchase } from '@/lib/types'; +import AdminLayout from '@/components/AdminLayout'; + +export default function AdminPurchasesPage() { + const { albums } = useAlbums(); + const [purchases, setPurchases] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + const savedPurchases = localStorage.getItem('purchases'); + if (savedPurchases) { + setPurchases(JSON.parse(savedPurchases).reverse()); + } + }, []); + + const filteredPurchases = purchases.filter((purchase) => { + const album = albums.find((a) => a.id === purchase.albumId); + return ( + album?.title.toLowerCase().includes(searchTerm.toLowerCase()) || + purchase.transactionId.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + + const totalRevenue = purchases.reduce((total, purchase) => { + const album = albums.find((a) => a.id === purchase.albumId); + return total + (album?.price || 0); + }, 0); + + const exportToCSV = () => { + const headers = ['Date', 'Time', 'Transaction ID', 'Album', 'Price']; + const rows = purchases.map((purchase) => { + const album = albums.find((a) => a.id === purchase.albumId); + const date = new Date(purchase.purchaseDate); + return [ + date.toLocaleDateString(), + date.toLocaleTimeString(), + purchase.transactionId, + album?.title || 'Unknown', + `$${album?.price || 0}`, + ]; + }); + + const csvContent = [headers, ...rows].map((row) => row.join(',')).join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `purchases-${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( + +
+ {/* Header */} +
+
+

Purchase History

+

+ {purchases.length} total purchases • ${totalRevenue.toFixed(2)} revenue +

+
+ + + Export CSV + +
+ + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="Search by album name or transaction ID..." + className="w-full pl-12 pr-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" + /> +
+
+ + {/* Purchases Table */} + + {filteredPurchases.length > 0 ? ( +
+ + + + + + + + + + + + {filteredPurchases.map((purchase, index) => { + const album = albums.find((a) => a.id === purchase.albumId); + const date = new Date(purchase.purchaseDate); + + return ( + + + + + + + + ); + })} + +
Date & TimeTransaction IDAlbumGenrePrice
+
{date.toLocaleDateString()}
+
{date.toLocaleTimeString()}
+
+ + {purchase.transactionId} + + +
{album?.title || 'Unknown'}
+
{album?.songs.length} tracks
+
{album?.genre} + ${album?.price || 0} +
+
+ ) : ( +
+

+ {searchTerm ? 'No purchases found matching your search' : 'No purchases yet'} +

+
+ )} +
+
+
+ ); +} diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index 0495202..8eed8e4 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -4,9 +4,9 @@ import { useState, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { motion } from 'framer-motion'; import { FaPlay, FaShoppingCart, FaArrowLeft, FaClock, FaMusic } from 'react-icons/fa'; -import { albums } from '@/lib/data'; import { Album, Purchase } from '@/lib/types'; import { useCart } from '@/lib/CartContext'; +import { useAlbums } from '@/lib/AlbumsContext'; import Header from '@/components/Header'; import CartSidebar from '@/components/CartSidebar'; import MusicPlayer from '@/components/MusicPlayer'; @@ -17,6 +17,7 @@ export default function AlbumDetailPage() { const params = useParams(); const router = useRouter(); const { addToCart, isInCart } = useCart(); + const { albums } = useAlbums(); const [album, setAlbum] = useState(null); const [purchasedAlbums, setPurchasedAlbums] = useState([]); const [purchases, setPurchases] = useState([]); diff --git a/app/api/albums/[id]/route.ts b/app/api/albums/[id]/route.ts new file mode 100644 index 0000000..f2d3684 --- /dev/null +++ b/app/api/albums/[id]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { albumDb } from '@/lib/db'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const album = albumDb.getById(params.id); + + if (!album) { + return NextResponse.json( + { error: 'Album not found' }, + { status: 404 } + ); + } + + return NextResponse.json(album, { status: 200 }); + } catch (error) { + console.error('Error fetching album:', error); + return NextResponse.json( + { error: 'Failed to fetch album' }, + { status: 500 } + ); + } +} diff --git a/app/api/albums/route.ts b/app/api/albums/route.ts new file mode 100644 index 0000000..78d29fa --- /dev/null +++ b/app/api/albums/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { albumDb } from '@/lib/db'; +import { Album } from '@/lib/types'; + +// GET - Get all albums +export async function GET() { + try { + const albums = albumDb.getAll(); + return NextResponse.json(albums, { status: 200 }); + } catch (error) { + console.error('Error fetching albums:', error); + return NextResponse.json( + { error: 'Failed to fetch albums' }, + { status: 500 } + ); + } +} + +// POST - Create new album +export async function POST(request: NextRequest) { + try { + const album: Album = await request.json(); + + // Validate required fields + if (!album.id || !album.title || !album.year || !album.genre || !album.price) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + // Check if album already exists + const existing = albumDb.getById(album.id); + if (existing) { + return NextResponse.json( + { error: 'Album with this ID already exists' }, + { status: 409 } + ); + } + + albumDb.create(album); + return NextResponse.json(album, { status: 201 }); + } catch (error) { + console.error('Error creating album:', error); + return NextResponse.json( + { error: 'Failed to create album' }, + { status: 500 } + ); + } +} + +// PUT - Update album +export async function PUT(request: NextRequest) { + try { + const album: Album = await request.json(); + + if (!album.id) { + return NextResponse.json( + { error: 'Album ID is required' }, + { status: 400 } + ); + } + + // Check if album exists + const existing = albumDb.getById(album.id); + if (!existing) { + return NextResponse.json( + { error: 'Album not found' }, + { status: 404 } + ); + } + + albumDb.update(album.id, album); + return NextResponse.json(album, { status: 200 }); + } catch (error) { + console.error('Error updating album:', error); + return NextResponse.json( + { error: 'Failed to update album' }, + { status: 500 } + ); + } +} + +// DELETE - Delete album +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'Album ID is required' }, + { status: 400 } + ); + } + + // Check if album exists + const existing = albumDb.getById(id); + if (!existing) { + return NextResponse.json( + { error: 'Album not found' }, + { status: 404 } + ); + } + + albumDb.delete(id); + return NextResponse.json({ message: 'Album deleted successfully' }, { status: 200 }); + } catch (error) { + console.error('Error deleting album:', error); + return NextResponse.json( + { error: 'Failed to delete album' }, + { status: 500 } + ); + } +} diff --git a/app/api/purchases/route.ts b/app/api/purchases/route.ts new file mode 100644 index 0000000..7ce71fa --- /dev/null +++ b/app/api/purchases/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { purchaseDb } from '@/lib/db'; +import { Purchase } from '@/lib/types'; + +// GET - Get all purchases +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const albumId = searchParams.get('albumId'); + + let purchases; + if (albumId) { + purchases = purchaseDb.getByAlbumId(albumId); + } else { + purchases = purchaseDb.getAll(); + } + + return NextResponse.json(purchases, { status: 200 }); + } catch (error) { + console.error('Error fetching purchases:', error); + return NextResponse.json( + { error: 'Failed to fetch purchases' }, + { status: 500 } + ); + } +} + +// POST - Create new purchase +export async function POST(request: NextRequest) { + try { + const purchaseData: Omit = await request.json(); + + // Validate required fields + if (!purchaseData.albumId || !purchaseData.transactionId) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + // Check if transaction ID already exists + const existing = purchaseDb.getByTransactionId(purchaseData.transactionId); + if (existing) { + return NextResponse.json( + { error: 'Transaction ID already exists' }, + { status: 409 } + ); + } + + // Ensure purchaseDate is set + const purchase: Omit = { + ...purchaseData, + purchaseDate: purchaseData.purchaseDate || new Date(), + }; + + const created = purchaseDb.create(purchase); + return NextResponse.json(created, { status: 201 }); + } catch (error) { + console.error('Error creating purchase:', error); + return NextResponse.json( + { error: 'Failed to create purchase' }, + { status: 500 } + ); + } +} + +// DELETE - Delete purchase +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'Purchase ID is required' }, + { status: 400 } + ); + } + + purchaseDb.delete(parseInt(id)); + return NextResponse.json({ message: 'Purchase deleted successfully' }, { status: 200 }); + } catch (error) { + console.error('Error deleting purchase:', error); + return NextResponse.json( + { error: 'Failed to delete purchase' }, + { status: 500 } + ); + } +} diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts new file mode 100644 index 0000000..713b092 --- /dev/null +++ b/app/api/upload/audio/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { uploadFileToS3, generateFileKey } from '@/lib/s3'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + const folder = formData.get('folder') as string || 'audio'; + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + + // Validate file type (audio only) + const allowedTypes = [ + 'audio/mpeg', + 'audio/mp3', + 'audio/wav', + 'audio/ogg', + 'audio/flac', + 'audio/aac', + 'audio/m4a', + ]; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + { error: 'Invalid file type. Only audio files are allowed.' }, + { status: 400 } + ); + } + + // Validate file size (max 50MB for audio) + const maxSize = 50 * 1024 * 1024; // 50MB + if (file.size > maxSize) { + return NextResponse.json( + { error: 'File too large. Maximum size is 50MB.' }, + { status: 400 } + ); + } + + // Generate unique key + const key = generateFileKey(folder, file.name); + + // Upload to S3 + const url = await uploadFileToS3({ + file, + key, + contentType: file.type, + }); + + return NextResponse.json({ url, key }, { status: 200 }); + } catch (error) { + console.error('Audio upload error:', error); + return NextResponse.json( + { error: 'Failed to upload audio file' }, + { status: 500 } + ); + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..b7ed195 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { uploadFileToS3, generateFileKey } from '@/lib/s3'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + const folder = formData.get('folder') as string || 'uploads'; + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + + // Validate file type (images only) + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + { error: 'Invalid file type. Only images are allowed.' }, + { status: 400 } + ); + } + + // Validate file size (max 5MB) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + return NextResponse.json( + { error: 'File too large. Maximum size is 5MB.' }, + { status: 400 } + ); + } + + // Generate unique key + const key = generateFileKey(folder, file.name); + + // Upload to S3 + const url = await uploadFileToS3({ + file, + key, + contentType: file.type, + }); + + return NextResponse.json({ url, key }, { status: 200 }); + } catch (error) { + console.error('Upload error:', error); + return NextResponse.json( + { error: 'Failed to upload file' }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index e97681a..657efc9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { CartProvider } from '@/lib/CartContext'; +import { AdminProvider } from '@/lib/AdminContext'; +import { AlbumsProvider } from '@/lib/AlbumsContext'; const inter = Inter({ subsets: ['latin'] }); @@ -19,7 +21,11 @@ export default function RootLayout({ return ( - {children} + + + {children} + + ); diff --git a/components/AddAlbumModal.tsx b/components/AddAlbumModal.tsx new file mode 100644 index 0000000..f6df543 --- /dev/null +++ b/components/AddAlbumModal.tsx @@ -0,0 +1,413 @@ +'use client'; + +import { useState } 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 AddAlbumModalProps { + show: boolean; + onClose: () => void; + onAdd: (album: Album) => void; +} + +export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalProps) { + 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(null); + const [isUploading, setIsUploading] = useState(false); + const [songs, setSongs] = useState([{ id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]); + const [error, setError] = useState(''); + + 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 handleCoverImageChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + 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 handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + // 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; + } + } + + // Create album + const albumId = title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + const albumSongs: Song[] = songs.map((song, index) => ({ + id: `${albumId}-${index + 1}`, + title: song.title, + duration: song.duration, + previewUrl: song.previewUrl || '/audio/preview-1.mp3', + fullUrl: song.fullUrl || '/audio/default-full.mp3', + })); + + const newAlbum: Album = { + id: albumId, + title, + coverImage: coverImage || '/albums/default-cover.jpg', + year: yearNum, + genre, + description, + price: priceNum, + songs: albumSongs, + }; + + onAdd(newAlbum); + handleClose(); + }; + + const handleClose = () => { + setTitle(''); + setYear(''); + setGenre(''); + setDescription(''); + setPrice(''); + setCoverImage(''); + setCoverImageFile(null); + setSongs([{ id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]); + setError(''); + onClose(); + }; + + return ( + + {show && ( + + e.stopPropagation()} + className="glass-effect rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-8 border-2 border-accent-cyan/30" + > + {/* Header */} +
+

+ Add New Album +

+ +
+ + {/* Form */} +
+ {/* Title */} +
+ + 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-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500" + required + /> +
+ + {/* Cover Image Upload */} +
+ +
+ + {coverImage && ( +
+ Cover preview +
+ )} +
+

+ Upload an album cover image (max 5MB, JPG/PNG/WEBP) +

+
+ + {/* Year and Genre */} +
+
+ + 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-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500" + required + /> +
+
+ + 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-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500" + required + /> +
+
+ + {/* Description */} +
+ +