Changeset 9c1dcc7
- Timestamp:
- 02/07/26 19:52:09 (5 months ago)
- Branches:
- main
- Children:
- 8675f75
- Parents:
- 8a47b30
- Files:
-
- 2 added
- 10 edited
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/controller/SongController.java (modified) (2 diffs)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/repository/SongRepository.java (modified) (4 diffs)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/service/SongService.java (modified) (1 diff)
-
frontend/src/App.tsx (modified) (3 diffs)
-
frontend/src/components/MiniPlayer.tsx (added)
-
frontend/src/components/Sidebar.tsx (modified) (4 diffs)
-
frontend/src/components/search/SongResult.tsx (modified) (3 diffs)
-
frontend/src/context/playerContext.tsx (added)
-
frontend/src/main.tsx (modified) (2 diffs)
-
frontend/src/pages/LandingPage.tsx (modified) (5 diffs)
-
frontend/src/pages/SongDetail.tsx (modified) (5 diffs)
-
frontend/src/utils/types.ts (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/controller/SongController.java
r8a47b30 r9c1dcc7 4 4 import com.ukim.finki.develop.finkwave.model.dto.MusicalEntityDto; 5 5 import com.ukim.finki.develop.finkwave.model.dto.SongDetailsDto; 6 import com.ukim.finki.develop.finkwave.model.dto.SongDto; 6 7 import com.ukim.finki.develop.finkwave.repository.SongRepository; 7 8 import com.ukim.finki.develop.finkwave.service.AuthService; … … 22 23 23 24 @GetMapping("/top") 24 public HttpEntity<List< MusicalEntityDto>> getSongs(){25 public HttpEntity<List<SongDto>> getSongs(){ 25 26 return ResponseEntity.ok(songService.getTopSongs()); 26 27 } -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/repository/SongRepository.java
r8a47b30 r9c1dcc7 7 7 import org.springframework.data.jpa.repository.Query; 8 8 import org.springframework.data.repository.query.Param; 9 import org.springframework.security.core.parameters.P;10 9 import org.springframework.stereotype.Repository; 11 10 … … 50 49 51 50 @Query(""" 52 SELECT NEW com.ukim.finki.develop.finkwave.model.dto. MusicalEntityDto(51 SELECT NEW com.ukim.finki.develop.finkwave.model.dto.SongDto( 53 52 s.id, 54 53 s.musicalEntities.title, … … 58 57 s.musicalEntities.cover, 59 58 (EXISTS (SELECT 1 FROM Like l WHERE l.musicalEntity.id = s.id AND l.listener.id = :currentUserId)), 60 s.album.musicalEntities.title 59 s.album.musicalEntities.title, 60 s.link 61 61 ) 62 62 FROM Song s … … 66 66 """) 67 67 // todo: add paging 68 List< MusicalEntityDto> findTopByListens(@Param("currentUserId")Long currentUserId);68 List<SongDto> findTopByListens(@Param("currentUserId")Long currentUserId); 69 69 70 70 @Query(""" -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/service/SongService.java
r8a47b30 r9c1dcc7 18 18 private final SongDetailsMapper songDetailsMapper; 19 19 20 public List< MusicalEntityDto> getTopSongs(){20 public List<SongDto> getTopSongs(){ 21 21 Long userId = null; 22 22 try { -
frontend/src/App.tsx
r8a47b30 r9c1dcc7 9 9 import "react-toastify/dist/ReactToastify.css"; 10 10 import LoadingSpinner from "./components/LoadingSpinner"; 11 import MiniPlayer from "./components/MiniPlayer"; 11 12 import Sidebar from "./components/Sidebar"; 12 13 import { useAuth } from "./context/authContext"; 14 import { usePlayer } from "./context/playerContext"; 13 15 import AllUsers from "./pages/AllUsers"; 14 16 import LandingPage from "./pages/LandingPage"; … … 22 24 const MainLayout = () => { 23 25 const { user } = useAuth(); 26 const { currentSong } = usePlayer(); 24 27 const location = useLocation(); 25 28 // show sidebar only if user is logged in and is on the landing page … … 56 59 className={`grow transition-all duration-300 pt-20 ${ 57 60 isSidebarOpen ? "ml-64" : "ml-0" 58 } `}61 } ${currentSong ? "pb-20" : ""}`} 59 62 > 60 63 <Outlet /> 61 64 </main> 65 <MiniPlayer /> 62 66 </div> 63 67 ); -
frontend/src/components/Sidebar.tsx
r8a47b30 r9c1dcc7 1 1 import { useEffect, useState } from "react"; 2 import { useNavigate } from "react-router-dom"; 2 3 import axiosInstance from "../api/axiosInstance"; 3 4 import { useAuth } from "../context/authContext"; 5 import { usePlayer } from "../context/playerContext"; 4 6 import type { BasicPlaylist, BasicSong, SidebarProps } from "../utils/types"; 7 8 const toEmbedUrl = (url: string): string => { 9 try { 10 const parsed = new URL(url); 11 if ( 12 (parsed.hostname === "www.youtube.com" || 13 parsed.hostname === "youtube.com") && 14 parsed.searchParams.has("v") 15 ) { 16 return `https://www.youtube.com/embed/${parsed.searchParams.get("v")}`; 17 } 18 if (parsed.hostname === "youtu.be") { 19 return `https://www.youtube.com/embed${parsed.pathname}`; 20 } 21 return url; 22 } catch { 23 return url; 24 } 25 }; 5 26 6 27 const Sidebar = ({ isOpen, onClose }: SidebarProps) => { 7 28 const { user } = useAuth(); 29 const navigate = useNavigate(); 30 const { play, currentSong } = usePlayer(); 8 31 const [recentlyListened, setRecentlyListened] = useState<BasicSong[]>([]); 9 32 const [playlists, setPlaylists] = useState<BasicPlaylist[]>([]); … … 74 97 <div 75 98 key={song.id} 76 className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 cursor-pointer transition-colors" 99 onClick={() => navigate(`/songs/${song.id}`)} 100 className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group relative" 77 101 > 78 102 <img … … 80 104 alt={song.title} 81 105 className="w-10 h-10 rounded object-cover" 106 onError={(e) => { 107 (e.target as HTMLImageElement).src = "/favicon.png"; 108 }} 82 109 /> 83 110 <div className="flex-1 min-w-0"> … … 89 116 </p> 90 117 </div> 118 {song.link && ( 119 <button 120 onClick={(e) => { 121 e.stopPropagation(); 122 play({ 123 id: song.id, 124 title: song.title, 125 artist: song.artist, 126 cover: song.cover, 127 embedUrl: toEmbedUrl(song.link!), 128 }); 129 }} 130 className={`p-1.5 rounded-full transition-all opacity-0 group-hover:opacity-100 ${ 131 currentSong?.id === song.id 132 ? "bg-white text-[#1db954]" 133 : "bg-[#1db954] text-black hover:scale-110" 134 }`} 135 aria-label={ 136 currentSong?.id === song.id ? "Now playing" : "Play song" 137 } 138 > 139 {currentSong?.id === song.id ? ( 140 <svg 141 className="w-4 h-4" 142 fill="currentColor" 143 viewBox="0 0 24 24" 144 > 145 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 146 </svg> 147 ) : ( 148 <svg 149 className="w-4 h-4" 150 fill="currentColor" 151 viewBox="0 0 24 24" 152 > 153 <path d="M8 5v14l11-7z" /> 154 </svg> 155 )} 156 </button> 157 )} 91 158 </div> 92 159 ))} -
frontend/src/components/search/SongResult.tsx
r8a47b30 r9c1dcc7 1 import { useNavigate } from "react-router-dom"; 2 import { usePlayer } from "../../context/playerContext"; 1 3 import type { Song } from "../../utils/types"; 2 4 … … 5 7 } 6 8 9 const toEmbedUrl = (url: string): string => { 10 try { 11 const parsed = new URL(url); 12 if ( 13 (parsed.hostname === "www.youtube.com" || 14 parsed.hostname === "youtube.com") && 15 parsed.searchParams.has("v") 16 ) { 17 return `https://www.youtube.com/embed/${parsed.searchParams.get("v")}`; 18 } 19 if (parsed.hostname === "youtu.be") { 20 return `https://www.youtube.com/embed${parsed.pathname}`; 21 } 22 return url; 23 } catch { 24 return url; 25 } 26 }; 27 7 28 const SongResult = ({ song }: SongResultProps) => { 29 const navigate = useNavigate(); 30 const { play, currentSong } = usePlayer(); 31 8 32 return ( 9 <div className="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group"> 33 <div 34 onClick={() => navigate(`/songs/${song.id}`)} 35 className="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group" 36 > 10 37 <img 11 38 src={song.cover || "/favicon.png"} … … 22 49 </p> 23 50 </div> 24 <span className="text-xs text-gray-500 uppercase tracking-wider ">51 <span className="text-xs text-gray-500 uppercase tracking-wider mr-2"> 25 52 {song.genre} 26 53 </span> 54 {song.link && ( 55 <button 56 onClick={(e) => { 57 e.stopPropagation(); 58 play({ 59 id: song.id, 60 title: song.title, 61 artist: song.releasedBy, 62 cover: song.cover, 63 embedUrl: toEmbedUrl(song.link!), 64 }); 65 }} 66 className={`p-2 rounded-full transition-all opacity-0 group-hover:opacity-100 ${ 67 currentSong?.id === song.id 68 ? "bg-white text-[#1db954]" 69 : "bg-[#1db954] text-black hover:scale-110" 70 }`} 71 aria-label={currentSong?.id === song.id ? "Now playing" : "Play song"} 72 > 73 {currentSong?.id === song.id ? ( 74 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> 75 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 76 </svg> 77 ) : ( 78 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> 79 <path d="M8 5v14l11-7z" /> 80 </svg> 81 )} 82 </button> 83 )} 27 84 </div> 28 85 ); -
frontend/src/main.tsx
r8a47b30 r9c1dcc7 3 3 import App from "./App.tsx"; 4 4 import { AuthProvider } from "./context/authContext.tsx"; 5 import { PlayerProvider } from "./context/playerContext.tsx"; 5 6 import "./index.css"; 6 7 … … 8 9 <StrictMode> 9 10 <AuthProvider> 10 <App /> 11 <PlayerProvider> 12 <App /> 13 </PlayerProvider> 11 14 </AuthProvider> 12 15 </StrictMode>, -
frontend/src/pages/LandingPage.tsx
r8a47b30 r9c1dcc7 1 1 import { useEffect, useRef, useState } from "react"; 2 import { useNavigate } from "react-router-dom"; 2 3 import axiosInstance from "../api/axiosInstance"; 3 4 import AlbumResult from "../components/search/AlbumResult"; 4 5 import SongResult from "../components/search/SongResult"; 5 6 import UserResult from "../components/search/UserResult"; 7 import { usePlayer } from "../context/playerContext"; 6 8 import type { 7 9 Album, … … 11 13 } from "../utils/types"; 12 14 15 // Convert a regular YouTube URL to an embeddable URL 16 const toEmbedUrl = (url: string): string => { 17 try { 18 const parsed = new URL(url); 19 if ( 20 (parsed.hostname === "www.youtube.com" || 21 parsed.hostname === "youtube.com") && 22 parsed.searchParams.has("v") 23 ) { 24 return `https://www.youtube.com/embed/${parsed.searchParams.get("v")}`; 25 } 26 if (parsed.hostname === "youtu.be") { 27 return `https://www.youtube.com/embed${parsed.pathname}`; 28 } 29 return url; 30 } catch { 31 return url; 32 } 33 }; 34 13 35 const CATEGORIES: { value: SearchCategory; label: string }[] = [ 14 36 { value: "songs", label: "Songs" }, … … 19 41 20 42 const LandingPage = () => { 43 const navigate = useNavigate(); 44 const { play, currentSong } = usePlayer(); 21 45 const [songs, setSongs] = useState<Song[]>([]); 22 46 const [loading, setLoading] = useState<boolean>(true); … … 327 351 <div 328 352 key={song.id} 353 onClick={() => navigate(`/songs/${song.id}`)} 329 354 className="bg-[#282828] rounded-xl overflow-hidden cursor-pointer shadow-lg transition-all duration-300 ease-in-out hover:-translate-y-2 hover:shadow-2xl group" 330 355 > … … 340 365 /> 341 366 <div className="absolute top-0 left-0 w-full h-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"> 342 <div className="w-15 h-15 rounded-full bg-[#1db954] flex items-center justify-center text-2xl text-black shadow-[0_4px_12px_rgba(29,185,84,0.5)]"> 343 ▶ 344 </div> 367 {song.link ? ( 368 <button 369 onClick={(e) => { 370 e.stopPropagation(); 371 play({ 372 id: song.id, 373 title: song.title, 374 artist: song.releasedBy, 375 cover: song.cover, 376 embedUrl: toEmbedUrl(song.link!), 377 }); 378 }} 379 className={`w-14 h-14 rounded-full flex items-center justify-center text-xl shadow-[0_4px_12px_rgba(29,185,84,0.5)] transition-all cursor-pointer ${ 380 currentSong?.id === song.id 381 ? "bg-white text-[#1db954]" 382 : "bg-[#1db954] text-black hover:scale-105" 383 }`} 384 aria-label={ 385 currentSong?.id === song.id 386 ? "Now playing" 387 : "Play song" 388 } 389 > 390 {currentSong?.id === song.id ? ( 391 <svg 392 className="w-6 h-6" 393 fill="currentColor" 394 viewBox="0 0 24 24" 395 > 396 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 397 </svg> 398 ) : ( 399 <svg 400 className="w-6 h-6" 401 fill="currentColor" 402 viewBox="0 0 24 24" 403 > 404 <path d="M8 5v14l11-7z" /> 405 </svg> 406 )} 407 </button> 408 ) : ( 409 <div className="w-14 h-14 rounded-full bg-[#1db954] flex items-center justify-center text-xl text-black shadow-[0_4px_12px_rgba(29,185,84,0.5)]"> 410 ▶ 411 </div> 412 )} 345 413 </div> 346 414 </div> -
frontend/src/pages/SongDetail.tsx
r8a47b30 r9c1dcc7 3 3 import axiosInstance from "../api/axiosInstance"; 4 4 import { useAuth } from "../context/authContext"; 5 import { usePlayer } from "../context/playerContext"; 5 6 import type { SongDetail as SongDetailType } from "../utils/types"; 6 7 … … 62 63 const { id } = useParams<{ id: string }>(); 63 64 const { user } = useAuth(); 65 const { play, currentSong } = usePlayer(); 64 66 const [song, setSong] = useState<SongDetailType | null>(null); 65 67 const [loading, setLoading] = useState(true); … … 72 74 const response = await axiosInstance.get(`/songs/${id}/details`); 73 75 setSong(response.data); 76 // Don't autoplay - user must click play button 74 77 } catch (err) { 75 78 console.error("Error fetching song details:", err); … … 207 210 208 211 {/* Action buttons */} 209 {/* todo: add add to playlist button */}210 212 <div className="flex items-center gap-3 mt-4"> 213 {/* Play button */} 214 {song.link && ( 215 <button 216 onClick={() => 217 play({ 218 id: song.id, 219 title: song.title, 220 artist: song.releasedBy, 221 cover: song.cover, 222 embedUrl: toEmbedUrl(song.link!), 223 }) 224 } 225 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${ 226 currentSong?.id === song.id 227 ? "bg-white text-[#1db954]" 228 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105" 229 }`} 230 > 231 {currentSong?.id === song.id ? ( 232 <> 233 <svg 234 className="w-5 h-5" 235 fill="currentColor" 236 viewBox="0 0 24 24" 237 > 238 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 239 </svg> 240 Now Playing 241 </> 242 ) : ( 243 <> 244 <svg 245 className="w-5 h-5" 246 fill="currentColor" 247 viewBox="0 0 24 24" 248 > 249 <path d="M8 5v14l11-7z" /> 250 </svg> 251 Play Song 252 </> 253 )} 254 </button> 255 )} 256 211 257 {user && ( 212 258 <button … … 248 294 </div> 249 295 250 {/* YouTube embed*/}296 {/* Play button — plays in persistent mini-player */} 251 297 {song.link && ( 252 298 <section className="mb-10"> 253 <div className="rounded-xl overflow-hidden shadow-lg aspect-video bg-black"> 254 <iframe 255 src={toEmbedUrl(song.link)} 256 title={song.title} 257 className="w-full h-full" 258 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 259 allowFullScreen 260 /> 261 </div> 299 {currentSong?.id === song.id ? ( 300 <div className="bg-[#1a1a2e]/60 rounded-xl p-4 flex items-center gap-3"> 301 <div className="w-10 h-10 rounded-full bg-[#1db954] flex items-center justify-center text-black animate-pulse"> 302 <svg 303 className="w-5 h-5" 304 fill="currentColor" 305 viewBox="0 0 24 24" 306 > 307 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 308 </svg> 309 </div> 310 <p className="text-[#1db954] font-medium"> 311 Now playing in mini-player below 312 </p> 313 </div> 314 ) : ( 315 <button 316 onClick={() => 317 play({ 318 id: song.id, 319 title: song.title, 320 artist: song.releasedBy, 321 cover: song.cover, 322 embedUrl: toEmbedUrl(song.link!), 323 }) 324 } 325 className="w-full bg-[#1a1a2e]/60 hover:bg-[#1a1a2e]/80 rounded-xl p-4 flex items-center gap-3 transition-colors cursor-pointer group" 326 > 327 <div className="w-10 h-10 rounded-full bg-[#1db954] flex items-center justify-center text-black group-hover:scale-105 transition-transform"> 328 <svg 329 className="w-5 h-5" 330 fill="currentColor" 331 viewBox="0 0 24 24" 332 > 333 <path d="M8 5v14l11-7z" /> 334 </svg> 335 </div> 336 <p className="text-white font-medium">Play this song</p> 337 </button> 338 )} 262 339 </section> 263 340 )} -
frontend/src/utils/types.ts
r8a47b30 r9c1dcc7 29 29 type: "SONG"; 30 30 album?: string; 31 link?: string; 31 32 } 32 33 … … 92 93 artist: string; 93 94 cover?: string; 95 link?: string; 94 96 } 95 97
Note:
See TracChangeset
for help on using the changeset viewer.
