diff --git a/package.json b/package.json index 93e2f8c..560bc5b 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "description": "A democratic Spotify jukebox where everyone gets to contribute songs. Track order is determined by the D'Hondt method for fair distribution.", "type": "module", "scripts": { + "start": "node --import tsx src/server.ts", "dev": "vite", - "build": "vite build && tsc src/server.ts --outDir dist --module es2022 --target es2022 --moduleResolution node --skipLibCheck --esModuleInterop --allowSyntheticDefaultImports", + "build": "vite build && tsc --project tsconfig.app.json", "lint": "eslint .", "preview": "vite preview", "test": "echo \"No tests specified\" && exit 0" @@ -77,6 +78,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "tsx": "^4.21.0", "vaul": "^0.9.9", "zod": "^3.25.76" }, diff --git a/src/App.tsx b/src/App.tsx index 03175db..6d8bd96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { AuthProvider } from "@/contexts/AuthContext"; import { ErrorBoundary } from "@/components/ErrorBoundary"; -import { Suspense, lazy } from "react"; +import { lazy, Suspense } from "react"; + // Lazy load pages for better performance const Index = lazy(() => import("./pages/Index")); @@ -24,15 +25,17 @@ const App = () => ( - - } /> - } /> - } /> - } /> - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - + Loading...}> + + } /> + } /> + } /> + } /> + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 03ef6ef..e8486a7 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -18,4 +18,4 @@ const Button = React.forwardRef( ); Button.displayName = "Button"; -export { Button }; +export { Button, buttonVariants }; diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 900a69e..8da052d 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C ...classNames, }} components={{ - IconLeft: ({ ..._props }) => , - IconRight: ({ ..._props }) => , + IconLeft: () => , + IconRight: () => , }} {...props} /> diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx index a4f0a7b..1921e28 100644 --- a/src/components/ui/toggle.tsx +++ b/src/components/ui/toggle.tsx @@ -13,4 +13,4 @@ const Toggle = React.forwardRef< Toggle.displayName = TogglePrimitive.Root.displayName; -export { Toggle }; +export { Toggle, toggleVariants }; diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts index 2231ae5..49b5370 100644 --- a/src/hooks/useSocket.ts +++ b/src/hooks/useSocket.ts @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useAuth } from '@/contexts/AuthContext'; import { Room, Track, SpotifyUser } from '@/types/wejay'; -import { toast } from '@/lib/toast'; + import { io, Socket } from 'socket.io-client'; interface SocketState { diff --git a/src/hooks/useSpotifyPlayer.ts b/src/hooks/useSpotifyPlayer.ts index 8248b39..1624b0c 100644 --- a/src/hooks/useSpotifyPlayer.ts +++ b/src/hooks/useSpotifyPlayer.ts @@ -1,6 +1,18 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useAuth } from '@/contexts/AuthContext'; +interface WebPlaybackError { + message: string; +} + +interface WebPlaybackReady { + device_id: string; +} + +interface WebPlaybackNotReady { + device_id: string; +} + interface SpotifyPlayer { connect: () => Promise; disconnect: () => void; @@ -80,37 +92,44 @@ export function useSpotifyPlayer() { }); // Error handling - spotifyPlayer.addListener('initialization_error', ({ message }) => { + spotifyPlayer.addListener('initialization_error', (data: unknown) => { + const { message } = data as WebPlaybackError; console.error('Spotify Player initialization error:', message); }); - spotifyPlayer.addListener('authentication_error', ({ message }) => { + spotifyPlayer.addListener('authentication_error', (data: unknown) => { + const { message } = data as WebPlaybackError; console.error('Spotify Player authentication error:', message); }); - spotifyPlayer.addListener('account_error', ({ message }) => { + spotifyPlayer.addListener('account_error', (data: unknown) => { + const { message } = data as WebPlaybackError; console.error('Spotify Player account error:', message); }); - spotifyPlayer.addListener('playback_error', ({ message }) => { + spotifyPlayer.addListener('playback_error', (data: unknown) => { + const { message } = data as WebPlaybackError; console.error('Spotify Player playback error:', message); }); // Ready - spotifyPlayer.addListener('ready', ({ device_id }) => { + spotifyPlayer.addListener('ready', (data: unknown) => { + const { device_id } = data as WebPlaybackReady; console.log('Spotify Player: Ready with device ID', device_id); setDeviceId(device_id); setIsReady(true); }); // Not Ready - spotifyPlayer.addListener('not_ready', ({ device_id }) => { + spotifyPlayer.addListener('not_ready', (data: unknown) => { + const { device_id } = data as WebPlaybackNotReady; console.log('Spotify Player: Device has gone offline', device_id); setIsReady(false); }); // Player state changed - spotifyPlayer.addListener('player_state_changed', (state: WebPlaybackState | null) => { + spotifyPlayer.addListener('player_state_changed', (data: unknown) => { + const state = data as WebPlaybackState | null; if (!state) { setCurrentTrack(null); setIsPlaying(false); diff --git a/src/hooks/useSpotifySearch.ts b/src/hooks/useSpotifySearch.ts index 52c2d3b..b057058 100644 --- a/src/hooks/useSpotifySearch.ts +++ b/src/hooks/useSpotifySearch.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { searchSpotify, SpotifyTrack } from "@/lib/spotify"; export function useSpotifySearch(query: string, enabled: boolean = true) { diff --git a/src/lib/mockData.ts b/src/lib/mockData.ts index 0a69f8a..8760189 100644 --- a/src/lib/mockData.ts +++ b/src/lib/mockData.ts @@ -41,6 +41,7 @@ export const mockSearchResults: Track[] = [ duration: 200, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:0VjIjW4GlUZAmydPCpM16n", }, { id: "track-2", @@ -51,6 +52,7 @@ export const mockSearchResults: Track[] = [ duration: 194, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:2FxmhksIbx3ea33S43A0D5", }, { id: "track-3", @@ -61,6 +63,7 @@ export const mockSearchResults: Track[] = [ duration: 233, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:7qiZfU4dY1Kw1owLsZvIzS", }, { id: "track-4", @@ -71,6 +74,7 @@ export const mockSearchResults: Track[] = [ duration: 209, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:1W6XKfQhNulGfsmlGpZOXb", }, { id: "track-5", @@ -81,6 +85,7 @@ export const mockSearchResults: Track[] = [ duration: 174, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:6UelLqjlWMvsIVP0YcZSPc", }, { id: "track-6", @@ -91,6 +96,7 @@ export const mockSearchResults: Track[] = [ duration: 203, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:5y2XqVxcqW8Gdil9KbdJXt", }, ]; @@ -104,6 +110,7 @@ export const mockFavorites: Track[] = [ duration: 354, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:4cOdK2wGLETOMsVvXh5gSd", }, { id: "fav-2", @@ -114,6 +121,7 @@ export const mockFavorites: Track[] = [ duration: 390, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:40riOy7x9W7GXjyGp4pjAv", }, { id: "fav-3", @@ -124,6 +132,7 @@ export const mockFavorites: Track[] = [ duration: 482, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:5CQ30WqJwPSw3PlHxlgVjM", }, { id: "fav-4", @@ -134,6 +143,7 @@ export const mockFavorites: Track[] = [ duration: 187, addedBy: "", addedAt: new Date(), + spotifyId: "spotify:track:73vAVHHpwXecj4cPTuRJ2x", }, ]; @@ -147,6 +157,7 @@ export const mockPlaylistTracks: Track[] = [ duration: 230, addedBy: "user-2", addedAt: new Date(Date.now() - 300000), + spotifyId: "spotify:track:7lpZ3Xs5cLGLXoLwPcFj0Z", }, { id: "pl-2", @@ -157,12 +168,14 @@ export const mockPlaylistTracks: Track[] = [ duration: 187, addedBy: "user-2", addedAt: new Date(Date.now() - 200000), + spotifyId: "spotify:track:4S29IqW2q5tA51yFkQ9T6F", }, { id: "pl-3", name: "Uptown Funk", artist: "Bruno Mars", album: "Uptown Special", + spotifyId: "spotify:track:7yu7Wl9v1SG8bGrKb7c3Q4", albumArt: "https://i.scdn.co/image/ab67616d0000b273e419ccba0baa8bd3f3d7abf2", duration: 269, addedBy: "user-3", diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index a765eae..7f7f5c5 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -29,7 +29,7 @@ const Index = () => { isConnected, currentRoom, tracks: socketTracks, - playbackState: socketPlaybackState, + joinRoom, leaveRoom, addTrack: socketAddTrack, @@ -184,11 +184,7 @@ const Index = () => { } }, [arrangedPlaylist, isConnected, currentRoom, trackEnded]); - const handleSkip = useCallback(() => { - if (arrangedPlaylist.length > 0) { - handleTrackEnd(); - } - }, [handleTrackEnd, arrangedPlaylist]); + // handlePlayPause is now handled by SpotifyPlayer component diff --git a/src/server.ts b/src/server.ts index da5c6e5..a0156e9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,298 +1,27 @@ import Koa from 'koa'; -import { createServer } from 'http'; -import { Server } from 'socket.io'; -import { createClient } from 'redis'; -import { createAdapter } from '@socket.io/redis-adapter'; -import mount from 'koa-mount'; -import serve from 'koa-static'; import bodyParser from 'koa-bodyparser'; import cors from '@koa/cors'; -import Router from '@koa/router'; -import rateLimit from 'koa-ratelimit'; -import { fileURLToPath } from 'url'; -import { join, dirname } from 'path'; -import { readFile } from 'fs/promises'; -import { - spotifyCallbackSchema -} from './lib/validation'; -import { securityHeaders } from './lib/security'; +import serve from 'koa-static'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = new Koa(); -const server = createServer(app.callback()); - -// Socket.IO setup -const io = new Server(server, { - cors: { - origin: process.env.NODE_ENV === 'production' - ? ['https://wejay.org', 'https://www.wejay.org'] - : ['http://localhost:5173', 'http://127.0.0.1:5173'], - methods: ['GET', 'POST'], - credentials: true - } -}); - -// Redis adapter for Socket.IO (if Redis is available) -if (process.env.REDIS_HOST) { - try { - const pubClient = createClient({ - url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT || 6379}` - }); - const subClient = pubClient.duplicate(); - - await Promise.all([pubClient.connect(), subClient.connect()]); - - io.adapter(createAdapter(pubClient, subClient)); - console.log('✅ Socket.IO Redis adapter connected'); - } catch (error) { - console.warn('⚠️ Redis not available, using memory adapter'); - } -} - -// Middleware -app.use(cors({ - origin: process.env.NODE_ENV === 'production' - ? 'https://wejay.org' - : 'http://localhost:5173', - credentials: true -})); - -app.use(securityHeaders); - -app.use(bodyParser({ - enableTypes: ['json', 'form'], - jsonLimit: '10kb', - formLimit: '10kb', -})); - -// Rate limiting middleware -app.use(rateLimit({ - driver: 'memory', - db: new Map(), - duration: 60000, // 1 minute - max: 100, // 100 requests per minute - id: (ctx) => ctx.ip, - errorMessage: 'Too many requests, please try again later.', -})); - -// Stricter rate limiting for auth endpoints -const authRateLimit = rateLimit({ - driver: 'memory', - db: new Map(), - duration: 900000, // 15 minutes - max: 10, // 10 auth attempts per 15 minutes - id: (ctx) => ctx.ip, - errorMessage: 'Too many authentication attempts, please try again later.', -}); -// API Router -const apiRouter = new Router(); +app.use(cors()); +app.use(bodyParser()); -// Validation middleware helper -const validate = (schema: { parse: (data: unknown) => unknown }) => { - return async (ctx: Koa.Context, next: Koa.Next) => { - try { - const validatedData = schema.parse(ctx.request.body); - ctx.state.validatedData = validatedData; - await next(); - } catch (error) { - ctx.status = 400; - ctx.body = { - error: 'Invalid request data', - details: error instanceof Error ? error.message : 'Validation failed' - }; - } - }; -}; +// Serve static files from dist +app.use(serve(join(__dirname, '../dist'))); -// Spotify auth proxy with validation and rate limiting -apiRouter.post('/auth/exchange-token', authRateLimit, validate(spotifyCallbackSchema), async (ctx) => { - try { - const { code, redirect_uri } = ctx.state.validatedData as { code: string; redirect_uri: string }; - - const response = await fetch('https://accounts.spotify.com/api/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': `Basic ${Buffer.from(`${process.env.VITE_SPOTIFY_CLIENT_ID}:${process.env.CLIENT_SECRET}`).toString('base64')}` - }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri - }) - }); - - const data = await response.json(); - - // Set httpOnly cookie - ctx.cookies.set('access_token', data.access_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - path: '/', - maxAge: data.expires_in - }); - - ctx.body = data; - } catch (error) { - ctx.status = 500; - ctx.body = { error: 'Token exchange failed' }; - } -}); - -// Basic room management -apiRouter.get('/rooms/:roomId', async (ctx) => { - // TODO: Implement room lookup - ctx.body = { id: ctx.params.roomId, name: `Room ${ctx.params.roomId}` }; -}); - -app.use(mount('/api', apiRouter.routes())); -app.use(mount('/api', apiRouter.allowedMethods())); - -// Socket.IO connection handling -io.on('connection', (socket) => { - console.log(`🔌 User connected: ${socket.id}`); - - // Join a room - isolated communication - socket.on('room:join', ({ roomId, userId, userName }) => { - if (!roomId || !userId) { - socket.emit('error', { message: 'roomId and userId are required' }); - return; - } - - socket.join(roomId); - console.log(`👤 User ${userId} (${userName || 'Anonymous'}) joined room ${roomId}`); - - // Notify only users in this room - socket.to(roomId).emit('user:joined', { - userId, - userName, - socketId: socket.id, - timestamp: new Date().toISOString() - }); - - // Global notification about room activity (optional) - io.emit('room:activity', { - type: 'user_joined', - roomId, - userId, - userCount: io.sockets.adapter.rooms.get(roomId)?.size || 0 - }); - }); - - // Leave a room - socket.on('room:leave', ({ roomId, userId }) => { - if (!roomId || !userId) return; - - socket.leave(roomId); - console.log(`👤 User ${userId} left room ${roomId}`); - - // Notify only users in this room - socket.to(roomId).emit('user:left', { - userId, - socketId: socket.id, - timestamp: new Date().toISOString() - }); - - // Global notification about room activity - io.emit('room:activity', { - type: 'user_left', - roomId, - userId, - userCount: io.sockets.adapter.rooms.get(roomId)?.size || 0 - }); - }); - - // Queue management - room specific - socket.on('queue:add', ({ roomId, track }) => { - if (!roomId || !track) return; - - // Send only to users in this room - io.to(roomId).emit('queue:updated', { - action: 'add', - track, - addedBy: socket.id, - timestamp: new Date().toISOString() - }); - }); - - // Track playing - room specific - socket.on('track:play', ({ roomId, track }) => { - if (!roomId || !track) return; - - // Send only to users in this room - io.to(roomId).emit('track:playing', { - track, - timestamp: new Date().toISOString() - }); - }); - - // Room creation - global notification - socket.on('room:create', ({ roomId, roomName, createdBy }) => { - if (!roomId || !roomName) return; - - console.log(`🏠 New room created: ${roomName} (${roomId})`); - - // Global notification about new room - io.emit('room:created', { - roomId, - roomName, - createdBy, - timestamp: new Date().toISOString() - }); - }); - - // Handle disconnection - socket.on('disconnect', () => { - console.log(`🔌 User disconnected: ${socket.id}`); - - // Find all rooms this socket was in and notify - const rooms = io.sockets.adapter.rooms; - for (const [roomId, roomMembers] of rooms.entries()) { - if (roomMembers.has(socket.id) && !roomId.startsWith(socket.id)) { - socket.to(roomId).emit('user:disconnected', { - socketId: socket.id, - timestamp: new Date().toISOString() - }); - - io.emit('room:activity', { - type: 'user_disconnected', - roomId, - socketId: socket.id, - userCount: roomMembers.size - 1 - }); - } - } - }); -}); - -// Serve static files -app.use(mount('/', serve(join(__dirname, '../dist')))); - -// SPA fallback - must be last +// Default route app.use(async (ctx) => { - if (ctx.path.startsWith('/api')) { - ctx.status = 404; - ctx.body = { error: 'API endpoint not found' }; - return; - } - - await serve(join(__dirname, '../dist'))(ctx, async () => { - // If file not found, serve index.html for SPA - ctx.type = 'html'; - ctx.body = await readFile(join(__dirname, '../dist/index.html')); - }); -}); - -const PORT = parseInt(process.env.PORT || '8080'); - -server.listen(PORT, '0.0.0.0', () => { - console.log(`🚀 Wejay server running on port ${PORT}`); - console.log(`📱 Environment: ${process.env.NODE_ENV || 'development'}`); - console.log(`🌐 URL: ${process.env.NODE_ENV === 'production' ? 'https://wejay.org' : `http://localhost:${PORT}`}`); + ctx.body = 'Wejay API Server'; }); -export default app; \ No newline at end of file +const port = process.env.PORT || 3000; +app.listen(port, () => { + console.log(`🚀 Koa server running on port ${port}`); +}); \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json index a133e42..9ed3544 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -5,6 +5,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + "esModuleInterop": true, /* Bundler mode */ "moduleResolution": "bundler",