Changeset 1579b4f
- Timestamp:
- 02/10/26 21:58:39 (5 months ago)
- Branches:
- main
- Children:
- 92db381
- Parents:
- 0808ef2
- Files:
-
- 3 added
- 18 edited
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/controller/PlaylistController.java (modified) (1 diff)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/AlbumDto.java (modified) (1 diff)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/ArtistContributionDto.java (modified) (1 diff)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/PlaylistDto.java (modified) (1 diff)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/SongWithLinkDto.java (added)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/repository/ArtistContributionRepository.java (modified) (2 diffs)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/repository/SongRepository.java (modified) (5 diffs)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/service/AlbumService.java (modified) (2 diffs)
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/service/PlaylistService.java (modified) (2 diffs)
-
frontend/src/components/Sidebar.tsx (modified) (1 diff)
-
frontend/src/components/SongItem.tsx (added)
-
frontend/src/components/search/SongResult.tsx (modified) (1 diff)
-
frontend/src/components/userProfile/ArtistView.tsx (modified) (1 diff)
-
frontend/src/components/userProfile/ListenerView.tsx (modified) (1 diff)
-
frontend/src/components/userProfile/UserListModal.tsx (modified) (5 diffs)
-
frontend/src/pages/LandingPage.tsx (modified) (1 diff)
-
frontend/src/pages/MusicalCollection.tsx (modified) (1 diff)
-
frontend/src/pages/SongDetail.tsx (modified) (4 diffs)
-
frontend/src/pages/UserDetail.tsx (modified) (9 diffs)
-
frontend/src/utils/types.ts (modified) (1 diff)
-
frontend/src/utils/utils.ts (added)
Legend:
- Unmodified
- Added
- Removed
-
finkwave/src/main/java/com/ukim/finki/develop/finkwave/controller/PlaylistController.java
r0808ef2 r1579b4f 5 5 import com.ukim.finki.develop.finkwave.service.PlaylistService; 6 6 import lombok.AllArgsConstructor; 7 import lombok.NoArgsConstructor;8 7 import org.springframework.http.HttpEntity; 9 8 import org.springframework.http.ResponseEntity; -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/AlbumDto.java
r0808ef2 r1579b4f 11 11 @AllArgsConstructor 12 12 public class AlbumDto extends MusicalEntityDto{ 13 private List< MusicalEntityDto>songs;13 private List<SongWithLinkDto>songs; 14 14 15 15 public AlbumDto(Long id, String title, String genre, String type, String releasedBy, String artistUsername, String cover, Boolean isLikedByCurrentUser) { -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/ArtistContributionDto.java
r0808ef2 r1579b4f 15 15 private String entityType; 16 16 private Boolean isLikedByCurrentUser; 17 private String cover; 18 private String link; 17 19 } -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/PlaylistDto.java
r0808ef2 r1579b4f 15 15 private String cover; 16 16 private String creatorName; 17 private List< MusicalEntityDto>songsInPlaylist;17 private List<SongWithLinkDto>songsInPlaylist; 18 18 private Boolean isSavedByCurrentUser; 19 19 -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/repository/ArtistContributionRepository.java
r0808ef2 r1579b4f 1 1 package com.ukim.finki.develop.finkwave.repository; 2 2 3 import java.util.List; 4 5 import org.springframework.data.jpa.repository.JpaRepository; 6 import org.springframework.data.jpa.repository.Query; 7 import org.springframework.data.repository.query.Param; 8 import org.springframework.stereotype.Repository; 9 10 import com.ukim.finki.develop.finkwave.model.ArtistContribution; 11 import com.ukim.finki.develop.finkwave.model.ArtistContributionId; 3 12 import com.ukim.finki.develop.finkwave.model.dto.ArtistContributionDto; 4 13 import com.ukim.finki.develop.finkwave.model.dto.ArtistContributionSummaryDto; 5 import org.springframework.data.jpa.repository.JpaRepository;6 import org.springframework.data.jpa.repository.Query;7 import org.springframework.stereotype.Repository;8 import org.springframework.data.repository.query.Param;9 import com.ukim.finki.develop.finkwave.model.ArtistContribution;10 import com.ukim.finki.develop.finkwave.model.ArtistContributionId;11 12 import java.util.List;13 14 14 15 @Repository … … 21 22 WHEN a.id IS NOT NULL THEN 'ALBUM' 22 23 ELSE 'Unknown' END), 23 (CASE WHEN l.id IS NOT NULL THEN true ELSE false END)) 24 (CASE WHEN l.id IS NOT NULL THEN true ELSE false END), 25 ac.musicalEntity.cover, 26 s.link) 24 27 FROM ArtistContribution ac 25 28 LEFT JOIN Song s ON s.musicalEntities.id=ac.musicalEntity.id -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/repository/SongRepository.java
r0808ef2 r1579b4f 3 3 import java.util.List; 4 4 5 import com.ukim.finki.develop.finkwave.model.dto.*; 5 6 import org.springframework.data.jpa.repository.JpaRepository; 6 7 import org.springframework.data.jpa.repository.Query; … … 9 10 10 11 import com.ukim.finki.develop.finkwave.model.Song; 11 import com.ukim.finki.develop.finkwave.model.dto.BasicSongDto;12 import com.ukim.finki.develop.finkwave.model.dto.MusicalEntitesByArtistDto;13 import com.ukim.finki.develop.finkwave.model.dto.MusicalEntityDto;14 import com.ukim.finki.develop.finkwave.model.dto.SongDto;15 12 16 13 @Repository … … 19 16 20 17 @Query(""" 21 SELECT NEW com.ukim.finki.develop.finkwave.model.dto. MusicalEntityDto(18 SELECT NEW com.ukim.finki.develop.finkwave.model.dto.SongWithLinkDto( 22 19 s.id, me.title, me.genre, 'SONG', u.fullName, u.username, me.cover, 23 (CASE WHEN :currentUserId IS NOT NULL AND l.id IS NOT NULL THEN true ELSE false END) 20 (CASE WHEN :currentUserId IS NOT NULL AND l.id IS NOT NULL THEN true ELSE false END), 21 s.link 24 22 ) 25 23 FROM Song s … … 31 29 WHERE s.album.id = :albumId 32 30 """) 33 List< MusicalEntityDto>findSongsByAlbum(@Param("albumId") Long albumId, @Param("currentUserId")Long currentUserId);31 List<SongWithLinkDto>findSongsByAlbum(@Param("albumId") Long albumId, @Param("currentUserId")Long currentUserId); 34 32 35 33 36 34 @Query(""" 37 SELECT NEW com.ukim.finki.develop.finkwave.model.dto. MusicalEntityDto(35 SELECT NEW com.ukim.finki.develop.finkwave.model.dto.SongWithLinkDto( 38 36 s.id, me.title, me.genre, 'SONG', u.fullName, u.username, me.cover, 39 (CASE WHEN :currentUserId IS NOT NULL AND l.id IS NOT NULL THEN true ELSE false END)) 37 (CASE WHEN :currentUserId IS NOT NULL AND l.id IS NOT NULL THEN true ELSE false END), 38 s.link 39 ) 40 40 FROM Song s 41 41 JOIN s.musicalEntities me … … 47 47 WHERE ps.playlist.id=:playlistId 48 48 """) 49 List< MusicalEntityDto>findSongsByPlaylistId(@Param("playlistId")Long playlistId, @Param("currentUserId")Long currentUserId);49 List<SongWithLinkDto>findSongsByPlaylistId(@Param("playlistId")Long playlistId, @Param("currentUserId")Long currentUserId); 50 50 51 51 @Query(""" -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/service/AlbumService.java
r0808ef2 r1579b4f 6 6 import com.ukim.finki.develop.finkwave.model.dto.AlbumDto; 7 7 import com.ukim.finki.develop.finkwave.model.dto.MusicalEntityDto; 8 import com.ukim.finki.develop.finkwave.model.dto.SongWithLinkDto; 8 9 import com.ukim.finki.develop.finkwave.repository.AlbumRepository; 9 10 import com.ukim.finki.develop.finkwave.repository.LikeRepository; … … 37 38 album.getMusicalEntities().getCover(), 38 39 likeRepository.isLikedByUser(currentUserId)); 39 List< MusicalEntityDto>songsFromAlbum=songRepository.findSongsByAlbum(id,currentUserId);40 List<SongWithLinkDto>songsFromAlbum=songRepository.findSongsByAlbum(id,currentUserId); 40 41 41 42 dto.setSongs(songsFromAlbum); -
finkwave/src/main/java/com/ukim/finki/develop/finkwave/service/PlaylistService.java
r0808ef2 r1579b4f 4 4 import com.ukim.finki.develop.finkwave.model.Playlist; 5 5 import com.ukim.finki.develop.finkwave.model.dto.BasicPlaylistDto; 6 import com.ukim.finki.develop.finkwave.model.dto.MusicalEntityDto;7 6 import com.ukim.finki.develop.finkwave.model.dto.PlaylistDto; 7 import com.ukim.finki.develop.finkwave.model.dto.SongWithLinkDto; 8 8 import com.ukim.finki.develop.finkwave.repository.PlaylistRepository; 9 9 import com.ukim.finki.develop.finkwave.repository.SavedPlaylistRepository; … … 37 37 Long currentUserId=authService.getCurrentUserIDOptional().orElse(null); 38 38 Playlist playlist = playlistRepository.findById(id).orElseThrow(()-> new PlaylistNotFoundException(id)); 39 List< MusicalEntityDto>songsInPlaylist=songRepository.findSongsByPlaylistId(id,currentUserId);39 List<SongWithLinkDto>songsInPlaylist=songRepository.findSongsByPlaylistId(id,currentUserId); 40 40 Set<Long> savedIds = savedPlaylistRepository.findAllByListener_Id(currentUserId) 41 41 .stream().map(sp -> sp.getPlaylist().getId()).collect(Collectors.toSet()); -
frontend/src/components/Sidebar.tsx
r0808ef2 r1579b4f 5 5 import { usePlayer } from "../context/playerContext"; 6 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 }; 7 import { toEmbedUrl } from "../utils/utils"; 26 8 27 9 const Sidebar = ({ isOpen, onClose }: SidebarProps) => { -
frontend/src/components/search/SongResult.tsx
r0808ef2 r1579b4f 1 import { useNavigate } from "react-router-dom"; 2 import { usePlayer } from "../../context/playerContext"; 1 import axiosInstance from "../../api/axiosInstance"; 3 2 import type { Song } from "../../utils/types"; 3 import SongItem from "../SongItem"; 4 4 5 5 interface SongResultProps { 6 6 song: Song; 7 /** Propagate like state change upward if needed */ 8 onLikeToggled?: (songId: number, isLiked: boolean) => void; 7 9 } 8 10 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")}`;11 const SongResult = ({ song, onLikeToggled }: SongResultProps) => { 12 const handleLike = async (songId: number) => { 13 try { 14 const response = await axiosInstance.post( 15 `/musical-entity/${songId}/like`, 16 ); 17 onLikeToggled?.(songId, response.data.isLiked); 18 } catch (err) { 19 console.error("Error toggling like:", err); 18 20 } 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 }; 21 }; 27 22 28 const SongResult = ({ song }: SongResultProps) => { 29 const navigate = useNavigate(); 30 const { play, currentSong } = usePlayer(); 31 32 return ( 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 > 37 <img 38 src={song.cover || "/favicon.png"} 39 alt={song.title} 40 className="w-12 h-12 rounded object-cover" 41 onError={(e) => { 42 (e.target as HTMLImageElement).src = "/favicon.png"; 43 }} 44 /> 45 <div className="flex-1 min-w-0"> 46 <p className="text-white font-medium truncate">{song.title}</p> 47 <p className="text-sm text-gray-400 truncate"> 48 Song • {song.releasedBy} 49 </p> 50 </div> 51 <span className="text-xs text-gray-500 uppercase tracking-wider mr-2"> 52 {song.genre} 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 )} 84 </div> 85 ); 23 return <SongItem song={song} label="Song" onLikeToggle={handleLike} />; 86 24 }; 87 25 -
frontend/src/components/userProfile/ArtistView.tsx
r0808ef2 r1579b4f 1 import { Disc3, Music } from "lucide-react"; 2 import { useState } from "react"; 1 3 import { useNavigate } from "react-router-dom"; 2 import { useState } from "react";3 import { Music, Disc3, Play, Plus } from "lucide-react";4 import { toast } from "react-toastify"; 5 import axiosInstance from "../../api/axiosInstance"; 4 6 import type { ArtistContribution } from "../../utils/types"; 5 import axiosInstance from "../../api/axiosInstance";7 import SongItem from "../SongItem"; 6 8 7 9 interface ArtistViewProps { 8 contributions: ArtistContribution[];10 contributions: ArtistContribution[]; 9 11 } 10 12 11 13 const ArtistView = ({ contributions }: ArtistViewProps) => { 12 const navigate = useNavigate(); 13 const [items, setItems] = useState(contributions); 14 const [toast, setToast] = useState<{ message: string; show: boolean }>({ 15 message: "", 16 show: false, 17 }); 14 const navigate = useNavigate(); 15 const [items, setItems] = useState(contributions); 18 16 19 const albums = items.filter((c) => c.entityType === "ALBUM");20 const songs = items.filter((c) => c.entityType === "SONG");17 const albums = items.filter((c) => c.entityType === "ALBUM"); 18 const songs = items.filter((c) => c.entityType === "SONG"); 21 19 22 const getRoleColor = (role: string) => {23 const colors: { [key: string]: string } = {24 COMPOSER: "bg-purple-100 text-purple-700",25 PERFORMER: "bg-blue-100 text-blue-700",26 PRODUCER: "bg-green-100 text-green-700",27 MAIN_VOCAL: "bg-pink-100 text-pink-700",28 };29 return colors[role] || "bg-gray-100 text-gray-700";30 };20 const getRoleColor = (role: string) => { 21 const colors: { [key: string]: string } = { 22 COMPOSER: "bg-purple-500/20 text-purple-300", 23 PERFORMER: "bg-blue-500/20 text-blue-300", 24 PRODUCER: "bg-green-500/20 text-green-300", 25 MAIN_VOCAL: "bg-pink-500/20 text-pink-300", 26 }; 27 return colors[role] || "bg-white/10 text-gray-300"; 28 }; 31 29 32 const showToast = (message: string) => { 33 setToast({ message, show: true }); 34 setTimeout(() => { 35 setToast({ message: "", show: false }); 36 }, 2000); 37 }; 30 const handleLike = async (id: number, title: string) => { 31 try { 32 const response = await axiosInstance.post(`/musical-entity/${id}/like`); 33 const data = response.data; 38 34 39 const handleLike = async (id: number, title: string) => { 40 try { 41 const response = await axiosInstance.post(`/musical-entity/${id}/like`); 42 const data = response.data; 35 setItems((prevItems) => 36 prevItems.map((item) => 37 item.id === data.entityId 38 ? { ...item, isLikedByCurrentUser: data.isLiked } 39 : item, 40 ), 41 ); 42 toast.success( 43 data.isLiked ? `Liked "${title}"` : `Removed "${title}" from likes`, 44 ); 45 } catch (err: any) { 46 toast.error(err.response?.data?.error || "Failed to like the item"); 47 } 48 }; 43 49 44 setItems((prevItems) => 45 prevItems.map((item) => 46 item.id === data.entityId 47 ? { ...item, isLikedByCurrentUser: data.isLiked } 48 : item, 49 ), 50 ); 50 return ( 51 <div className="mt-8"> 52 {albums.length > 0 && ( 53 <div className="mb-12"> 54 <div className="flex items-center gap-3 mb-6"> 55 <Disc3 className="w-6 h-6 text-[#1db954]" /> 56 <h2 className="text-2xl font-bold text-white">Albums</h2> 57 </div> 58 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 59 {albums.map((album) => ( 60 <div 61 key={album.id} 62 className="group cursor-pointer" 63 onClick={() => navigate(`/collection/album/${album.id}`)} 64 > 65 <div className="relative aspect-square rounded-lg mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 bg-[#181818]"> 66 <img 67 src={album.cover || "/favicon.png"} 68 alt={album.title} 69 className="w-full h-full object-cover" 70 onError={(e) => { 71 (e.target as HTMLImageElement).src = "/favicon.png"; 72 }} 73 /> 51 74 52 showToast( 53 data.isLiked ? `Liked "${title}"` : `Removed ${title} from likes`, 54 ); 55 } catch (err: any) { 56 showToast(err.response?.data?.error); 57 } 58 }; 75 <button 76 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100 cursor-pointer" 77 title={album.isLikedByCurrentUser ? "Unlike" : "Like"} 78 onClick={(e) => { 79 e.stopPropagation(); 80 handleLike(album.id, album.title); 81 }} 82 > 83 <svg 84 className="w-5 h-5" 85 fill={album.isLikedByCurrentUser ? "#ef4444" : "none"} 86 stroke={ 87 album.isLikedByCurrentUser ? "#ef4444" : "#9ca3af" 88 } 89 strokeWidth="2" 90 viewBox="0 0 24 24" 91 > 92 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 93 </svg> 94 </button> 59 95 60 return ( 61 <div className="mt-8"> 62 {toast.show && ( 63 <div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-fade-in-up"> 64 <div className="bg-gray-900 text-white px-6 py-3 rounded-full shadow-lg text-sm font-medium"> 65 {toast.message} 66 </div> 67 </div> 68 )} 96 <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/80 to-transparent p-3"> 97 <span 98 className={`text-xs px-2 py-1 rounded-full font-medium ${getRoleColor(album.role)}`} 99 > 100 {album.role.replace("_", " ")} 101 </span> 102 </div> 103 </div> 104 <h3 className="font-semibold text-sm line-clamp-2 text-white group-hover:text-[#1db954] transition-colors"> 105 {album.title} 106 </h3> 107 </div> 108 ))} 109 </div> 110 </div> 111 )} 69 112 70 {albums.length > 0 && ( 71 <div className="mb-12"> 72 <div className="flex items-center gap-3 mb-6"> 73 <Disc3 className="w-6 h-6 text-gray-700" /> 74 <h2 className="text-2xl font-bold text-gray-800">Albums</h2> 75 </div> 76 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 77 {albums.map((album) => ( 78 <div 79 key={album.id} 80 className="group cursor-pointer" 81 onClick={() => navigate(`/collection/album/${album.id}`)} 82 > 83 <div className="relative aspect-square bg-gradient-to-br from-blue-400 to-purple-500 rounded-lg mb-3 overflow-hidden shadow-md group-hover:shadow-lg transition-all duration-300"> 84 <div className="absolute inset-0 flex items-center justify-center"> 85 <Disc3 className="w-16 h-16 text-white opacity-30" /> 86 </div> 113 {songs.length > 0 && ( 114 <div className="mb-12"> 115 <div className="flex items-center gap-3 mb-6"> 116 <Music className="w-6 h-6 text-[#1db954]" /> 117 <h2 className="text-2xl font-bold text-white">Songs</h2> 118 </div> 119 <div className="space-y-1"> 120 {songs.map((song) => ( 121 <SongItem 122 key={song.id} 123 song={{ 124 id: song.id, 125 title: song.title, 126 cover: song.cover, 127 genre: song.genre, 128 link: song.link, 129 isLikedByCurrentUser: song.isLikedByCurrentUser, 130 }} 131 role={song.role} 132 onLikeToggle={() => handleLike(song.id, song.title)} 133 /> 134 ))} 135 </div> 136 </div> 137 )} 87 138 88 <button 89 className="absolute top-2 right-2 p-2 bg-white/90 hover:bg-white rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100 cursor-pointer" 90 title={album.isLikedByCurrentUser ? "Unlike" : "Like"} 91 onClick={(e) => { 92 e.stopPropagation(); 93 handleLike(album.id, album.title); 94 }} 95 > 96 <svg 97 className="w-5 h-5" 98 fill={album.isLikedByCurrentUser ? "#ef4444" : "none"} 99 stroke={ 100 album.isLikedByCurrentUser ? "#ef4444" : "#6b7280" 101 } 102 strokeWidth="2" 103 viewBox="0 0 24 24" 104 > 105 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 106 </svg> 107 </button> 108 109 <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-3"> 110 <span 111 className={`text-xs px-2 py-1 rounded-full font-medium ${getRoleColor(album.role)}`} 112 > 113 {album.role.replace("_", " ")} 114 </span> 115 </div> 116 </div> 117 <h3 className="font-semibold text-sm line-clamp-2 text-gray-900 group-hover:text-blue-600 transition-colors"> 118 {album.title} 119 </h3> 120 </div> 121 ))} 122 </div> 123 </div> 124 )} 125 126 {songs.length > 0 && ( 127 <div className="mb-12"> 128 <div className="flex items-center gap-3 mb-6"> 129 <Music className="w-6 h-6 text-gray-700" /> 130 <h2 className="text-2xl font-bold text-gray-800">Songs</h2> 131 </div> 132 <div className="space-y-2"> 133 {songs.map((song) => ( 134 <div 135 key={song.id} 136 className="group relative flex items-center gap-4 p-3 rounded-lg hover:bg-gray-50 transition-all cursor-pointer" 137 onClick={() => navigate(`/musical-entity/${song.id}`)} 138 > 139 <div className="relative shrink-0"> 140 <div className="w-12 h-12 bg-gradient-to-br from-blue-400 to-purple-500 rounded flex items-center justify-center shadow-sm"> 141 <Music className="w-6 h-6 text-white" /> 142 </div> 143 <button 144 className="absolute inset-0 bg-black/60 rounded flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200" 145 aria-label="Play song" 146 > 147 <Play className="w-6 h-6 text-white fill-white" /> 148 </button> 149 </div> 150 151 <div className="flex-1 min-w-0"> 152 <h3 className="font-semibold text-base text-gray-900 group-hover:text-blue-600 transition-colors truncate"> 153 {song.title} 154 </h3> 155 <div className="flex items-center gap-2 mt-1"> 156 <span className="text-sm text-gray-600">{song.genre}</span> 157 </div> 158 </div> 159 160 <span 161 className={`px-3 py-1 rounded-full text-sm font-medium ${getRoleColor(song.role)}`} 162 > 163 {song.role.replace("_", " ")} 164 </span> 165 166 <div className="flex items-center gap-2"> 167 <button 168 className="p-2 hover:bg-gray-200 rounded-full transition-colors duration-200 cursor-pointer" 169 title="Add to playlist" 170 > 171 <Plus className="w-5 h-5 text-gray-600" /> 172 </button> 173 174 <button 175 className="p-2 hover:bg-gray-200 rounded-full transition-colors duration-200 cursor-pointer" 176 title={song.isLikedByCurrentUser ? "Unlike" : "Like"} 177 onClick={(e) => { 178 e.stopPropagation(); 179 handleLike(song.id, song.title); 180 }} 181 > 182 <svg 183 className="w-5 h-5" 184 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"} 185 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"} 186 strokeWidth="2" 187 viewBox="0 0 24 24" 188 > 189 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 190 </svg> 191 </button> 192 </div> 193 </div> 194 ))} 195 </div> 196 </div> 197 )} 198 199 {contributions.length === 0 && ( 200 <div className="flex flex-col items-center justify-center py-16 text-gray-400"> 201 <Music className="w-20 h-20 mb-4 opacity-20" /> 202 <p className="text-lg font-medium">No contributions yet</p> 203 <p className="text-sm mt-2">Start creating music to see it here</p> 204 </div> 205 )} 206 </div> 207 ); 139 {contributions.length === 0 && ( 140 <div className="flex flex-col items-center justify-center py-16 text-gray-500"> 141 <Music className="w-20 h-20 mb-4 opacity-20" /> 142 <p className="text-lg font-medium">No contributions yet</p> 143 <p className="text-sm mt-2">Start creating music to see it here</p> 144 </div> 145 )} 146 </div> 147 ); 208 148 }; 209 149 -
frontend/src/components/userProfile/ListenerView.tsx
r0808ef2 r1579b4f 1 import { Album, Bookmark, Heart, ListMusic, Music } from "lucide-react"; 2 import { useState } from "react"; 1 3 import { useNavigate } from "react-router-dom"; 2 import { Heart, ListMusic, Music, Album, Bookmark } from "lucide-react"; 3 import type { Playlist, MusicalEntity } from "../../utils/types"; 4 import { toast } from "react-toastify"; 4 5 import axiosInstance from "../../api/axiosInstance"; 5 import { useState } from "react"; 6 import type { MusicalEntity, Playlist } from "../../utils/types"; 7 import SongItem from "../SongItem"; 6 8 7 9 interface ListenerViewProps { 8 likedEntities: MusicalEntity[] | [];9 createdPlaylists: Playlist[] | [];10 savedPlaylists: Playlist[] | [];10 likedEntities: MusicalEntity[] | []; 11 createdPlaylists: Playlist[] | []; 12 savedPlaylists: Playlist[] | []; 11 13 } 12 14 13 15 const ListenerView = ({ 14 likedEntities,15 createdPlaylists,16 savedPlaylists,16 likedEntities, 17 createdPlaylists, 18 savedPlaylists, 17 19 }: ListenerViewProps) => { 18 const navigate = useNavigate(); 19 const [items, setItems] = useState(likedEntities); 20 const [savedItems, setSavedItems] = useState(savedPlaylists); 21 const [toast, setToast] = useState<{ message: string; show: boolean }>({ 22 message: "", 23 show: false, 24 }); 25 26 const showToast = (message: string) => { 27 setToast({ message, show: true }); 28 setTimeout(() => { 29 setToast({ message: "", show: false }); 30 }, 2000); 31 }; 32 33 const likedSongs = items.filter((e) => e.type === "SONG"); 34 const likedAlbums = items.filter((e) => e.type === "ALBUM"); 35 36 const handleSavePlaylist = async ( 37 e: React.MouseEvent, 38 playlistId: number, 39 playlistName: string, 40 ) => { 41 e.stopPropagation(); 42 43 try { 44 const response = await axiosInstance.post(`/playlist/${playlistId}/save`); 45 const data = response.data; 46 47 setSavedItems((prevItems) => 48 data.isSaved 49 ? [...prevItems, prevItems.find((p) => p.id === playlistId)!] 50 : prevItems.filter((p) => p.id !== playlistId), 51 ); 52 53 showToast( 54 data.isSaved 55 ? `Saved "${playlistName}"` 56 : `Removed "${playlistName}" from saved playlists`, 57 ); 58 } catch (err: any) { 59 showToast(err.response?.data?.error || "Failed to save playlist"); 60 } 61 }; 62 63 const handleLike = async (id: number, title: string) => { 64 try { 65 const response = await axiosInstance.post(`/musical-entity/${id}/like`); 66 const data = response.data; 67 68 setItems((prevItems) => 69 prevItems.map((item) => 70 item.id === data.entityId 71 ? { ...item, isLikedByCurrentUser: data.isLiked } 72 : item, 73 ), 74 ); 75 76 showToast( 77 data.isLiked ? `Liked "${title}"` : `Removed "${title}" from likes`, 78 ); 79 } catch (err: any) { 80 showToast(err.response?.data?.error); 81 } 82 }; 83 84 return ( 85 <div className="mt-8 space-y-12"> 86 {toast.show && ( 87 <div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-fade-in-up"> 88 <div className="bg-gray-900 text-white px-6 py-3 rounded-full shadow-lg text-sm font-medium"> 89 {toast.message} 90 </div> 91 </div> 92 )} 93 94 {createdPlaylists && createdPlaylists.length > 0 && ( 95 <section> 96 <div className="flex items-center gap-3 mb-6"> 97 <ListMusic className="w-6 h-6 text-gray-700" /> 98 <h3 className="text-2xl font-bold text-gray-800"> 99 Created Playlists 100 </h3> 101 <span className="text-sm text-gray-500"> 102 ({createdPlaylists.length}) 103 </span> 104 </div> 105 106 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 107 {createdPlaylists.map((playlist) => ( 108 <div 109 key={playlist.id} 110 className="group cursor-pointer" 111 onClick={() => navigate(`/collection/playlist/${playlist.id}`)} 112 > 113 <div className="relative aspect-square rounded-lg overflow-hidden bg-gray-100 mb-3 shadow-md group-hover:shadow-lg transition-all"> 114 {playlist.cover ? ( 115 <img 116 src={playlist.cover} 117 alt={playlist.name} 118 className="w-full h-full object-cover" 119 /> 120 ) : ( 121 <div className="w-full h-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center"> 122 <ListMusic className="w-16 h-16 text-white opacity-30" /> 123 </div> 124 )} 125 126 <button 127 onClick={(e) => 128 handleSavePlaylist(e, playlist.id, playlist.name) 129 } 130 className="absolute top-2 right-2 p-2 bg-white/90 hover:bg-white rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100" 131 title={playlist.isSavedByCurrentUser ? "Unsave" : "Save"} 132 > 133 <Bookmark 134 className={`w-5 h-5 ${ 135 playlist.isSavedByCurrentUser 136 ? "fill-blue-600 text-blue-600" 137 : "text-gray-600" 138 }`} 139 /> 140 </button> 141 </div> 142 <p className="text-sm font-semibold text-gray-900 truncate group-hover:text-blue-600 transition-colors"> 143 {playlist.name} 144 </p> 145 <p className="text-xs text-gray-500 truncate"> 146 {playlist.creatorName} 147 </p> 148 </div> 149 ))} 150 </div> 151 </section> 152 )} 153 154 {savedItems && savedItems.length > 0 && ( 155 <section> 156 <div className="flex items-center gap-3 mb-6"> 157 <Bookmark className="w-6 h-6 text-blue-600 fill-blue-600" /> 158 <h3 className="text-2xl font-bold text-gray-800"> 159 Saved Playlists 160 </h3> 161 <span className="text-sm text-gray-500">({savedItems.length})</span> 162 </div> 163 164 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 165 {savedItems.map((playlist) => ( 166 <div 167 key={playlist.id} 168 className="group cursor-pointer" 169 onClick={() => navigate(`/collection/playlist/${playlist.id}`)} 170 > 171 <div className="relative aspect-square rounded-lg overflow-hidden bg-gray-100 mb-3 shadow-md group-hover:shadow-lg transition-all"> 172 {playlist.cover ? ( 173 <img 174 src={playlist.cover} 175 alt={playlist.name} 176 className="w-full h-full object-cover" 177 /> 178 ) : ( 179 <div className="w-full h-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center"> 180 <ListMusic className="w-16 h-16 text-white opacity-30" /> 181 </div> 182 )} 183 184 <button 185 onClick={(e) => 186 handleSavePlaylist(e, playlist.id, playlist.name) 187 } 188 className="absolute top-2 right-2 p-2 bg-white/90 hover:bg-white rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100" 189 title="Unsave" 190 > 191 <Bookmark className="w-5 h-5 fill-blue-600 text-blue-600" /> 192 </button> 193 </div> 194 <p className="text-sm font-semibold text-gray-900 truncate group-hover:text-blue-600 transition-colors"> 195 {playlist.name} 196 </p> 197 <p className="text-xs text-gray-500 truncate"> 198 {playlist.creatorName} 199 </p> 200 </div> 201 ))} 202 </div> 203 </section> 204 )} 205 206 {likedSongs && likedSongs.length > 0 && ( 207 <section> 208 <div className="flex items-center gap-3 mb-6"> 209 <Heart className="w-6 h-6 text-red-500 fill-red-500" /> 210 <h3 className="text-2xl font-bold text-gray-800">Liked Songs</h3> 211 <span className="text-sm text-gray-500"> 212 ({likedSongs?.length}) 213 </span> 214 </div> 215 216 <div className="space-y-2"> 217 {likedSongs.map((song, index) => ( 218 <div 219 key={song.id} 220 className="flex items-center gap-4 p-3 rounded-lg hover:bg-gray-50 cursor-pointer group transition-colors" 221 onClick={() => navigate(`/musical-entity/${song.id}`)} 222 > 223 <span className="text-gray-500 font-medium w-8 text-center"> 224 {index + 1} 225 </span> 226 <div className="w-12 h-12 rounded bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center flex-shrink-0 shadow-sm"> 227 <Music className="w-6 h-6 text-white" /> 228 </div> 229 <div className="flex-1 min-w-0"> 230 <p className="font-semibold text-gray-900 truncate group-hover:text-blue-600 transition-colors"> 231 {song.title} 232 </p> 233 <p className="text-sm text-gray-600 truncate"> 234 {song.releasedBy} 235 </p> 236 </div> 237 <span className="text-sm text-gray-600 px-3 py-1 bg-gray-100 rounded-full"> 238 {song.genre} 239 </span> 240 241 <button 242 onClick={(e) => { 243 e.stopPropagation(); 244 handleLike(song.id, song.title); 245 }} 246 className="p-2 hover:bg-gray-200 rounded-full transition-colors duration-200 cursor-pointer" 247 title={song.isLikedByCurrentUser ? "Unlike" : "Like"} 248 > 249 <svg 250 className="w-5 h-5" 251 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"} 252 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"} 253 strokeWidth="2" 254 viewBox="0 0 24 24" 255 > 256 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 257 </svg> 258 </button> 259 </div> 260 ))} 261 </div> 262 </section> 263 )} 264 265 {likedAlbums && likedAlbums.length > 0 && ( 266 <section> 267 <div className="flex items-center gap-3 mb-6"> 268 <Album className="w-6 h-6 text-gray-700" /> 269 <h3 className="text-2xl font-bold text-gray-800">Liked Albums</h3> 270 <span className="text-sm text-gray-500"> 271 ({likedAlbums.length}) 272 </span> 273 </div> 274 275 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 276 {likedAlbums.map((album) => ( 277 <div 278 key={album.id} 279 className="group cursor-pointer" 280 onClick={() => navigate(`/collection/album/${album.id}`)} 281 > 282 <div className="relative aspect-square rounded-lg overflow-hidden bg-gradient-to-br from-blue-400 to-purple-500 mb-3 flex items-center justify-center shadow-md group-hover:shadow-lg transition-all"> 283 <Album className="w-16 h-16 text-white opacity-30" /> 284 285 <button 286 onClick={(e) => { 287 e.stopPropagation(); 288 handleLike(album.id, album.title); 289 }} 290 className="absolute top-2 right-2 p-2 bg-white/90 hover:bg-white rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100 cursor-pointer" 291 title={album.isLikedByCurrentUser ? "Unlike" : "Like"} 292 > 293 <svg 294 className="w-5 h-5" 295 fill={album.isLikedByCurrentUser ? "#ef4444" : "none"} 296 stroke={ 297 album.isLikedByCurrentUser ? "#ef4444" : "#6b7280" 298 } 299 strokeWidth="2" 300 viewBox="0 0 24 24" 301 > 302 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 303 </svg> 304 </button> 305 </div> 306 <p className="font-semibold text-sm text-gray-900 truncate group-hover:text-blue-600 transition-colors"> 307 {album.title} 308 </p> 309 <p className="text-xs text-gray-600 truncate mt-1"> 310 {album.releasedBy} 311 </p> 312 </div> 313 ))} 314 </div> 315 </section> 316 )} 317 318 {likedEntities && 319 likedEntities.length === 0 && 320 (!createdPlaylists || createdPlaylists.length === 0) && 321 (!savedItems || savedItems.length === 0) && ( 322 <div className="flex flex-col items-center justify-center py-16 text-gray-400"> 323 <Music className="w-20 h-20 mb-4 opacity-20" /> 324 <p className="text-lg font-medium">Nothing here yet</p> 325 <p className="text-sm mt-2"> 326 Start exploring music to build your collection 327 </p> 328 </div> 329 )} 330 </div> 331 ); 20 const navigate = useNavigate(); 21 const [items, setItems] = useState(likedEntities); 22 const [savedItems, setSavedItems] = useState(savedPlaylists); 23 24 const likedSongs = items.filter((e) => e.type === "SONG"); 25 const likedAlbums = items.filter((e) => e.type === "ALBUM"); 26 27 const handleSavePlaylist = async ( 28 e: React.MouseEvent, 29 playlistId: number, 30 playlistName: string, 31 ) => { 32 e.stopPropagation(); 33 34 try { 35 const response = await axiosInstance.post(`/playlist/${playlistId}/save`); 36 const data = response.data; 37 38 setSavedItems((prevItems) => 39 data.isSaved 40 ? [...prevItems, prevItems.find((p) => p.id === playlistId)!] 41 : prevItems.filter((p) => p.id !== playlistId), 42 ); 43 44 toast.success( 45 data.isSaved 46 ? `Saved "${playlistName}" to your library` 47 : `Removed "${playlistName}" from your library`, 48 ); 49 } catch (err: any) { 50 toast.error(err.response?.data?.error || "Failed to save the playlist"); 51 } 52 }; 53 54 const handleLike = async (id: number, title: string) => { 55 try { 56 const response = await axiosInstance.post(`/musical-entity/${id}/like`); 57 const data = response.data; 58 59 setItems((prevItems) => 60 prevItems.map((item) => 61 item.id === data.entityId 62 ? { ...item, isLikedByCurrentUser: data.isLiked } 63 : item, 64 ), 65 ); 66 67 toast.success( 68 data.isLiked ? `Liked "${title}"` : `Removed "${title}" from likes`, 69 ); 70 } catch (err: any) { 71 toast.error(err.response?.data?.error || "Failed to like the item"); 72 } 73 }; 74 75 return ( 76 <div className="mt-8 space-y-12"> 77 {createdPlaylists && createdPlaylists.length > 0 && ( 78 <section> 79 <div className="flex items-center gap-3 mb-6"> 80 <ListMusic className="w-6 h-6 text-[#1db954]" /> 81 <h3 className="text-2xl font-bold text-white">Created Playlists</h3> 82 <span className="text-sm text-gray-500"> 83 ({createdPlaylists.length}) 84 </span> 85 </div> 86 87 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 88 {createdPlaylists.map((playlist) => ( 89 <div 90 key={playlist.id} 91 className="group cursor-pointer" 92 onClick={() => navigate(`/collection/playlist/${playlist.id}`)} 93 > 94 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 95 <img 96 src={playlist.cover || "/favicon.png"} 97 alt={playlist.name} 98 className="w-full h-full object-cover" 99 onError={(e) => { 100 (e.target as HTMLImageElement).src = "/favicon.png"; 101 }} 102 /> 103 104 <button 105 onClick={(e) => 106 handleSavePlaylist(e, playlist.id, playlist.name) 107 } 108 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100" 109 title={playlist.isSavedByCurrentUser ? "Unsave" : "Save"} 110 > 111 <Bookmark 112 className={`w-5 h-5 ${ 113 playlist.isSavedByCurrentUser 114 ? "fill-[#1db954] text-[#1db954]" 115 : "text-gray-400" 116 }`} 117 /> 118 </button> 119 </div> 120 <p className="text-sm font-semibold text-white truncate group-hover:text-[#1db954] transition-colors"> 121 {playlist.name} 122 </p> 123 <p className="text-xs text-gray-400 truncate"> 124 {playlist.creatorName} 125 </p> 126 </div> 127 ))} 128 </div> 129 </section> 130 )} 131 132 {savedItems && savedItems.length > 0 && ( 133 <section> 134 <div className="flex items-center gap-3 mb-6"> 135 <Bookmark className="w-6 h-6 text-[#1db954] fill-[#1db954]" /> 136 <h3 className="text-2xl font-bold text-white">Saved Playlists</h3> 137 <span className="text-sm text-gray-500">({savedItems.length})</span> 138 </div> 139 140 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 141 {savedItems.map((playlist) => ( 142 <div 143 key={playlist.id} 144 className="group cursor-pointer" 145 onClick={() => navigate(`/collection/playlist/${playlist.id}`)} 146 > 147 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 148 <img 149 src={playlist.cover || "/favicon.png"} 150 alt={playlist.name} 151 className="w-full h-full object-cover" 152 onError={(e) => { 153 (e.target as HTMLImageElement).src = "/favicon.png"; 154 }} 155 /> 156 157 <button 158 onClick={(e) => 159 handleSavePlaylist(e, playlist.id, playlist.name) 160 } 161 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100" 162 title="Unsave" 163 > 164 <Bookmark className="w-5 h-5 fill-[#1db954] text-[#1db954]" /> 165 </button> 166 </div> 167 <p className="text-sm font-semibold text-white truncate group-hover:text-[#1db954] transition-colors"> 168 {playlist.name} 169 </p> 170 <p className="text-xs text-gray-400 truncate"> 171 {playlist.creatorName} 172 </p> 173 </div> 174 ))} 175 </div> 176 </section> 177 )} 178 179 {likedSongs && likedSongs.length > 0 && ( 180 <section> 181 <div className="flex items-center gap-3 mb-6"> 182 <Heart className="w-6 h-6 text-red-500 fill-red-500" /> 183 <h3 className="text-2xl font-bold text-white">Liked Songs</h3> 184 <span className="text-sm text-gray-500"> 185 ({likedSongs?.length}) 186 </span> 187 </div> 188 189 <div className="space-y-1"> 190 {likedSongs.map((song, index) => ( 191 <SongItem 192 key={song.id} 193 song={{ 194 id: song.id, 195 title: song.title, 196 cover: song.cover, 197 genre: song.genre, 198 link: (song as any).link, 199 releasedBy: song.releasedBy, 200 isLikedByCurrentUser: song.isLikedByCurrentUser, 201 }} 202 index={index + 1} 203 onLikeToggle={() => handleLike(song.id, song.title)} 204 /> 205 ))} 206 </div> 207 </section> 208 )} 209 210 {likedAlbums && likedAlbums.length > 0 && ( 211 <section> 212 <div className="flex items-center gap-3 mb-6"> 213 <Album className="w-6 h-6 text-[#1db954]" /> 214 <h3 className="text-2xl font-bold text-white">Liked Albums</h3> 215 <span className="text-sm text-gray-500"> 216 ({likedAlbums.length}) 217 </span> 218 </div> 219 220 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 221 {likedAlbums.map((album) => ( 222 <div 223 key={album.id} 224 className="group cursor-pointer" 225 onClick={() => navigate(`/collection/album/${album.id}`)} 226 > 227 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 228 <img 229 src={album.cover || "/favicon.png"} 230 alt={album.title} 231 className="w-full h-full object-cover" 232 onError={(e) => { 233 (e.target as HTMLImageElement).src = "/favicon.png"; 234 }} 235 /> 236 237 <button 238 onClick={(e) => { 239 e.stopPropagation(); 240 handleLike(album.id, album.title); 241 }} 242 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100 cursor-pointer" 243 title={album.isLikedByCurrentUser ? "Unlike" : "Like"} 244 > 245 <svg 246 className="w-5 h-5" 247 fill={album.isLikedByCurrentUser ? "#ef4444" : "none"} 248 stroke={ 249 album.isLikedByCurrentUser ? "#ef4444" : "#9ca3af" 250 } 251 strokeWidth="2" 252 viewBox="0 0 24 24" 253 > 254 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 255 </svg> 256 </button> 257 </div> 258 <p className="font-semibold text-sm text-white truncate group-hover:text-[#1db954] transition-colors"> 259 {album.title} 260 </p> 261 <p className="text-xs text-gray-400 truncate mt-1"> 262 {album.releasedBy} 263 </p> 264 </div> 265 ))} 266 </div> 267 </section> 268 )} 269 270 {likedEntities && 271 likedEntities.length === 0 && 272 (!createdPlaylists || createdPlaylists.length === 0) && 273 (!savedItems || savedItems.length === 0) && ( 274 <div className="flex flex-col items-center justify-center py-16 text-gray-500"> 275 <Music className="w-20 h-20 mb-4 opacity-20" /> 276 <p className="text-lg font-medium">Nothing here yet</p> 277 <p className="text-sm mt-2"> 278 Start exploring music to build your collection 279 </p> 280 </div> 281 )} 282 </div> 283 ); 332 284 }; 333 285 -
frontend/src/components/userProfile/UserListModal.tsx
r0808ef2 r1579b4f 1 1 import { useNavigate } from "react-router-dom"; 2 import { baseURL } from "../../api/axiosInstance"; 2 3 import type { BaseNonAdminUser } from "../../utils/types"; 3 4 … … 16 17 }: ModalProps) => { 17 18 const navigate = useNavigate(); 18 const baseURL = import.meta.env.VITE_API_BASE_URL;19 19 20 20 return ( 21 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/ 50 backdrop-blur-sm">22 <div className="bg- whiterounded-xl shadow-2xl w-full max-w-md max-h-[70vh] flex flex-col">23 <div className="p-4 border-b flex justify-between items-center">24 <h2 className="text-xl font-bold ">{title}</h2>21 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> 22 <div className="bg-[#1a1a2e] border border-white/10 rounded-xl shadow-2xl w-full max-w-md max-h-[70vh] flex flex-col"> 23 <div className="p-4 border-b border-white/10 flex justify-between items-center"> 24 <h2 className="text-xl font-bold text-white">{title}</h2> 25 25 <button 26 26 onClick={onClose} 27 className="text-gray- 500 hover:text-black text-2xl cursor-pointer"27 className="text-gray-400 hover:text-white text-2xl cursor-pointer transition-colors" 28 28 > 29 29 × … … 38 38 <div 39 39 key={u.username} 40 className="flex items-center justify-between p-3 hover:bg- gray-50rounded-lg transition-colors"40 className="flex items-center justify-between p-3 hover:bg-white/5 rounded-lg transition-colors" 41 41 > 42 42 <div … … 47 47 }} 48 48 > 49 <div className="w-10 h-10 rounded-full bg- blue-100overflow-hidden shrink-0 flex items-center justify-center">49 <div className="w-10 h-10 rounded-full bg-[#282828] overflow-hidden shrink-0 flex items-center justify-center"> 50 50 {u.profilePhoto ? ( 51 51 <img … … 55 55 /> 56 56 ) : ( 57 <span className="text- blue-600font-bold">57 <span className="text-[#1db954] font-bold"> 58 58 {u.fullName.charAt(0)} 59 59 </span> 60 60 )} 61 61 </div> 62 <p className="font-semibold text-gray-900">{u.fullName}</p> 62 <div> 63 <p className="font-semibold text-white">{u.fullName}</p> 64 <p className="text-sm text-gray-400">@{u.username}</p> 65 </div> 63 66 </div> 64 67 65 68 <button 66 69 onClick={() => onFollowToggle(u.username)} 67 className={`px-4 py-1 text-sm font-medium rounded-mdtransition-colors cursor-pointer ${70 className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors cursor-pointer ${ 68 71 u.isFollowedByCurrentUser 69 ? "bg- gray-200 text-gray-700 hover:bg-gray-300"70 : "bg- blue-500 text-white hover:bg-blue-600"72 ? "bg-white/10 text-white hover:bg-white/20" 73 : "bg-[#1db954] text-black hover:bg-[#1ed760]" 71 74 }`} 72 75 > 73 {u.isFollowedByCurrentUser ? " Unfollow" : "Follow"}76 {u.isFollowedByCurrentUser ? "Following" : "Follow"} 74 77 </button> 75 78 </div> -
frontend/src/pages/LandingPage.tsx
r0808ef2 r1579b4f 12 12 Song, 13 13 } from "../utils/types"; 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 }; 14 import { toEmbedUrl } from "../utils/utils"; 34 15 35 16 const CATEGORIES: { value: SearchCategory; label: string }[] = [ -
frontend/src/pages/MusicalCollection.tsx
r0808ef2 r1579b4f 1 1 import { useEffect, useState } from "react"; 2 import { useNavigate, useParams } from "react-router-dom";2 import { Link, useParams } from "react-router-dom"; 3 3 import axiosInstance from "../api/axiosInstance"; 4 import LoadingSpinner from "../components/LoadingSpinner"; 5 import type { Song, Album, Playlist } from "../utils/types"; 4 import SongItem from "../components/SongItem"; 6 5 import { handleError } from "../utils/error"; 6 import type { Album, Playlist, Song } from "../utils/types"; 7 7 interface CollectionView { 8 id: number; 9 title: string; 10 genre?: string; 11 type: string; 12 releasedBy: string; 13 isLikedByCurrentUser?: boolean; 14 songs: Song[]; 8 id: number; 9 title: string; 10 cover?: string | null; 11 genre?: string; 12 type: string; 13 releasedBy: string; 14 isLikedByCurrentUser?: boolean; 15 songs: Song[]; 15 16 } 16 17 17 18 const MusicalCollection = () => { 18 const { type, id } = useParams(); 19 const navigate = useNavigate(); 20 const [collection, setCollection] = useState<CollectionView | null>(null); 21 const [isLoading, setIsLoading] = useState(true); 22 const [error, setError] = useState<string | null>(null); 23 24 const normalizeCollection = ( 25 data: Album | Playlist, 26 type: string, 27 ): CollectionView => { 28 if (type === "album") { 29 const album = data as Album; 30 return { 31 id: album.id, 32 title: album.title, 33 genre: album.genre, 34 type: album.type, 35 releasedBy: album.releasedBy, 36 isLikedByCurrentUser: album.isLikedByCurrentUser, 37 songs: album.songs, 38 }; 39 } else { 40 const playlist = data as Playlist; 41 return { 42 id: playlist.id, 43 title: playlist.name, 44 genre: undefined, 45 type: "PLAYLIST", 46 releasedBy: playlist.creatorName, 47 isLikedByCurrentUser: undefined, 48 songs: playlist.songsInPlaylist, 49 }; 50 } 51 }; 52 53 useEffect(() => { 54 const fetchData = async () => { 55 setIsLoading(true); 56 setError(null); 57 try { 58 const endpoint = 59 type === "album" ? `/albums/${id}` : `/playlists/${id}`; 60 const response = await axiosInstance.get(endpoint); 61 62 const normalized = normalizeCollection(response.data, type!); 63 setCollection(normalized); 64 } catch (err: any) { 65 setError(handleError(err)); 66 } finally { 67 setIsLoading(false); 68 } 69 }; 70 fetchData(); 71 }, [id, type]); 72 73 if (isLoading) { 74 return <LoadingSpinner />; 75 } 76 77 if (error) { 78 return ( 79 <div className="p-6 bg-red-50 border border-red-200 text-red-700 rounded-lg"> 80 <h2 className="font-bold">Error</h2> 81 <p>{error}</p> 82 </div> 83 ); 84 } 85 86 if (!collection) return null; 87 88 return ( 89 <div className="container mx-auto p-6"> 90 <button 91 onClick={() => navigate(-1)} 92 className="mb-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors duration-200 cursor-pointer" 93 > 94 ← Back 95 </button> 96 97 <div className="bg-white shadow-lg rounded-lg p-8"> 98 <div className="flex items-start gap-6 mb-8"> 99 <div className="shrink-0"> 100 <div className="w-40 h-40 rounded-lg bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-5xl font-bold shadow-lg"> 101 {collection.title.charAt(0).toUpperCase()} 102 </div> 103 </div> 104 105 <div className="flex-1"> 106 <span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium mb-2"> 107 {collection.type} 108 </span> 109 <h1 className="text-4xl font-bold mb-3">{collection.title}</h1> 110 111 <div className="flex items-center gap-3 text-gray-700 mb-4"> 112 <span className="font-semibold">{collection.releasedBy}</span> 113 {collection.genre && ( 114 <> 115 <span>•</span> 116 <span className="text-gray-600">{collection.genre}</span> 117 </> 118 )} 119 {collection.songs && ( 120 <> 121 <span>•</span> 122 <span className="text-gray-600"> 123 {collection.songs.length} song 124 {collection.songs.length !== 1 ? "s" : ""} 125 </span> 126 </> 127 )} 128 </div> 129 130 {type === "album" && ( 131 <button 132 className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200" 133 aria-label={collection.isLikedByCurrentUser ? "Unlike" : "Like"} 134 > 135 <svg 136 className="w-5 h-5" 137 fill={collection.isLikedByCurrentUser ? "#ef4444" : "none"} 138 stroke={ 139 collection.isLikedByCurrentUser ? "#ef4444" : "#6b7280" 140 } 141 strokeWidth="2" 142 viewBox="0 0 24 24" 143 > 144 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 145 </svg> 146 <span className="text-sm font-medium text-gray-700"> 147 {collection.isLikedByCurrentUser ? "Liked" : "Like"} 148 </span> 149 </button> 150 )} 151 </div> 152 </div> 153 154 <div className="border-t pt-6"> 155 <h2 className="text-2xl font-bold mb-4 text-gray-800">Songs</h2> 156 157 {collection.songs && collection.songs.length > 0 ? ( 158 <div className="space-y-2"> 159 {collection.songs.map((song, index) => ( 160 <div 161 key={song.id} 162 className="flex items-center gap-4 p-3 rounded-lg hover:bg-gray-50 transition-colors duration-150" 163 > 164 <span className="text-gray-500 font-medium w-8 text-center"> 165 {index + 1} 166 </span> 167 168 <div className="flex-1 min-w-0"> 169 <p className="font-semibold text-gray-900 truncate"> 170 {song.title} 171 </p> 172 <p className="text-sm text-gray-600 truncate"> 173 {song.releasedBy} 174 </p> 175 </div> 176 177 <span className="text-sm text-gray-600 px-3 py-1 bg-gray-100 rounded-full"> 178 {song.genre} 179 </span> 180 181 <button 182 className="p-2 hover:bg-gray-100 rounded-full transition-colors duration-200" 183 aria-label={song.isLikedByCurrentUser ? "Unlike" : "Like"} 184 > 185 <svg 186 className="w-5 h-5" 187 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"} 188 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"} 189 strokeWidth="2" 190 viewBox="0 0 24 24" 191 > 192 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 193 </svg> 194 </button> 195 </div> 196 ))} 197 </div> 198 ) : ( 199 <p className="text-center text-gray-500 py-8">No songs available</p> 200 )} 201 </div> 202 </div> 203 </div> 204 ); 19 const { type, id } = useParams(); 20 const [collection, setCollection] = useState<CollectionView | null>(null); 21 const [isLoading, setIsLoading] = useState(true); 22 const [error, setError] = useState<string | null>(null); 23 24 const normalizeCollection = ( 25 data: Album | Playlist, 26 type: string, 27 ): CollectionView => { 28 if (type === "album") { 29 const album = data as Album; 30 return { 31 id: album.id, 32 title: album.title, 33 cover: album.cover, 34 genre: album.genre, 35 type: album.type, 36 releasedBy: album.releasedBy, 37 isLikedByCurrentUser: album.isLikedByCurrentUser, 38 songs: album.songs, 39 }; 40 } else { 41 const playlist = data as Playlist; 42 return { 43 id: playlist.id, 44 title: playlist.name, 45 cover: playlist.cover, 46 genre: undefined, 47 type: "PLAYLIST", 48 releasedBy: playlist.creatorName, 49 isLikedByCurrentUser: undefined, 50 songs: playlist.songsInPlaylist, 51 }; 52 } 53 }; 54 55 const toggleLike = async (songId: number) => { 56 try { 57 await axiosInstance.post(`/musical-entity/${songId}/like`); 58 setCollection((prev) => { 59 if (!prev) return null; 60 return { 61 ...prev, 62 songs: prev.songs.map((s) => 63 s.id === songId 64 ? { ...s, isLikedByCurrentUser: !s.isLikedByCurrentUser } 65 : s, 66 ), 67 }; 68 }); 69 } catch (err) { 70 console.error("Error toggling like:", err); 71 } 72 }; 73 74 const toggleCollectionLike = async () => { 75 if (!collection) return; 76 try { 77 await axiosInstance.post(`/musical-entity/${collection.id}/like`); 78 setCollection((prev) => { 79 if (!prev) return null; 80 return { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser }; 81 }); 82 } catch (err) { 83 console.error("Error toggling collection like:", err); 84 } 85 }; 86 87 useEffect(() => { 88 const fetchData = async () => { 89 setIsLoading(true); 90 setError(null); 91 try { 92 const endpoint = 93 type === "album" ? `/albums/${id}` : `/playlists/${id}`; 94 const response = await axiosInstance.get(endpoint); 95 96 const normalized = normalizeCollection(response.data, type!); 97 setCollection(normalized); 98 } catch (err: any) { 99 setError(handleError(err)); 100 } finally { 101 setIsLoading(false); 102 } 103 }; 104 fetchData(); 105 }, [id, type]); 106 107 if (isLoading) { 108 return ( 109 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 110 <div className="flex flex-col items-center gap-4"> 111 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 112 <p className="text-gray-400 text-lg">Loading collection…</p> 113 </div> 114 </div> 115 ); 116 } 117 118 if (error) { 119 return ( 120 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 121 <div className="text-center"> 122 <p className="text-red-400 text-xl mb-4">{error}</p> 123 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 124 ← Back to Home 125 </Link> 126 </div> 127 </div> 128 ); 129 } 130 131 if (!collection) return null; 132 133 return ( 134 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 135 <div className="max-w-5xl mx-auto px-6 py-10"> 136 {/* Back link */} 137 <Link 138 to="/" 139 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 140 > 141 <svg 142 className="w-4 h-4" 143 fill="none" 144 stroke="currentColor" 145 viewBox="0 0 24 24" 146 > 147 <path 148 strokeLinecap="round" 149 strokeLinejoin="round" 150 strokeWidth={2} 151 d="M15 19l-7-7 7-7" 152 /> 153 </svg> 154 Back to Home 155 </Link> 156 157 {/* Hero section */} 158 <div className="flex flex-col md:flex-row gap-8 mb-10"> 159 {/* Cover art */} 160 <div className="w-full md:w-72 shrink-0"> 161 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]"> 162 <img 163 src={collection.cover || "/favicon.png"} 164 alt={collection.title} 165 className="absolute inset-0 w-full h-full object-cover" 166 onError={(e) => { 167 (e.target as HTMLImageElement).src = "/favicon.png"; 168 }} 169 /> 170 </div> 171 </div> 172 173 {/* Collection info */} 174 <div className="flex flex-col justify-end gap-3 min-w-0"> 175 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 176 {collection.genre ? `${collection.genre} • ` : ""} 177 {collection.type === "PLAYLIST" ? "Playlist" : "Album"} 178 </span> 179 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate"> 180 {collection.title} 181 </h1> 182 183 <p className="text-xl text-gray-300 font-semibold"> 184 {collection.releasedBy} 185 </p> 186 187 {collection.songs && ( 188 <p className="text-sm text-gray-500"> 189 {collection.songs.length} song 190 {collection.songs.length !== 1 ? "s" : ""} 191 </p> 192 )} 193 194 {/* Action buttons */} 195 <div className="flex items-center gap-3 mt-4"> 196 {type === "album" && ( 197 <button 198 onClick={toggleCollectionLike} 199 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${ 200 collection.isLikedByCurrentUser 201 ? "bg-[#1db954] text-black" 202 : "bg-white/10 text-white hover:bg-white/20" 203 }`} 204 > 205 <svg 206 className="w-5 h-5" 207 fill={ 208 collection.isLikedByCurrentUser ? "currentColor" : "none" 209 } 210 stroke="currentColor" 211 viewBox="0 0 24 24" 212 > 213 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 214 </svg> 215 {collection.isLikedByCurrentUser ? "Liked" : "Like"} 216 </button> 217 )} 218 </div> 219 </div> 220 </div> 221 222 {/* Songs list */} 223 <div className="border-t border-white/10 pt-6"> 224 <h2 className="text-2xl font-bold mb-4">Songs</h2> 225 226 {collection.songs && collection.songs.length > 0 ? ( 227 <div className="space-y-1"> 228 {collection.songs.map((song, index) => ( 229 <SongItem 230 key={song.id} 231 song={song} 232 index={index + 1} 233 onLikeToggle={() => toggleLike(song.id)} 234 /> 235 ))} 236 </div> 237 ) : ( 238 <div className="text-center py-12 text-gray-400"> 239 <p className="text-lg">No songs available</p> 240 </div> 241 )} 242 </div> 243 </div> 244 </div> 245 ); 205 246 }; 206 247 -
frontend/src/pages/SongDetail.tsx
r0808ef2 r1579b4f 1 1 import { useEffect, useRef, useState } from "react"; 2 2 import { Link, useParams } from "react-router-dom"; 3 import { toast } from "react-toastify"; 3 4 import axiosInstance from "../api/axiosInstance"; 4 5 import { useAuth } from "../context/authContext"; 5 6 import { usePlayer } from "../context/playerContext"; 6 7 import type { SongDetail as SongDetailType } from "../utils/types"; 8 import { toEmbedUrl } from "../utils/utils"; 7 9 8 10 const ROLE_LABELS: Record<string, string> = { … … 18 20 const formatRole = (role: string): string => { 19 21 return ROLE_LABELS[role] || role.replace(/_/g, " "); 20 };21 22 // convert a regular youtube URL to an embeddable URL23 const toEmbedUrl = (url: string): string => {24 try {25 const parsed = new URL(url);26 // youtube.com/watch?v=ID27 if (28 (parsed.hostname === "www.youtube.com" ||29 parsed.hostname === "youtube.com") &&30 parsed.searchParams.has("v")31 ) {32 return `https://www.youtube.com/embed/${parsed.searchParams.get("v")}`;33 }34 // youtu.be/ID35 if (parsed.hostname === "youtu.be") {36 return `https://www.youtube.com/embed${parsed.pathname}`;37 }38 // already an embed URL or other provider – return as-is39 return url;40 } catch {41 return url;42 }43 22 }; 44 23 … … 137 116 setSong(response.data); 138 117 } catch (err) { 139 // todo :add toast118 toast.error("Failed to delete review"); 140 119 console.error("Error deleting review:", err); 141 120 } … … 152 131 ); 153 132 } catch (err) { 133 toast.error("Failed to toggle like"); 154 134 console.error("Error toggling like:", err); 155 135 } -
frontend/src/pages/UserDetail.tsx
r0808ef2 r1579b4f 1 1 import { useEffect, useState } from "react"; 2 import { useNavigate, useParams } from "react-router-dom";3 import axiosInstance from "../api/axiosInstance";2 import { Link, useNavigate, useParams } from "react-router-dom"; 3 import axiosInstance, { baseURL } from "../api/axiosInstance"; 4 4 import LoadingSpinner from "../components/LoadingSpinner"; 5 5 import ArtistView from "../components/userProfile/ArtistView"; … … 35 35 36 36 const UserDetail = () => { 37 // user refers to the selected user NOT to the user from context38 const baseURL = import.meta.env.VITE_API_BASE_URL;39 37 const { username: usernameParam } = useParams(); 40 // sintaksava dole znaci zemi go user od auth context i preimenuvaj go vo currentUser, za da ne se izmesa so user-ot dole41 38 const { user: currentUser } = useAuth(); 42 39 const navigate = useNavigate(); … … 49 46 const [isFollowing, setIsFollowing] = useState(false); 50 47 51 // determine which username to use: URL param or current user's username52 48 const username = usernameParam || currentUser?.username; 53 54 // if we're on /me route and no user is logged in, show error 49 const isOwnProfile = currentUser?.username === username; 50 55 51 if (!usernameParam && !currentUser) { 56 52 return ( 57 <div className="p-6 bg-red-50 border border-red-200 text-red-700 rounded-lg"> 58 <h2 className="font-bold">Authentication Required</h2> 59 <p>You must be logged in to view your profile.</p> 60 <button 61 onClick={() => navigate("/login")} 62 className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors duration-200 cursor-pointer" 63 > 64 Go to Login 65 </button> 53 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 54 <div className="text-center"> 55 <p className="text-red-400 text-xl mb-4"> 56 You must be logged in to view your profile. 57 </p> 58 <button 59 onClick={() => navigate("/login")} 60 className="text-[#1db954] hover:underline text-sm cursor-pointer" 61 > 62 Go to Login 63 </button> 64 </div> 66 65 </div> 67 66 ); … … 95 94 try { 96 95 const response = await axiosInstance.post<FollowStatus>( 97 `/users/na/${ username}/follow`,96 `/users/na/${targetUsername}/follow`, 98 97 ); 99 98 … … 105 104 ), 106 105 ); 107 108 // if (user && user.id === targetId) {109 // setUser((prev) => {110 // if (!prev) return null;111 // return {112 // ...prev,113 // isFollowedByCurrentUser: response.data.isFollowing,114 // followers: response.data.followerCount,115 // };116 // });117 // }118 106 } catch (err: any) { 119 107 setError(handleError(err)); … … 157 145 try { 158 146 const response = await axiosInstance.get(`/users/na/${username}`); 159 console.log(response.data);160 147 setUser(response.data); 161 148 } catch (err: any) { … … 168 155 if (error) { 169 156 return ( 170 <div className="p-6 bg-red-50 border border-red-200 text-red-700 rounded-lg"> 171 <h2 className="font-bold">Error</h2> 172 <p>{error}</p> 157 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 158 <div className="text-center"> 159 <p className="text-red-400 text-xl mb-4">{error}</p> 160 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 161 ← Back to Home 162 </Link> 163 </div> 173 164 </div> 174 165 ); … … 180 171 181 172 return ( 182 <div className=" container mx-auto p-6">173 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 183 174 {isLoadingModal && ( 184 <div className="fixed inset-0 z-40 bg-black/30 backdrop-blur-sm flex items-center justify-center"> 185 <div className="flex items-center gap-3"> 186 <div className="w-6 h-6 border-3 border-blue-500 border-t-transparent rounded-full animate-spin"></div> 187 </div> 175 <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm flex items-center justify-center"> 176 <div className="w-10 h-10 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 188 177 </div> 189 178 )} 190 <button 191 onClick={() => navigate(-1)} 192 className="mb-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors duration-200 cursor-pointer" 193 > 194 ← Back 195 </button> 196 197 <div className="bg-white shadow-lg rounded-lg p-8"> 198 <div className="flex items-start gap-6 mb-8"> 199 <div className="shrink-0"> 200 <div className="w-32 h-32 rounded-full bg-linear-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-4xl font-bold shadow-lg overflow-hidden"> 179 180 <div className="max-w-5xl mx-auto px-6 py-10"> 181 {/* Back link */} 182 <Link 183 to="/" 184 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 185 > 186 <svg 187 className="w-4 h-4" 188 fill="none" 189 stroke="currentColor" 190 viewBox="0 0 24 24" 191 > 192 <path 193 strokeLinecap="round" 194 strokeLinejoin="round" 195 strokeWidth={2} 196 d="M15 19l-7-7 7-7" 197 /> 198 </svg> 199 Back to Home 200 </Link> 201 202 {/* Hero section */} 203 <div className="flex flex-col md:flex-row gap-8 mb-10"> 204 {/* Profile photo */} 205 <div className="w-full md:w-48 shrink-0"> 206 <div className="relative w-48 h-48 rounded-full overflow-hidden shadow-2xl bg-[#181818] mx-auto md:mx-0"> 201 207 {user.profilePhoto ? ( 202 208 <img … … 206 212 /> 207 213 ) : ( 208 user.fullName.charAt(0).toUpperCase() 214 <div className="w-full h-full bg-linear-to-br from-[#1db954] to-[#1ed760] flex items-center justify-center text-white text-5xl font-bold"> 215 {user.fullName.charAt(0).toUpperCase()} 216 </div> 209 217 )} 210 218 </div> 211 219 </div> 212 220 213 <div className="flex-1">214 <h1 className="text-4xl font-bold mb-2">{user.fullName}</h1>215 <span className=" inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium mb-4">216 {user.userType }221 {/* User info */} 222 <div className="flex flex-col justify-end gap-3 min-w-0"> 223 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 224 {user.userType === "ARTIST" ? "Artist" : "Listener"} • Profile 217 225 </span> 218 219 <div className="flex gap-6 mb-4 text-gray-700"> 226 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight"> 227 {user.fullName} 228 </h1> 229 <p className="text-gray-400">@{user.username}</p> 230 231 {/* Stats */} 232 <div className="flex items-center gap-6 mt-2"> 220 233 <div 221 className={` flex flex-col ${user.userType == "LISTENER" ? "cursor-pointer" : "cursor-default"}`}234 className={`${user.userType === "LISTENER" ? "cursor-pointer hover:text-white" : ""} transition-colors`} 222 235 onClick={ 223 236 user.userType === "LISTENER" ? displayFollowers : undefined 224 237 } 225 238 > 226 <span className="text-2xl font-bold">{user.followers}</span> 227 <span className="text-sm text-gray-500">Followers</span> 239 <span className="text-xl font-bold text-white"> 240 {user.followers} 241 </span> 242 <span className="text-sm text-gray-400 ml-1">Followers</span> 228 243 </div> 229 244 <div 230 className={` flex flex-col ${user.userType == "LISTENER" ? "cursor-pointer" : "cursor-default"}`}245 className={`${user.userType === "LISTENER" ? "cursor-pointer hover:text-white" : ""} transition-colors`} 231 246 onClick={ 232 247 user.userType === "LISTENER" ? displayFollowing : undefined 233 248 } 234 249 > 235 <span className="text-2xl font-bold">{user.following}</span> 236 <span className="text-sm text-gray-500">Following</span> 250 <span className="text-xl font-bold text-white"> 251 {user.following} 252 </span> 253 <span className="text-sm text-gray-400 ml-1">Following</span> 237 254 </div> 238 255 </div> 239 256 240 <button 241 onClick={handleFollow} 242 disabled={isFollowing} 243 className={` 244 px-6 py-2 font-semibold rounded-lg shadow-md 245 transition-colors duration-200 246 ${ 247 isFollowing 248 ? "bg-gray-400 text-gray-200 cursor-not-allowed" 249 : user.isFollowedByCurrentUser 250 ? "bg-gray-200 text-gray-700 hover:bg-gray-300 cursor-pointer" 251 : "bg-blue-500 text-white hover:bg-blue-600 cursor-pointer" 252 } 253 `} 254 > 255 {user.isFollowedByCurrentUser ? "Unfollow" : "Follow"} 256 </button> 257 {/* Follow button - hidden on own profile */} 258 {!isOwnProfile && ( 259 <div className="mt-4"> 260 <button 261 onClick={handleFollow} 262 disabled={isFollowing} 263 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${ 264 isFollowing 265 ? "bg-gray-700 text-gray-400 cursor-not-allowed" 266 : user.isFollowedByCurrentUser 267 ? "bg-white/10 text-white hover:bg-white/20" 268 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105" 269 }`} 270 > 271 {user.isFollowedByCurrentUser ? ( 272 <> 273 <svg 274 className="w-5 h-5" 275 fill="none" 276 stroke="currentColor" 277 viewBox="0 0 24 24" 278 > 279 <path 280 strokeLinecap="round" 281 strokeLinejoin="round" 282 strokeWidth={2} 283 d="M5 13l4 4L19 7" 284 /> 285 </svg> 286 Following 287 </> 288 ) : ( 289 <> 290 <svg 291 className="w-5 h-5" 292 fill="none" 293 stroke="currentColor" 294 viewBox="0 0 24 24" 295 > 296 <path 297 strokeLinecap="round" 298 strokeLinejoin="round" 299 strokeWidth={2} 300 d="M12 4v16m8-8H4" 301 /> 302 </svg> 303 Follow 304 </> 305 )} 306 </button> 307 </div> 308 )} 257 309 </div> 258 310 </div> 259 311 312 {/* Content */} 260 313 {user.userType === "ARTIST" ? ( 261 314 <ArtistView contributions={user.contributions} /> -
frontend/src/utils/types.ts
r0808ef2 r1579b4f 15 15 entityType: string; 16 16 isLikedByCurrentUser: boolean; 17 cover?: string | null; 18 link?: string | null; 17 19 } 18 20
Note:
See TracChangeset
for help on using the changeset viewer.
