From e0e4f49ce988360dff7fd3d6e0fad0da4055b776 Mon Sep 17 00:00:00 2001 From: hey-Zayn Date: Thu, 26 Mar 2026 09:41:57 +0500 Subject: [PATCH] added redis layer --- .gitignore | 9 +- backend/package-lock.json | 94 ++ backend/package.json | 1 + backend/src/__tests__/cacheManager.test.js | 61 ++ .../src/__tests__/stats.controller.test.js | 50 + backend/src/controllers/admin.controller.js | 50 +- backend/src/controllers/album.controller.js | 89 +- backend/src/controllers/song.controller.js | 51 +- backend/src/controllers/stats.controller.js | 60 +- backend/src/lib/cacheManager.js | 57 + backend/src/lib/cloudinaryHelper.js | 24 + backend/src/lib/redis.js | 21 + backend/src/middleware/auth.middleware.js | 17 +- backend/src/models/song.model.js | 2 +- backend/src/routes/album.route.js | 6 +- backend/src/routes/songs.route.js | 4 +- backend/src/seeds/migrate_song_creators.js | 36 + frontend/package-lock.json | 987 +++++++++++++++++- frontend/package.json | 1 + frontend/src/components/layout/MainLayout.tsx | 60 +- .../layout/components/FriendsActivity.tsx | 8 +- .../layout/components/LeftSidebar.jsx | 4 +- .../layout/components/PlaybackControls.tsx | 50 +- frontend/src/components/ui/alert-dialog.tsx | 194 ++++ frontend/src/components/ui/skeleton.tsx | 15 + frontend/src/index.css | 15 + frontend/src/main.tsx | 23 +- frontend/src/pages/admin/AdminPage.tsx | 12 +- .../pages/admin/components/AddAlbumDialog.tsx | 22 +- .../pages/admin/components/AddSongDialog.tsx | 55 +- .../admin/components/AlbumsTabContent.tsx | 4 +- .../pages/admin/components/AlbumsTable.tsx | 159 ++- .../pages/admin/components/DashboardStats.tsx | 4 +- .../admin/components/EditAlbumDialog.tsx | 4 +- .../pages/admin/components/EditSongDialog.tsx | 6 +- .../src/pages/admin/components/Header.tsx | 6 +- .../admin/components/SongsTabContent.tsx | 4 +- .../src/pages/admin/components/SongsTable.tsx | 142 ++- frontend/src/pages/album/AlbumPage.tsx | 400 ++++--- .../pages/home/components/FeaturedSection.jsx | 6 +- frontend/src/pages/home/page/HomePage.tsx | 4 +- frontend/src/pages/playlists/PlaylistPage.tsx | 3 - frontend/src/store/useAlbumStore.ts | 89 ++ frontend/src/store/useMusicStore.test.tsx | 46 - frontend/src/store/useMusicStore.tsx | 155 --- frontend/src/store/useSongStore.test.ts | 48 + frontend/src/store/useSongStore.ts | 87 ++ frontend/src/store/useStatsStore.ts | 79 ++ frontend/src/types/index.ts | 3 +- .../{test-report.md => unit-test-report.md} | 0 report/unit_test_report-2.md | 36 + 51 files changed, 2662 insertions(+), 701 deletions(-) create mode 100644 backend/src/__tests__/cacheManager.test.js create mode 100644 backend/src/__tests__/stats.controller.test.js create mode 100644 backend/src/lib/cacheManager.js create mode 100644 backend/src/lib/cloudinaryHelper.js create mode 100644 backend/src/lib/redis.js create mode 100644 backend/src/seeds/migrate_song_creators.js create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/store/useAlbumStore.ts delete mode 100644 frontend/src/store/useMusicStore.test.tsx delete mode 100644 frontend/src/store/useMusicStore.tsx create mode 100644 frontend/src/store/useSongStore.test.ts create mode 100644 frontend/src/store/useSongStore.ts create mode 100644 frontend/src/store/useStatsStore.ts rename report/{test-report.md => unit-test-report.md} (100%) create mode 100644 report/unit_test_report-2.md diff --git a/.gitignore b/.gitignore index 7e1124a..0b168aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ final_remediation_report.md -analysis_report.md \ No newline at end of file +analysis_report.md +/plan/ + +plan +/plan/** +plan/development-plan.md +plan/remediation-plan.md +/plan/ diff --git a/backend/package-lock.json b/backend/package-lock.json index e31f6a9..0c37288 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "express-fileupload": "^1.5.2", "mongoose": "^9.0.0", "nodemon": "^3.1.11", + "redis": "^5.11.0", "socket.io": "^4.8.1", "winston": "^3.19.0" }, @@ -743,6 +744,74 @@ "@opentelemetry/api": "^1.8" } }, + "node_modules/@redis/bloom": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", + "integrity": "sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", + "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.11.0.tgz", + "integrity": "sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/search": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.11.0.tgz", + "integrity": "sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.11.0.tgz", + "integrity": "sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", @@ -1652,6 +1721,15 @@ "node": ">=9" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -3475,6 +3553,22 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", + "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.11.0", + "@redis/client": "5.11.0", + "@redis/json": "5.11.0", + "@redis/search": "5.11.0", + "@redis/time-series": "5.11.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/require-in-the-middle": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 3513877..0cc805b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "express-fileupload": "^1.5.2", "mongoose": "^9.0.0", "nodemon": "^3.1.11", + "redis": "^5.11.0", "socket.io": "^4.8.1", "winston": "^3.19.0" }, diff --git a/backend/src/__tests__/cacheManager.test.js b/backend/src/__tests__/cacheManager.test.js new file mode 100644 index 0000000..4762e1c --- /dev/null +++ b/backend/src/__tests__/cacheManager.test.js @@ -0,0 +1,61 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +const redisClient = require('../lib/redis'); +const CacheManager = require('../lib/cacheManager'); + +describe('CacheManager', () => { + const mockKey = 'test-key'; + const mockData = { foo: 'bar' }; + const mockTTL = 60; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('getOrFetch', () => { + it('should return cached data if present', async () => { + const spyGet = vi.spyOn(redisClient, 'get').mockResolvedValue(JSON.stringify(mockData)); + const fetcher = vi.fn(); + + const result = await CacheManager.getOrFetch(mockKey, mockTTL, fetcher); + + expect(result).toEqual(mockData); + expect(spyGet).toHaveBeenCalledWith(mockKey); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('should call fetcher and set cache if data is missing', async () => { + vi.spyOn(redisClient, 'get').mockResolvedValue(null); + const spySet = vi.spyOn(redisClient, 'setEx').mockResolvedValue('OK'); + const fetcher = vi.fn().mockResolvedValue(mockData); + + const result = await CacheManager.getOrFetch(mockKey, mockTTL, fetcher); + + expect(result).toEqual(mockData); + expect(fetcher).toHaveBeenCalled(); + expect(spySet).toHaveBeenCalledWith(mockKey, mockTTL, JSON.stringify(mockData)); + }); + + it('should fallback to fetcher if Redis fails', async () => { + vi.spyOn(redisClient, 'get').mockRejectedValue(new Error('Redis Down')); + const fetcher = vi.fn().mockResolvedValue(mockData); + + const result = await CacheManager.getOrFetch(mockKey, mockTTL, fetcher); + + expect(result).toEqual(mockData); + expect(fetcher).toHaveBeenCalled(); + }); + }); + + describe('purgePattern', () => { + it('should delete keys matching a pattern', async () => { + const mockKeys = ['key1', 'key2']; + const spyKeys = vi.spyOn(redisClient, 'keys').mockResolvedValue(mockKeys); + const spyDel = vi.spyOn(redisClient, 'del').mockResolvedValue(2); + + await CacheManager.purgePattern('pattern:*'); + + expect(spyKeys).toHaveBeenCalledWith('pattern:*'); + expect(spyDel).toHaveBeenCalledWith(mockKeys); + }); + }); +}); diff --git a/backend/src/__tests__/stats.controller.test.js b/backend/src/__tests__/stats.controller.test.js new file mode 100644 index 0000000..5971a52 --- /dev/null +++ b/backend/src/__tests__/stats.controller.test.js @@ -0,0 +1,50 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +const { getStats } = require('../controllers/stats.controller'); +const Song = require('../models/song.model'); +const User = require('../models/user.model'); +const Album = require('../models/album.model'); +const CacheManager = require('../lib/cacheManager'); + +describe('StatsController - getStats', () => { + let req, res, next; + + beforeEach(() => { + req = { + query: {}, + auth: { userId: 'user_123' } + }; + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis() + }; + next = vi.fn(); + vi.restoreAllMocks(); + }); + + it('should use global cache key for non-user queries', async () => { + const spyGetOrFetch = vi.spyOn(CacheManager, 'getOrFetch').mockImplementation(async (key, ttl, fetcher) => { + return await fetcher(); + }); + + // Mock DB calls + vi.spyOn(Song, 'countDocuments').mockResolvedValue(10); + vi.spyOn(User, 'countDocuments').mockResolvedValue(5); + vi.spyOn(Album, 'countDocuments').mockResolvedValue(2); + vi.spyOn(Song, 'aggregate').mockResolvedValue([{ count: 3 }]); + + await getStats(req, res, next); + + expect(spyGetOrFetch).toHaveBeenCalledWith('music-app:stats:global', 600, expect.any(Function)); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ totalSongs: 10 })); + }); + + it('should use user-specific cache key when user=true', async () => { + req.query.user = 'true'; + vi.spyOn(CacheManager, 'getOrFetch').mockResolvedValue({ totalSongs: 1 }); + + await getStats(req, res, next); + + expect(CacheManager.getOrFetch).toHaveBeenCalledWith('music-app:stats:user_123', 600, expect.any(Function)); + }); +}); diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index 942751f..0a7f353 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -1,27 +1,10 @@ const clerk = require('@clerk/express'); const Song = require("../models/song.model"); const Album = require("../models/album.model"); -const cloudinary = require("../lib/cloudinary"); +const { getPublicId, uploadToCloudinary } = require("../lib/cloudinaryHelper"); +const CacheManager = require("../lib/cacheManager"); -const getPublicId = (url) => { - if (!url) return null; - const parts = url.split("/"); - const fileName = parts.pop(); - const publicId = fileName.split(".")[0]; - return publicId; -}; -const uploadToCloudinary = async (file) => { - try { - const result = await cloudinary.uploader.upload(file.tempFilePath, { - resource_type: "auto", - }); - return result.secure_url; - } catch (err) { - console.log("Error in uploadToCloudinary:", err); - throw new Error(err.message || "Cloudinary upload failed"); - } -}; const createSong = async (req, res, next) => { try { @@ -45,8 +28,8 @@ const createSong = async (req, res, next) => { const audioFile = req.files.audioFile; const imageFile = req.files.imageFile; - const audioUrl = await uploadToCloudinary(audioFile); - const imageUrl = await uploadToCloudinary(imageFile); + const audioUrl = await uploadToCloudinary(audioFile, "music-app/songs"); + const imageUrl = await uploadToCloudinary(imageFile, "music-app/songs"); const song = new Song({ title, @@ -65,6 +48,12 @@ const createSong = async (req, res, next) => { }, { new: true }); } + // Invalidate caches + await CacheManager.del("music-app:stats:global"); + if (song.creator) await CacheManager.del(`music-app:stats:${song.creator}`); + await CacheManager.del(["music-app:songs:featured", "music-app:songs:for-you", "music-app:songs:trending"]); + await CacheManager.purgePattern("music-app:albums:*"); // Clear any album listings as they may change counts or content + res.status(201).json({ success: true, message: "Song created successfully", @@ -111,6 +100,12 @@ const deleteSong = async (req, res, next) => { await Song.findByIdAndDelete(id); + // Invalidate caches + await CacheManager.del("music-app:stats:global"); + if (song.creator) await CacheManager.del(`music-app:stats:${song.creator}`); + await CacheManager.del(["music-app:songs:featured", "music-app:songs:for-you", "music-app:songs:trending"]); + await CacheManager.purgePattern("music-app:albums:*"); + res.status(200).json({ success: true, message: "Song and associated media deleted successfully" @@ -140,7 +135,7 @@ const createAlbum = async (req, res, next) => { } const imageFile = req.files.imageFile; - const imageUrl = await uploadToCloudinary(imageFile); + const imageUrl = await uploadToCloudinary(imageFile, "music-app/albums"); const album = new Album({ title, @@ -152,6 +147,11 @@ const createAlbum = async (req, res, next) => { await album.save(); + // Invalidate caches + await CacheManager.del("music-app:stats:global"); + if (album.creator) await CacheManager.del(`music-app:stats:${album.creator}`); + await CacheManager.purgePattern("music-app:albums:*"); + res.status(201).json({ success: true, message: "Album created successfully", @@ -201,6 +201,12 @@ const deleteAlbum = async (req, res, next) => { await Song.deleteMany({ albumId: id }); await Album.findByIdAndDelete(id); + // Invalidate caches + await CacheManager.del("music-app:stats:global"); + if (album.creator) await CacheManager.del(`music-app:stats:${album.creator}`); + await CacheManager.del(["music-app:songs:featured", "music-app:songs:for-you", "music-app:songs:trending"]); + await CacheManager.purgePattern("music-app:albums:*"); + res.status(200).json({ success: true, message: "Album and all associated music and files deleted successfully" diff --git a/backend/src/controllers/album.controller.js b/backend/src/controllers/album.controller.js index 883ad8b..aee3645 100644 --- a/backend/src/controllers/album.controller.js +++ b/backend/src/controllers/album.controller.js @@ -1,53 +1,53 @@ +const { getPublicId, uploadToCloudinary } = require("../lib/cloudinaryHelper"); +const { isAdminUser } = require("../middleware/auth.middleware"); const Album = require("../models/album.model"); const Song = require("../models/song.model"); const cloudinary = require("../lib/cloudinary"); +const CacheManager = require("../lib/cacheManager"); -const getPublicId = (url) => { - if (!url) return null; - const parts = url.split("/"); - const fileName = parts.pop(); - const publicId = fileName.split(".")[0]; - return publicId; -}; - -const uploadToCloudinary = async (file) => { - try { - const result = await cloudinary.uploader.upload(file.tempFilePath, { - resource_type: "auto", - }); - return result.secure_url; - } catch (err) { - console.log("Error in uploadToCloudinary:", err); - throw new Error(err.message || "Cloudinary upload failed"); - } -}; - -const AllAlbums = async (req, res, next) => { +const getAlbums = async (req, res, next) => { try { - const filter = {}; - - // If user=true, only show their own albums - if (req.query.user === "true" && req.auth?.userId) { - filter.creator = req.auth.userId; - } else if (!req.query.all === "true") { - // Default behavior if not asking for "all" (and not an admin) could be to still filter by user - // or just allow discovery if intended. - // For now, let's keep it restricted to the creator if they are in a management context. - // But if it's for discovery (HomePage), they might not pass user=true. - // However, the dashboard ALWAYS passes user=true. - } + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + const isUserOnly = req.query.user === "true" && req.auth?.userId; + const cacheKey = isUserOnly ? `music-app:albums:${req.auth.userId}:${page}:${limit}` : `music-app:albums:global:${page}:${limit}`; + + const responseData = await CacheManager.getOrFetch(cacheKey, 900, async () => { + const skip = (page - 1) * limit; + const filter = {}; + + if (isUserOnly) { + filter.creator = req.auth.userId; + } + + const albums = await Album.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit); + + const total = await Album.countDocuments(filter); + + return { + success: true, + albums, + pagination: { + total, + page, + limit, + pages: Math.ceil(total / limit) + } + }; + }); - const albums = await Album.find(filter); res.status(200).json({ - success: true, - message: "All Albums", - albums + ...responseData, + message: "All Albums" }) } catch (error) { next(error); } } -const AllAlbumsById = async (req, res, next) => { +const getAlbumById = async (req, res, next) => { try { const { albumId } = req.params; const album = await Album.findById(albumId).populate("songs"); @@ -80,7 +80,7 @@ const createAlbum = async (req, res, next) => { return res.status(400).json({ success: false, message: "Title and artist are required" }); } - const imageUrl = await uploadToCloudinary(req.files.imageFile); + const imageUrl = await uploadToCloudinary(req.files.imageFile, "music-app/albums"); const album = new Album({ title, @@ -109,10 +109,7 @@ const deleteAlbum = async (req, res, next) => { } if (album.creator && album.creator !== userId) { - const { clerkClient } = require('@clerk/express'); - const currentUser = await clerkClient.users.getUser(userId); - const primaryEmail = currentUser?.primaryEmailAddress?.emailAddress; - const isAdmin = process.env.ADMIN_EMAIL && primaryEmail && process.env.ADMIN_EMAIL === primaryEmail; + const isAdmin = await isAdminUser(userId); if (!isAdmin) { return res.status(403).json({ message: "Unauthorized to delete this album" }); @@ -169,7 +166,7 @@ const updateAlbum = async (req, res, next) => { console.error("Cloudinary old album image deletion failed:", err); } } - updatedData.imageUrl = await uploadToCloudinary(req.files.imageFile); + updatedData.imageUrl = await uploadToCloudinary(req.files.imageFile, "music-app/albums"); } const updatedAlbum = await Album.findByIdAndUpdate(id, updatedData, { new: true }); @@ -180,8 +177,8 @@ const updateAlbum = async (req, res, next) => { }; module.exports = { - AllAlbums, - AllAlbumsById, + getAlbums, + getAlbumById, createAlbum, deleteAlbum, updateAlbum, diff --git a/backend/src/controllers/song.controller.js b/backend/src/controllers/song.controller.js index 6579914..15df4ad 100644 --- a/backend/src/controllers/song.controller.js +++ b/backend/src/controllers/song.controller.js @@ -1,26 +1,9 @@ +const { getPublicId, uploadToCloudinary } = require("../lib/cloudinaryHelper"); +const { isAdminUser } = require("../middleware/auth.middleware"); const Song = require("../models/song.model"); const Album = require("../models/album.model"); const cloudinary = require("../lib/cloudinary"); - -const getPublicId = (url) => { - if (!url) return null; - const parts = url.split("/"); - const fileName = parts.pop(); - const publicId = fileName.split(".")[0]; - return publicId; -}; - -const uploadToCloudinary = async (file) => { - try { - const result = await cloudinary.uploader.upload(file.tempFilePath, { - resource_type: "auto", - }); - return result.secure_url; - } catch (err) { - console.log("Error in uploadToCloudinary:", err); - throw new Error(err.message || "Cloudinary upload failed"); - } -}; +const CacheManager = require("../lib/cacheManager"); const getRandomSongs = async (size) => { return await Song.aggregate([ @@ -37,7 +20,7 @@ const getRandomSongs = async (size) => { ]); }; -const getAllSongs = async (req, res, next) => { +const getSongs = async (req, res, next) => { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; @@ -86,7 +69,11 @@ const getSingleSong = async (req, res, next) => { const getFeaturedSongs = async (req, res, next) => { try { - const songs = await getRandomSongs(8); + const cacheKey = "music-app:songs:featured"; + const songs = await CacheManager.getOrFetch(cacheKey, 1800, async () => { + return await getRandomSongs(8); + }); + res.status(200).json({ success: true, message: "Featured songs fetched successfully", @@ -99,7 +86,11 @@ const getFeaturedSongs = async (req, res, next) => { const getSongsForYou = async (req, res, next) => { try { - const songs = await getRandomSongs(4); + const cacheKey = "music-app:songs:for-you"; + const songs = await CacheManager.getOrFetch(cacheKey, 1800, async () => { + return await getRandomSongs(4); + }); + res.status(200).json({ success: true, message: "Songs for you fetched successfully", @@ -112,7 +103,11 @@ const getSongsForYou = async (req, res, next) => { const getTrendingSongs = async (req, res, next) => { try { - const songs = await getRandomSongs(4); + const cacheKey = "music-app:songs:trending"; + const songs = await CacheManager.getOrFetch(cacheKey, 1800, async () => { + return await getRandomSongs(4); + }); + res.status(200).json({ success: true, message: "Trending songs fetched successfully", @@ -193,11 +188,7 @@ const deleteSong = async (req, res, next) => { // Actually, the user rules say "everyone can upload", but usually we want some restriction on deletion. // I'll allow the creator to delete. if (song.creator && song.creator !== userId) { - // We can check admin status here too if we want to allow admins to delete anything - const { clerkClient } = require('@clerk/express'); - const currentUser = await clerkClient.users.getUser(userId); - const primaryEmail = currentUser?.primaryEmailAddress?.emailAddress; - const isAdmin = process.env.ADMIN_EMAIL && primaryEmail && process.env.ADMIN_EMAIL === primaryEmail; + const isAdmin = await isAdminUser(userId); if (!isAdmin) { return res.status(403).json({ message: "Unauthorized to delete this song" }); @@ -265,7 +256,7 @@ const updateSong = async (req, res, next) => { }; module.exports = { - getAllSongs, + getSongs, getFeaturedSongs, getSongsForYou, getTrendingSongs, diff --git a/backend/src/controllers/stats.controller.js b/backend/src/controllers/stats.controller.js index 0561f9d..81283d1 100644 --- a/backend/src/controllers/stats.controller.js +++ b/backend/src/controllers/stats.controller.js @@ -1,40 +1,48 @@ const Song = require("../models/song.model"); const User = require("../models/user.model"); const Album = require("../models/album.model"); +const CacheManager = require("../lib/cacheManager"); const getStats = async (req, res, next) => { try { - const filter = {}; - if (req.query.user === "true") { - filter.creator = req.auth.userId; - } - - const [totalSongs, totalUsers, totalAlbums, uniqueArtists] = await Promise.all([ - Song.countDocuments(filter), - User.countDocuments(), - Album.countDocuments(filter), - Song.aggregate([ - { $match: filter }, - { - $group: { - _id: "$artist", + const isUserOnly = req.query.user === "true"; + const cacheKey = isUserOnly ? `music-app:stats:${req.auth.userId}` : "music-app:stats:global"; + + const stats = await CacheManager.getOrFetch(cacheKey, 600, async () => { + const filter = {}; + if (isUserOnly) { + filter.creator = req.auth.userId; + } + + const [totalSongs, totalUsers, totalAlbums, uniqueArtists] = await Promise.all([ + Song.countDocuments(filter), + User.countDocuments(), + Album.countDocuments(filter), + Song.aggregate([ + { $match: filter }, + { + $group: { + _id: "$artist", + }, }, - }, - { - $count: "count", - }, - ]), - ]); - - res.status(200).json({ - totalAlbums, - totalSongs, - totalUsers, - totalArtists: uniqueArtists[0]?.count || 0, + { + $count: "count", + }, + ]), + ]); + + return { + totalAlbums, + totalSongs, + totalUsers, + totalArtists: uniqueArtists[0]?.count || 0, + }; }); + res.status(200).json(stats); + } catch (error) { } diff --git a/backend/src/lib/cacheManager.js b/backend/src/lib/cacheManager.js new file mode 100644 index 0000000..3616f3f --- /dev/null +++ b/backend/src/lib/cacheManager.js @@ -0,0 +1,57 @@ +const redisClient = require("./redis"); + +/** + * Senior-level Cache Manager with Graceful Degradation + */ +const CacheManager = { + /** + * getOrFetch handles errors by falling back to the database (fetcher) + */ + async getOrFetch(key, ttl, fetcher) { + try { + const cached = await redisClient.get(key); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.error(`Cache Read Error [${key}]:`, err); + // Fall through to fetcher + } + + const data = await fetcher(); + + try { + await redisClient.setEx(key, ttl, JSON.stringify(data)); + } catch (err) { + console.error(`Cache Write Error [${key}]:`, err); + // Non-blocking error + } + + return data; + }, + + /** + * Purges keys using wildcards (Note: keys() is O(N), use sparingly) + */ + async purgePattern(pattern) { + try { + const keys = await redisClient.keys(pattern); + if (keys.length > 0) { + await redisClient.del(keys); + console.log(`Purged ${keys.length} keys matching ${pattern}`); + } + } catch (err) { + console.error(`Cache Purge Error [${pattern}]:`, err); + } + }, + + async del(keys) { + try { + await redisClient.del(keys); + } catch (err) { + console.error(`Cache Del Error:`, err); + } + } +}; + +module.exports = CacheManager; diff --git a/backend/src/lib/cloudinaryHelper.js b/backend/src/lib/cloudinaryHelper.js new file mode 100644 index 0000000..7e275de --- /dev/null +++ b/backend/src/lib/cloudinaryHelper.js @@ -0,0 +1,24 @@ +const cloudinary = require("./cloudinary"); + +const getPublicId = (url) => { + if (!url) return null; + const parts = url.split("/"); + const fileName = parts.pop(); + const publicId = fileName.split(".")[0]; + return publicId; +}; + +const uploadToCloudinary = async (file, folder = "music-app") => { + try { + const result = await cloudinary.uploader.upload(file.tempFilePath, { + resource_type: "auto", + folder: folder, + }); + return result.secure_url; + } catch (err) { + console.log("Error in uploadToCloudinary:", err); + throw new Error(err.message || "Cloudinary upload failed"); + } +}; + +module.exports = { getPublicId, uploadToCloudinary }; diff --git a/backend/src/lib/redis.js b/backend/src/lib/redis.js new file mode 100644 index 0000000..757faec --- /dev/null +++ b/backend/src/lib/redis.js @@ -0,0 +1,21 @@ +const { createClient } = require('redis'); + +const redisClient = createClient({ + url: process.env.REDIS_URL +}); + +redisClient.on('error', (err) => console.log('Redis Client Error', err)); + +// Immediate connection for a long-running process +if (process.env.NODE_ENV !== 'test') { + (async () => { + try { + await redisClient.connect(); + console.log('Connected to Redis Cloud successfully'); + } catch (err) { + console.error('Failed to connect to Redis:', err); + } + })(); +} + +module.exports = redisClient; diff --git a/backend/src/middleware/auth.middleware.js b/backend/src/middleware/auth.middleware.js index 7a64160..1baa4de 100644 --- a/backend/src/middleware/auth.middleware.js +++ b/backend/src/middleware/auth.middleware.js @@ -9,15 +9,24 @@ const protectRoute = async (req, res, next) => { return next(); } +const isAdminUser = async (userId) => { + try { + const currentUser = await clerkClient.users.getUser(userId); + const primaryEmail = currentUser?.primaryEmailAddress?.emailAddress; + return process.env.ADMIN_EMAIL && primaryEmail && process.env.ADMIN_EMAIL === primaryEmail; + } catch (err) { + console.error('isAdminUser error:', err?.message || err); + return false; + } +}; + const requireAdmin = async (req, res, next) => { try { if (!req.auth || !req.auth.userId) { return res.status(401).json({ message: 'unauthorized - missing user id' }); } - const currentUser = await clerkClient.users.getUser(req.auth.userId); - const primaryEmail = currentUser?.primaryEmailAddress?.emailAddress; - const isAdmin = process.env.ADMIN_EMAIL && primaryEmail && process.env.ADMIN_EMAIL === primaryEmail; + const isAdmin = await isAdminUser(req.auth.userId); if (!isAdmin) { return res.status(403).json({ @@ -32,4 +41,4 @@ const requireAdmin = async (req, res, next) => { } } -module.exports = { protectRoute, requireAdmin }; \ No newline at end of file +module.exports = { protectRoute, requireAdmin, isAdminUser }; \ No newline at end of file diff --git a/backend/src/models/song.model.js b/backend/src/models/song.model.js index b190481..8e94c62 100644 --- a/backend/src/models/song.model.js +++ b/backend/src/models/song.model.js @@ -28,7 +28,7 @@ const songSchema = new mongoose.Schema({ }, creator: { type: String, // clerkId - required: false, // Optional for existing seeded data + required: true, }, },{timestamps: true}); diff --git a/backend/src/routes/album.route.js b/backend/src/routes/album.route.js index 56303ec..5e2a813 100644 --- a/backend/src/routes/album.route.js +++ b/backend/src/routes/album.route.js @@ -1,12 +1,12 @@ const express = require('express'); -const { AllAlbums, AllAlbumsById, createAlbum, deleteAlbum, updateAlbum } = require("../controllers/album.controller"); +const { getAlbums, getAlbumById, createAlbum, deleteAlbum, updateAlbum } = require("../controllers/album.controller"); const { protectRoute } = require("../middleware/auth.middleware"); const router = express.Router(); // Sample route to get all users // Protected route to fetch albums; supports filtering by ?user=true -router.get('/', protectRoute, AllAlbums); -router.get('/:albumId', AllAlbumsById); +router.get('/', protectRoute, getAlbums); +router.get('/:albumId', getAlbumById); router.post("/", protectRoute, createAlbum); router.put("/:id", protectRoute, updateAlbum); router.delete("/:id", protectRoute, deleteAlbum); diff --git a/backend/src/routes/songs.route.js b/backend/src/routes/songs.route.js index dc5c90f..35fadf0 100644 --- a/backend/src/routes/songs.route.js +++ b/backend/src/routes/songs.route.js @@ -1,10 +1,10 @@ const express = require('express'); -const { getAllSongs, getSongsForYou, getFeaturedSongs, getTrendingSongs, getSingleSong, createSong, deleteSong, updateSong } = require('../controllers/song.controller'); +const { getSongs, getSongsForYou, getFeaturedSongs, getTrendingSongs, getSingleSong, createSong, deleteSong, updateSong } = require('../controllers/song.controller'); const { protectRoute } = require('../middleware/auth.middleware'); const router = express.Router(); // Sample route to get all users -router.get('/', protectRoute, getAllSongs); +router.get('/', protectRoute, getSongs); router.get('/featured', getFeaturedSongs); router.get('/made-for-you', getSongsForYou); router.get('/trending', getTrendingSongs); diff --git a/backend/src/seeds/migrate_song_creators.js b/backend/src/seeds/migrate_song_creators.js new file mode 100644 index 0000000..5e76e58 --- /dev/null +++ b/backend/src/seeds/migrate_song_creators.js @@ -0,0 +1,36 @@ +const mongoose = require("mongoose"); +const Song = require("../models/song.model"); +const Album = require("../models/album.model"); +require("dotenv").config(); + +const SYSTEM_ADMIN_ID = "system_seed_admin"; + +const runMigration = async () => { + try { + console.log("Connecting to MongoDB..."); + await mongoose.connect(process.env.MONGO_URL); + console.log("Connected successfully."); + + console.log("Starting migration: Backfilling 'creator' on legacy Songs..."); + const songResult = await Song.updateMany( + { $or: [{ creator: { $exists: false } }, { creator: null }] }, + { $set: { creator: SYSTEM_ADMIN_ID } } + ); + console.log(`Updated ${songResult.modifiedCount} legacy songs with creator ID: ${SYSTEM_ADMIN_ID}`); + + console.log("Starting migration: Backfilling 'creator' on legacy Albums..."); + const albumResult = await Album.updateMany( + { $or: [{ creator: { $exists: false } }, { creator: null }] }, + { $set: { creator: SYSTEM_ADMIN_ID } } + ); + console.log(`Updated ${albumResult.modifiedCount} legacy albums with creator ID: ${SYSTEM_ADMIN_ID}`); + + console.log("Migration completed successfully!"); + } catch (error) { + console.error("Error running migration:", error); + } finally { + mongoose.connection.close(); + } +}; + +runMigration(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 905b71e..af39e53 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.555.0", + "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", @@ -1065,6 +1066,106 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1088,6 +1189,29 @@ } } }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-avatar": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", @@ -1153,6 +1277,66 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -1227,6 +1411,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -1323,6 +1535,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -1363,40 +1604,49 @@ } } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -1413,20 +1663,338 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -1461,13 +2029,55 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1484,21 +2094,35 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, @@ -1625,6 +2249,29 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slider": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", @@ -1676,6 +2323,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -1706,6 +2382,123 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -5439,6 +6232,128 @@ "node": ">=6" } }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 46a47d1..fa7c3bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.555.0", + "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index 6cb4933..297b82a 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -1,15 +1,15 @@ -import { useEffect, useState } from 'react' -import { Outlet } from 'react-router-dom' +import { useEffect, useState } from "react"; +import { Outlet } from "react-router-dom"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, -} from "@/components/ui/resizable" -import LeftSidebar from './components/LeftSidebar' -import FriendsActivity from './components/FriendsActivity' -import AuidoPlayer from './components/AuidoPlayer' -import PlaybackControls from './components/PlaybackControls' -import TopHeader from './components/TopHeader' +} from "@/components/ui/resizable"; +import LeftSidebar from "./components/LeftSidebar"; +import FriendsActivity from "./components/FriendsActivity"; +import AuidoPlayer from "./components/AuidoPlayer"; +import PlaybackControls from "./components/PlaybackControls"; +import TopHeader from "./components/TopHeader"; const MainLayout = () => { const [isMobile, setIsMobile] = useState(false); @@ -24,9 +24,8 @@ const MainLayout = () => { return () => window.removeEventListener("resize", checkMobile); }, []); - return ( -
+
{/* */} @@ -37,34 +36,43 @@ const MainLayout = () => { {/* LSB */} - + {/* Main */} -
{/* Added container with controlled scrolling */} +
+ {" "} + {/* Added container with controlled scrolling */}
- { - !isMobile && ( - <> - - {/* Right Hand Side */} - - - - - ) - } + {!isMobile && ( + <> + + {/* Right Hand Side */} + + + + + )}
- ) -} + ); +}; -export default MainLayout \ No newline at end of file +export default MainLayout; diff --git a/frontend/src/components/layout/components/FriendsActivity.tsx b/frontend/src/components/layout/components/FriendsActivity.tsx index 62e69ed..7b604cf 100644 --- a/frontend/src/components/layout/components/FriendsActivity.tsx +++ b/frontend/src/components/layout/components/FriendsActivity.tsx @@ -50,7 +50,7 @@ const FriendsActivity = () => { onClick={() => handleUserClick(user)} className='cursor-pointer hover:bg-zinc-800/50 p-3 rounded-md transition-colors group' > -
+
@@ -65,13 +65,13 @@ const FriendsActivity = () => {
-
- {user.fullName} +
+ {user.fullName} {isPlaying && }
{isPlaying ? ( -
+
{activity.replace("Playing ", "").split(" by ")[0]}
diff --git a/frontend/src/components/layout/components/LeftSidebar.jsx b/frontend/src/components/layout/components/LeftSidebar.jsx index 8734613..9859ff4 100644 --- a/frontend/src/components/layout/components/LeftSidebar.jsx +++ b/frontend/src/components/layout/components/LeftSidebar.jsx @@ -6,7 +6,7 @@ import { useEffect } from 'react' import { Link } from 'react-router-dom' import { ScrollArea } from "@/components/ui/scroll-area" import PlaylistSkeleton from '../../skeletons/PlaylistSkeleton.jsx' -import { useMusicStore } from '../../../store/useMusicStore.js' +import { useAlbumStore } from '../../../store/useAlbumStore' import { usePlaylistStore } from '../../../store/usePlaylistStore' import { Plus } from 'lucide-react' import CreatePlaylistDialog from './CreatePlaylistDialog' @@ -21,7 +21,7 @@ const LeftSidebar = () => { // const [playlists, SetPlaylists] = useState([]); // useMusicStore - const { albums, isLoading: isMusicLoading, fetchAlbums } = useMusicStore(); + const { albums, isLoading: isMusicLoading, fetchAlbums } = useAlbumStore(); const { playlists, isLoading: isPlaylistLoading, fetchPlaylists } = usePlaylistStore(); useEffect(() => { diff --git a/frontend/src/components/layout/components/PlaybackControls.tsx b/frontend/src/components/layout/components/PlaybackControls.tsx index 5d2f751..3199a00 100644 --- a/frontend/src/components/layout/components/PlaybackControls.tsx +++ b/frontend/src/components/layout/components/PlaybackControls.tsx @@ -16,9 +16,10 @@ const PlaybackControls = () => { const { currentSong, isPlaying, togglePlay, playNext, playPrevious, toggleRepeat, repeatMode, isShuffled, toggleShuffle } = usePlayerStore(); const [volume, setVolume] = useState(75); + const [prevVolume, setPrevVolume] = useState(75); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); - const audioRef = useRef(null) + const audioRef = useRef(null); useEffect(() => { audioRef.current = document.querySelector("audio"); @@ -44,6 +45,45 @@ const PlaybackControls = () => { } }, [currentSong]); + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if user is typing in an input + if (document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA") { + return; + } + + switch (e.code) { + case "Space": + e.preventDefault(); + if (currentSong) togglePlay(); + break; + case "ArrowRight": + e.preventDefault(); + if (currentSong) playNext(); + break; + case "ArrowLeft": + e.preventDefault(); + if (currentSong) playPrevious(); + break; + case "KeyM": + e.preventDefault(); + if (volume > 0) { + setPrevVolume(volume); + setVolume(0); + if (audioRef.current) audioRef.current.volume = 0; + } else { + setVolume(prevVolume || 75); + if (audioRef.current) audioRef.current.volume = (prevVolume || 75) / 100; + } + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [currentSong, togglePlay, playNext, playPrevious, volume, prevVolume]); + const handleSeek = (value: number[]) => { if (audioRef.current) { @@ -62,9 +102,11 @@ const PlaybackControls = () => { alt={currentSong.title} className='w-14 h-14 object-cover rounded-md' /> -
-
- {currentSong.title} +
+
+
+ {currentSong.title} +
{currentSong.artist} diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..7e6c036 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,194 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..8f19ceb --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/frontend/src/index.css b/frontend/src/index.css index 101cdb5..b098244 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -121,4 +121,19 @@ body { @apply bg-background text-foreground; } +} + +@keyframes marquee { + 0% { transform: translateX(0%); } + 100% { transform: translateX(-100%); } +} + +.animate-marquee { + display: inline-block; + white-space: nowrap; + animation: marquee 10s linear infinite; +} + +.animate-marquee:hover { + animation-play-state: paused; } \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c4c508b..acaee3a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -29,7 +29,28 @@ createRoot(document.getElementById('root')!).render( An error has occurred
} showDialog> diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx index 6f03f27..d34dec7 100644 --- a/frontend/src/pages/admin/AdminPage.tsx +++ b/frontend/src/pages/admin/AdminPage.tsx @@ -6,12 +6,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Album, Music } from 'lucide-react'; import SongsTabContent from './components/SongsTabContent'; import AlbumsTabContent from './components/AlbumsTabContent'; -import { useMusicStore } from '@/store/useMusicStore'; +import { useSongStore } from '@/store/useSongStore'; +import { useAlbumStore } from '@/store/useAlbumStore'; +import { useStatsStore } from '@/store/useStatsStore'; const AdminPage = () => { const { isLoading } = useAuthStore(); - const { fetchStats, fetchSongs, fetchAlbums } = useMusicStore(); + const { fetchStats } = useStatsStore(); + const { fetchSongs } = useSongStore(); + const { fetchAlbums } = useAlbumStore(); useEffect(() => { fetchStats(true); fetchSongs(true); @@ -20,8 +24,8 @@ const AdminPage = () => { if (isLoading) return
Loading...
return ( -
+
diff --git a/frontend/src/pages/admin/components/AddAlbumDialog.tsx b/frontend/src/pages/admin/components/AddAlbumDialog.tsx index 433f6c5..b41bd21 100644 --- a/frontend/src/pages/admin/components/AddAlbumDialog.tsx +++ b/frontend/src/pages/admin/components/AddAlbumDialog.tsx @@ -12,13 +12,14 @@ import { Input } from "@/components/ui/input"; import { Plus, Upload } from "lucide-react"; import { useRef, useState } from "react"; import toast from "react-hot-toast"; -import { useMusicStore } from "@/store/useMusicStore"; +import { useAlbumStore } from "@/store/useAlbumStore"; import { axiosInstance } from "@/lib/axios"; const AddAlbumDialog = () => { const [albumDialogOpen, setAlbumDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const { fetchAlbums } = useMusicStore(); + const [isDragging, setIsDragging] = useState(false); + const { fetchAlbums } = useAlbumStore(); const fileInputRef = useRef(null); const [newAlbum, setNewAlbum] = useState({ @@ -98,14 +99,27 @@ const AddAlbumDialog = () => { className='hidden' />
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files && e.dataTransfer.files[0] && e.dataTransfer.files[0].type.startsWith("image/")) { + setImageFile(e.dataTransfer.files[0]); + } else { + toast.error("Please drop an image file."); + } + }} >
-
+
{imageFile ? imageFile.name : "Upload album artwork"}
+
audioInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setIsDraggingAudio(true); }} + onDragLeave={() => setIsDraggingAudio(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDraggingAudio(false); + if (e.dataTransfer.files && e.dataTransfer.files[0] && e.dataTransfer.files[0].type.startsWith("audio/")) { + handleAudioSelect(e.dataTransfer.files[0]); + } else { + toast.error("Please drop an audio file."); + } + }} + > + {files.audio ? ( +
+ {files.audio.name} +
+ ) : ( +
+ + Click or drag audio file here +
+ )}
diff --git a/frontend/src/pages/admin/components/AlbumsTabContent.tsx b/frontend/src/pages/admin/components/AlbumsTabContent.tsx index 4f74da5..bd1455e 100644 --- a/frontend/src/pages/admin/components/AlbumsTabContent.tsx +++ b/frontend/src/pages/admin/components/AlbumsTabContent.tsx @@ -4,11 +4,11 @@ import AddAlbumDialog from "./AddAlbumDialog"; import AlbumsTable from "./AlbumsTable"; -import { useMusicStore } from "@/store/useMusicStore"; +import { useAlbumStore } from "@/store/useAlbumStore"; import NoContent from "./NoContent"; const AlbumsTabContent = () => { - const { albums, isLoading } = useMusicStore(); + const { albums, isLoading } = useAlbumStore(); return ( diff --git a/frontend/src/pages/admin/components/AlbumsTable.tsx b/frontend/src/pages/admin/components/AlbumsTable.tsx index 0f9e0dc..7894b0e 100644 --- a/frontend/src/pages/admin/components/AlbumsTable.tsx +++ b/frontend/src/pages/admin/components/AlbumsTable.tsx @@ -1,13 +1,35 @@ +import { Calendar, Trash2, Search, Library } from "lucide-react"; +import EditAlbumDialog from "./EditAlbumDialog"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { useMusicStore } from "@/store/useMusicStore"; -import { Calendar, Music, Trash2 } from "lucide-react"; -import EditAlbumDialog from "./EditAlbumDialog"; +import { useAlbumStore } from "@/store/useAlbumStore"; +import { memo } from "react"; +import { Music } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; -const AlbumsTable = () => { - const { albums, deleteAlbum, isLoading } = useMusicStore(); - - if (isLoading) { +const AlbumsTable = memo(() => { + const { albums, deleteAlbum, isLoading } = useAlbumStore(); + const [searchTerm, setSearchTerm] = useState(""); + + // Filter albums based on search + const filteredAlbums = albums.filter(album => + album.title.toLowerCase().includes(searchTerm.toLowerCase()) || + album.artist.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (isLoading) { return (
Loading albums...
@@ -17,28 +39,50 @@ const AlbumsTable = () => { // useEffect removed as parent AdminPage handles fetching with proper filtering - return ( - - - - - Title - Artist - Release Year - Songs - Actions - - - - {albums.length === 0 ? ( - - - No albums found. Create your first album! - - - ) : ( - albums.map((album) => ( - + return ( +
+
+ + setSearchTerm(e.target.value)} + className="max-w-sm bg-zinc-800/50 border-zinc-700/50" + /> +
+ +
+
+ + + + Title + Artist + Release Year + Songs + Actions + + + + {filteredAlbums.length === 0 ? ( + + +
+
+ +
+
+

No albums found

+

+ {searchTerm ? "No albums match your search query." : "Create your first collection to get started!"} +

+
+
+
+
+ ) : ( + filteredAlbums.map((album) => ( + {album.title} @@ -59,21 +103,44 @@ const AlbumsTable = () => {
- -
-
-
- )) - )} -
-
- ); -}; + + + + + + + Are you absolutely sure? + + This will permanently delete "{album.title}" and all its associated songs. This action cannot be undone. + + + + Cancel + deleteAlbum(album._id)} + className="bg-red-500 hover:bg-red-600 text-white" + > + Yes, Delete + + + + +
+ + + )) + )} + + +
+
+ ); +}); + export default AlbumsTable; \ No newline at end of file diff --git a/frontend/src/pages/admin/components/DashboardStats.tsx b/frontend/src/pages/admin/components/DashboardStats.tsx index 20cfb2b..0245388 100644 --- a/frontend/src/pages/admin/components/DashboardStats.tsx +++ b/frontend/src/pages/admin/components/DashboardStats.tsx @@ -1,10 +1,10 @@ -import { useMusicStore } from '@/store/useMusicStore'; +import { useStatsStore } from '@/store/useStatsStore'; import StatsCard from './StatsCard'; import { Library, ListMusic, Users2 } from "lucide-react"; const DashboardStats = () => { - const { stats, isLoading } = useMusicStore(); + const { stats, isStatsLoading: isLoading } = useStatsStore(); if (isLoading || !stats) { return ( diff --git a/frontend/src/pages/admin/components/EditAlbumDialog.tsx b/frontend/src/pages/admin/components/EditAlbumDialog.tsx index 394ddb9..b29e83b 100644 --- a/frontend/src/pages/admin/components/EditAlbumDialog.tsx +++ b/frontend/src/pages/admin/components/EditAlbumDialog.tsx @@ -9,7 +9,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { useMusicStore } from "@/store/useMusicStore"; +import { useAlbumStore } from "@/store/useAlbumStore"; import { Edit, Upload } from "lucide-react"; import { useState, useRef } from "react"; import toast from "react-hot-toast"; @@ -20,7 +20,7 @@ interface EditAlbumDialogProps { } const EditAlbumDialog = ({ album }: EditAlbumDialogProps) => { - const { isLoading, updateAlbum } = useMusicStore(); + const { isLoading, updateAlbum } = useAlbumStore(); const [open, setOpen] = useState(false); const [formData, setFormData] = useState({ title: album.title, diff --git a/frontend/src/pages/admin/components/EditSongDialog.tsx b/frontend/src/pages/admin/components/EditSongDialog.tsx index 1d6f7eb..5a08c9c 100644 --- a/frontend/src/pages/admin/components/EditSongDialog.tsx +++ b/frontend/src/pages/admin/components/EditSongDialog.tsx @@ -10,7 +10,8 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { useMusicStore } from "@/store/useMusicStore"; +import { useSongStore } from "@/store/useSongStore"; +import { useAlbumStore } from "@/store/useAlbumStore"; import { Edit, Upload } from "lucide-react"; import { useState, useRef } from "react"; import toast from "react-hot-toast"; @@ -21,7 +22,8 @@ interface EditSongDialogProps { } const EditSongDialog = ({ song }: EditSongDialogProps) => { - const { albums, isLoading, updateSong } = useMusicStore(); + const { isLoading, updateSong } = useSongStore(); + const { albums } = useAlbumStore(); const [open, setOpen] = useState(false); const [formData, setFormData] = useState({ title: song.title, diff --git a/frontend/src/pages/admin/components/Header.tsx b/frontend/src/pages/admin/components/Header.tsx index 1cf956d..1ec0576 100644 --- a/frontend/src/pages/admin/components/Header.tsx +++ b/frontend/src/pages/admin/components/Header.tsx @@ -3,14 +3,14 @@ import { Link } from "react-router-dom"; const Header = () => { return ( -
-
+
+

Music Manager

-

Manage your music catalog

+

Manage your music catalog

diff --git a/frontend/src/pages/admin/components/SongsTabContent.tsx b/frontend/src/pages/admin/components/SongsTabContent.tsx index 6927f81..005d022 100644 --- a/frontend/src/pages/admin/components/SongsTabContent.tsx +++ b/frontend/src/pages/admin/components/SongsTabContent.tsx @@ -4,11 +4,11 @@ import AddSongDialog from "./AddSongDialog"; import SongsTable from "./SongsTable"; -import { useMusicStore } from "@/store/useMusicStore"; +import { useSongStore } from "@/store/useSongStore"; import NoContent from "./NoContent"; const SongsTabContent = () => { - const { songs, isSongsLoading } = useMusicStore(); + const { songs, isSongsLoading } = useSongStore(); return ( diff --git a/frontend/src/pages/admin/components/SongsTable.tsx b/frontend/src/pages/admin/components/SongsTable.tsx index 945e64c..4853112 100644 --- a/frontend/src/pages/admin/components/SongsTable.tsx +++ b/frontend/src/pages/admin/components/SongsTable.tsx @@ -1,13 +1,34 @@ -import { Button } from "@/components/ui/button"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { useMusicStore } from "@/store/useMusicStore"; -import { Calendar, Trash2 } from "lucide-react"; +import { Calendar, Trash2, Search, Music } from "lucide-react"; import AddToPlaylistDialog from "@/components/playlist/AddToPlaylistDialog"; import EditSongDialog from "./EditSongDialog"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { useSongStore } from "@/store/useSongStore"; +import { memo } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; + +const SongsTable = memo(() => { + const { songs, isSongsLoading, error, deleteSong } = useSongStore(); + const [searchTerm, setSearchTerm] = useState(""); + + // Filter songs based on search + const filteredSongs = songs.filter(song => + song.title.toLowerCase().includes(searchTerm.toLowerCase()) || + song.artist.toLowerCase().includes(searchTerm.toLowerCase()) + ); -const SongsTable = () => { - const { songs, isSongsLoading, error, deleteSong } = useMusicStore(); - // const deleteSong = false; if (isSongsLoading) { return (
@@ -25,27 +46,49 @@ const SongsTable = () => { } return ( - - - - - Title - Artist - Release Date - Actions - - +
+
+ + setSearchTerm(e.target.value)} + className="max-w-sm bg-zinc-800/50 border-zinc-700/50" + /> +
+ +
+
+ + + + Title + Artist + Release Date + Actions + + - - {songs.length === 0 ? ( - - - No music found. Start by uploading some! - - - ) : ( - songs.map((song) => ( - + + {filteredSongs.length === 0 ? ( + + +
+
+ +
+
+

No songs found

+

+ {searchTerm ? "No songs match your search query." : "Upload your first song to get started!"} +

+
+
+
+
+ ) : ( + filteredSongs.map((song) => ( + {song.title} @@ -62,21 +105,44 @@ const SongsTable = () => {
- + + + + + + + Are you absolutely sure? + + This will permanently delete "{song.title}" from the database. This action cannot be undone. + + + + Cancel + deleteSong(song._id)} + className="bg-red-500 hover:bg-red-600 text-white" + > + Yes, Delete + + + +
)) )} -
-
+ + +
+
); -}; +}); + export default SongsTable; \ No newline at end of file diff --git a/frontend/src/pages/album/AlbumPage.tsx b/frontend/src/pages/album/AlbumPage.tsx index 5cc7e18..c57bc70 100644 --- a/frontend/src/pages/album/AlbumPage.tsx +++ b/frontend/src/pages/album/AlbumPage.tsx @@ -1,198 +1,256 @@ -import { useMusicStore } from '@/store/useMusicStore'; -import { useEffect } from 'react' -import { useParams } from 'react-router-dom' -import { ScrollArea } from "@/components/ui/scroll-area" -import { Button } from '@/components/ui/button'; -import { Clock, Pause, Play } from 'lucide-react'; -import { usePlayerStore } from '@/store/usePlayerStore'; -import AddToPlaylistDialog from '@/components/playlist/AddToPlaylistDialog'; - +import { useAlbumStore } from "@/store/useAlbumStore"; +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; +import { Clock, Pause, Play } from "lucide-react"; +import { usePlayerStore } from "@/store/usePlayerStore"; +import AddToPlaylistDialog from "@/components/playlist/AddToPlaylistDialog"; +import { Skeleton } from "@/components/ui/skeleton"; const ALBUM_THEMES = [ - { name: "Emerald", from: "from-emerald-900/80" }, - { name: "Royal Blue", from: "from-blue-900/80" }, - { name: "Deep Ruby", from: "from-rose-900/80" }, - { name: "Golden Amber", from: "from-amber-900/80" }, - { name: "Midnight Purple", from: "from-violet-900/80" }, - { name: "Deep Forest", from: "from-teal-900/80" }, - { name: "Crimson Red", from: "from-red-900/80" }, - { name: "Midnight Indigo", from: "from-indigo-900/80" }, - { name: "Luxury Pink", from: "from-pink-900/80" }, - { name: "Royal Gold", from: "from-yellow-900/80" }, - { name: "Ocean Deep", from: "from-cyan-900/80" }, - { name: "Deep Mint", from: "from-green-900/80" }, - { name: "Desert Sand", from: "from-orange-900/80" }, - { name: "Nordic Frost", from: "from-sky-900/80" }, - { name: "Mystic Plum", from: "from-fuchsia-900/80" }, - { name: "Deep Ochre", from: "from-amber-800/80" }, - { name: "Velvet Grape", from: "from-purple-900/80" }, + { name: "Emerald", from: "from-emerald-900/80" }, + { name: "Royal Blue", from: "from-blue-900/80" }, + { name: "Deep Ruby", from: "from-rose-900/80" }, + { name: "Golden Amber", from: "from-amber-900/80" }, + { name: "Midnight Purple", from: "from-violet-900/80" }, + { name: "Deep Forest", from: "from-teal-900/80" }, + { name: "Crimson Red", from: "from-red-900/80" }, + { name: "Midnight Indigo", from: "from-indigo-900/80" }, + { name: "Luxury Pink", from: "from-pink-900/80" }, + { name: "Royal Gold", from: "from-yellow-900/80" }, + { name: "Ocean Deep", from: "from-cyan-900/80" }, + { name: "Deep Mint", from: "from-green-900/80" }, + { name: "Desert Sand", from: "from-orange-900/80" }, + { name: "Nordic Frost", from: "from-sky-900/80" }, + { name: "Mystic Plum", from: "from-fuchsia-900/80" }, + { name: "Deep Ochre", from: "from-amber-800/80" }, + { name: "Velvet Grape", from: "from-purple-900/80" }, ]; const getAlbumTheme = (id: string) => { - if (!id) return ALBUM_THEMES[0]; - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = id.charCodeAt(i) + ((hash << 5) - hash); - } - const index = Math.abs(hash) % ALBUM_THEMES.length; - return ALBUM_THEMES[index]; + if (!id) return ALBUM_THEMES[0]; + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = id.charCodeAt(i) + ((hash << 5) - hash); + } + const index = Math.abs(hash) % ALBUM_THEMES.length; + return ALBUM_THEMES[index]; }; const AlbumPage = () => { + // const isPlaying = false; + const { albumId } = useParams(); + const { isLoading, fetchAlbumById, currentAlbum } = useAlbumStore(); + const { currentSong, isPlaying, playAlbum, togglePlay } = usePlayerStore(); + useEffect(() => { + if (albumId) fetchAlbumById(albumId); + }, [fetchAlbumById, albumId]); - // const isPlaying = false; - - const { albumId } = useParams(); - const { isLoading, fetchAlbumById, currentAlbum } = useMusicStore(); - const { currentSong, isPlaying, playAlbum, togglePlay } = usePlayerStore() - useEffect(() => { - if (albumId) fetchAlbumById(albumId) - }, [fetchAlbumById, albumId]); - - const formatDuration = (seconds: number) => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - - return `${minutes} : ${remainingSeconds.toString().padStart(2, "0")} `; - } - - + const formatDuration = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; - if (isLoading) return null + return `${minutes} : ${remainingSeconds.toString().padStart(2, "0")} `; + }; - const handlePlayAlbum = () => { - if (!currentAlbum) return; + if (isLoading) { + return ( +
+ +
+
+
+
+ +
+ + + +
+
+
+ +
+
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ +
+ +
+ + +
+
+ + + +
+ ))} +
+
+
+
+
+
+ ); + } - const isCurrentAlbumPlaying = currentAlbum?.songs.some((song) => song._id === currentSong?._id); - if (isCurrentAlbumPlaying) togglePlay(); - else { - // start playing the album from the beginning - playAlbum(currentAlbum?.songs, 0); - } - }; + const handlePlayAlbum = () => { + if (!currentAlbum) return; - const handlePlaySong = (index: number) => { - if (!currentAlbum) return - playAlbum(currentAlbum?.songs, index) + const isCurrentAlbumPlaying = currentAlbum?.songs.some( + (song) => song._id === currentSong?._id, + ); + if (isCurrentAlbumPlaying) togglePlay(); + else { + // start playing the album from the beginning + playAlbum(currentAlbum?.songs, 0); } - const albumTheme = getAlbumTheme(albumId || ""); + }; - return ( -
- -
-