Changeset 1579b4f


Ignore:
Timestamp:
02/10/26 21:58:39 (5 months ago)
Author:
Filip Gavrilovski <filipgavrilovski28@…>
Branches:
main
Children:
92db381
Parents:
0808ef2
Message:

refactor artist, collection, listener components to have similar style as other components; refactor some dto-s

Files:
3 added
18 edited

Legend:

Unmodified
Added
Removed
  • finkwave/src/main/java/com/ukim/finki/develop/finkwave/controller/PlaylistController.java

    r0808ef2 r1579b4f  
    55import com.ukim.finki.develop.finkwave.service.PlaylistService;
    66import lombok.AllArgsConstructor;
    7 import lombok.NoArgsConstructor;
    87import org.springframework.http.HttpEntity;
    98import org.springframework.http.ResponseEntity;
  • finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/AlbumDto.java

    r0808ef2 r1579b4f  
    1111@AllArgsConstructor
    1212public class AlbumDto extends MusicalEntityDto{
    13     private List<MusicalEntityDto>songs;
     13    private List<SongWithLinkDto>songs;
    1414
    1515    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  
    1515    private String entityType;
    1616    private Boolean isLikedByCurrentUser;
     17    private String cover;
     18    private String link;
    1719}
  • finkwave/src/main/java/com/ukim/finki/develop/finkwave/model/dto/PlaylistDto.java

    r0808ef2 r1579b4f  
    1515    private String cover;
    1616    private String creatorName;
    17     private List<MusicalEntityDto>songsInPlaylist;
     17    private List<SongWithLinkDto>songsInPlaylist;
    1818    private Boolean isSavedByCurrentUser;
    1919
  • finkwave/src/main/java/com/ukim/finki/develop/finkwave/repository/ArtistContributionRepository.java

    r0808ef2 r1579b4f  
    11package com.ukim.finki.develop.finkwave.repository;
    22
     3import java.util.List;
     4
     5import org.springframework.data.jpa.repository.JpaRepository;
     6import org.springframework.data.jpa.repository.Query;
     7import org.springframework.data.repository.query.Param;
     8import org.springframework.stereotype.Repository;
     9
     10import com.ukim.finki.develop.finkwave.model.ArtistContribution;
     11import com.ukim.finki.develop.finkwave.model.ArtistContributionId;
    312import com.ukim.finki.develop.finkwave.model.dto.ArtistContributionDto;
    413import 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;
    1314
    1415@Repository
     
    2122                  WHEN a.id IS NOT NULL THEN 'ALBUM'
    2223                  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)
    2427        FROM ArtistContribution ac
    2528        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  
    33import java.util.List;
    44
     5import com.ukim.finki.develop.finkwave.model.dto.*;
    56import org.springframework.data.jpa.repository.JpaRepository;
    67import org.springframework.data.jpa.repository.Query;
     
    910
    1011import 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;
    1512
    1613@Repository
     
    1916
    2017    @Query("""
    21         SELECT NEW com.ukim.finki.develop.finkwave.model.dto.MusicalEntityDto(
     18        SELECT NEW com.ukim.finki.develop.finkwave.model.dto.SongWithLinkDto(
    2219            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
    2422        )
    2523        FROM Song s
     
    3129        WHERE s.album.id = :albumId
    3230    """)
    33     List<MusicalEntityDto>findSongsByAlbum(@Param("albumId") Long albumId, @Param("currentUserId")Long currentUserId);
     31    List<SongWithLinkDto>findSongsByAlbum(@Param("albumId") Long albumId, @Param("currentUserId")Long currentUserId);
    3432
    3533
    3634    @Query("""
    37         SELECT NEW com.ukim.finki.develop.finkwave.model.dto.MusicalEntityDto(
     35        SELECT NEW com.ukim.finki.develop.finkwave.model.dto.SongWithLinkDto(
    3836            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            )
    4040        FROM Song s
    4141        JOIN s.musicalEntities me
     
    4747        WHERE ps.playlist.id=:playlistId
    4848    """)
    49     List<MusicalEntityDto>findSongsByPlaylistId(@Param("playlistId")Long playlistId, @Param("currentUserId")Long currentUserId);
     49    List<SongWithLinkDto>findSongsByPlaylistId(@Param("playlistId")Long playlistId, @Param("currentUserId")Long currentUserId);
    5050
    5151    @Query("""
  • finkwave/src/main/java/com/ukim/finki/develop/finkwave/service/AlbumService.java

    r0808ef2 r1579b4f  
    66import com.ukim.finki.develop.finkwave.model.dto.AlbumDto;
    77import com.ukim.finki.develop.finkwave.model.dto.MusicalEntityDto;
     8import com.ukim.finki.develop.finkwave.model.dto.SongWithLinkDto;
    89import com.ukim.finki.develop.finkwave.repository.AlbumRepository;
    910import com.ukim.finki.develop.finkwave.repository.LikeRepository;
     
    3738                album.getMusicalEntities().getCover(),
    3839                likeRepository.isLikedByUser(currentUserId));
    39         List<MusicalEntityDto>songsFromAlbum=songRepository.findSongsByAlbum(id,currentUserId);
     40        List<SongWithLinkDto>songsFromAlbum=songRepository.findSongsByAlbum(id,currentUserId);
    4041
    4142        dto.setSongs(songsFromAlbum);
  • finkwave/src/main/java/com/ukim/finki/develop/finkwave/service/PlaylistService.java

    r0808ef2 r1579b4f  
    44import com.ukim.finki.develop.finkwave.model.Playlist;
    55import com.ukim.finki.develop.finkwave.model.dto.BasicPlaylistDto;
    6 import com.ukim.finki.develop.finkwave.model.dto.MusicalEntityDto;
    76import com.ukim.finki.develop.finkwave.model.dto.PlaylistDto;
     7import com.ukim.finki.develop.finkwave.model.dto.SongWithLinkDto;
    88import com.ukim.finki.develop.finkwave.repository.PlaylistRepository;
    99import com.ukim.finki.develop.finkwave.repository.SavedPlaylistRepository;
     
    3737        Long currentUserId=authService.getCurrentUserIDOptional().orElse(null);
    3838        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);
    4040        Set<Long> savedIds = savedPlaylistRepository.findAllByListener_Id(currentUserId)
    4141                .stream().map(sp -> sp.getPlaylist().getId()).collect(Collectors.toSet());
  • frontend/src/components/Sidebar.tsx

    r0808ef2 r1579b4f  
    55import { usePlayer } from "../context/playerContext";
    66import 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 };
     7import { toEmbedUrl } from "../utils/utils";
    268
    279const 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";
     1import axiosInstance from "../../api/axiosInstance";
    32import type { Song } from "../../utils/types";
     3import SongItem from "../SongItem";
    44
    55interface SongResultProps {
    66        song: Song;
     7        /** Propagate like state change upward if needed */
     8        onLikeToggled?: (songId: number, isLiked: boolean) => void;
    79}
    810
    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")}`;
     11const 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);
    1820                }
    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        };
    2722
    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} />;
    8624};
    8725
  • frontend/src/components/userProfile/ArtistView.tsx

    r0808ef2 r1579b4f  
     1import { Disc3, Music } from "lucide-react";
     2import { useState } from "react";
    13import { useNavigate } from "react-router-dom";
    2 import { useState } from "react";
    3 import { Music, Disc3, Play, Plus } from "lucide-react";
     4import { toast } from "react-toastify";
     5import axiosInstance from "../../api/axiosInstance";
    46import type { ArtistContribution } from "../../utils/types";
    5 import axiosInstance from "../../api/axiosInstance";
     7import SongItem from "../SongItem";
    68
    79interface ArtistViewProps {
    8   contributions: ArtistContribution[];
     10        contributions: ArtistContribution[];
    911}
    1012
    1113const 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);
    1816
    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");
    2119
    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        };
    3129
    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;
    3834
    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        };
    4349
    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                                                                        />
    5174
    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>
    5995
    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                        )}
    69112
    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                        )}
    87138
    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        );
    208148};
    209149
  • frontend/src/components/userProfile/ListenerView.tsx

    r0808ef2 r1579b4f  
     1import { Album, Bookmark, Heart, ListMusic, Music } from "lucide-react";
     2import { useState } from "react";
    13import { useNavigate } from "react-router-dom";
    2 import { Heart, ListMusic, Music, Album, Bookmark } from "lucide-react";
    3 import type { Playlist, MusicalEntity } from "../../utils/types";
     4import { toast } from "react-toastify";
    45import axiosInstance from "../../api/axiosInstance";
    5 import { useState } from "react";
     6import type { MusicalEntity, Playlist } from "../../utils/types";
     7import SongItem from "../SongItem";
    68
    79interface ListenerViewProps {
    8   likedEntities: MusicalEntity[] | [];
    9   createdPlaylists: Playlist[] | [];
    10   savedPlaylists: Playlist[] | [];
     10        likedEntities: MusicalEntity[] | [];
     11        createdPlaylists: Playlist[] | [];
     12        savedPlaylists: Playlist[] | [];
    1113}
    1214
    1315const ListenerView = ({
    14   likedEntities,
    15   createdPlaylists,
    16   savedPlaylists,
     16        likedEntities,
     17        createdPlaylists,
     18        savedPlaylists,
    1719}: 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        );
    332284};
    333285
  • frontend/src/components/userProfile/UserListModal.tsx

    r0808ef2 r1579b4f  
    11import { useNavigate } from "react-router-dom";
     2import { baseURL } from "../../api/axiosInstance";
    23import type { BaseNonAdminUser } from "../../utils/types";
    34
     
    1617}: ModalProps) => {
    1718        const navigate = useNavigate();
    18         const baseURL = import.meta.env.VITE_API_BASE_URL;
    1919
    2020        return (
    21                 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
    22                         <div className="bg-white rounded-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>
    2525                                        <button
    2626                                                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"
    2828                                        >
    2929                                                &times;
     
    3838                                                        <div
    3939                                                                key={u.username}
    40                                                                 className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors"
     40                                                                className="flex items-center justify-between p-3 hover:bg-white/5 rounded-lg transition-colors"
    4141                                                        >
    4242                                                                <div
     
    4747                                                                        }}
    4848                                                                >
    49                                                                         <div className="w-10 h-10 rounded-full bg-blue-100 overflow-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">
    5050                                                                                {u.profilePhoto ? (
    5151                                                                                        <img
     
    5555                                                                                        />
    5656                                                                                ) : (
    57                                                                                         <span className="text-blue-600 font-bold">
     57                                                                                        <span className="text-[#1db954] font-bold">
    5858                                                                                                {u.fullName.charAt(0)}
    5959                                                                                        </span>
    6060                                                                                )}
    6161                                                                        </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>
    6366                                                                </div>
    6467
    6568                                                                <button
    6669                                                                        onClick={() => onFollowToggle(u.username)}
    67                                                                         className={`px-4 py-1 text-sm font-medium rounded-md transition-colors cursor-pointer ${
     70                                                                        className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors cursor-pointer ${
    6871                                                                                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]"
    7174                                                                        }`}
    7275                                                                >
    73                                                                         {u.isFollowedByCurrentUser ? "Unfollow" : "Follow"}
     76                                                                        {u.isFollowedByCurrentUser ? "Following" : "Follow"}
    7477                                                                </button>
    7578                                                        </div>
  • frontend/src/pages/LandingPage.tsx

    r0808ef2 r1579b4f  
    1212        Song,
    1313} 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 };
     14import { toEmbedUrl } from "../utils/utils";
    3415
    3516const CATEGORIES: { value: SearchCategory; label: string }[] = [
  • frontend/src/pages/MusicalCollection.tsx

    r0808ef2 r1579b4f  
    11import { useEffect, useState } from "react";
    2 import { useNavigate, useParams } from "react-router-dom";
     2import { Link, useParams } from "react-router-dom";
    33import axiosInstance from "../api/axiosInstance";
    4 import LoadingSpinner from "../components/LoadingSpinner";
    5 import type { Song, Album, Playlist } from "../utils/types";
     4import SongItem from "../components/SongItem";
    65import { handleError } from "../utils/error";
     6import type { Album, Playlist, Song } from "../utils/types";
    77interface 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[];
    1516}
    1617
    1718const 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        );
    205246};
    206247
  • frontend/src/pages/SongDetail.tsx

    r0808ef2 r1579b4f  
    11import { useEffect, useRef, useState } from "react";
    22import { Link, useParams } from "react-router-dom";
     3import { toast } from "react-toastify";
    34import axiosInstance from "../api/axiosInstance";
    45import { useAuth } from "../context/authContext";
    56import { usePlayer } from "../context/playerContext";
    67import type { SongDetail as SongDetailType } from "../utils/types";
     8import { toEmbedUrl } from "../utils/utils";
    79
    810const ROLE_LABELS: Record<string, string> = {
     
    1820const formatRole = (role: string): string => {
    1921        return ROLE_LABELS[role] || role.replace(/_/g, " ");
    20 };
    21 
    22 // convert a regular youtube URL to an embeddable URL
    23 const toEmbedUrl = (url: string): string => {
    24         try {
    25                 const parsed = new URL(url);
    26                 // youtube.com/watch?v=ID
    27                 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/ID
    35                 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-is
    39                 return url;
    40         } catch {
    41                 return url;
    42         }
    4322};
    4423
     
    137116                        setSong(response.data);
    138117                } catch (err) {
    139                         // todo :add toast
     118                        toast.error("Failed to delete review");
    140119                        console.error("Error deleting review:", err);
    141120                }
     
    152131                        );
    153132                } catch (err) {
     133                        toast.error("Failed to toggle like");
    154134                        console.error("Error toggling like:", err);
    155135                }
  • frontend/src/pages/UserDetail.tsx

    r0808ef2 r1579b4f  
    11import { useEffect, useState } from "react";
    2 import { useNavigate, useParams } from "react-router-dom";
    3 import axiosInstance from "../api/axiosInstance";
     2import { Link, useNavigate, useParams } from "react-router-dom";
     3import axiosInstance, { baseURL } from "../api/axiosInstance";
    44import LoadingSpinner from "../components/LoadingSpinner";
    55import ArtistView from "../components/userProfile/ArtistView";
     
    3535
    3636const UserDetail = () => {
    37         // user refers to the selected user NOT to the user from context
    38         const baseURL = import.meta.env.VITE_API_BASE_URL;
    3937        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 dole
    4138        const { user: currentUser } = useAuth();
    4239        const navigate = useNavigate();
     
    4946        const [isFollowing, setIsFollowing] = useState(false);
    5047
    51         // determine which username to use: URL param or current user's username
    5248        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
    5551        if (!usernameParam && !currentUser) {
    5652                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>
    6665                        </div>
    6766                );
     
    9594                try {
    9695                        const response = await axiosInstance.post<FollowStatus>(
    97                                 `/users/na/${username}/follow`,
     96                                `/users/na/${targetUsername}/follow`,
    9897                        );
    9998
     
    105104                                ),
    106105                        );
    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                         // }
    118106                } catch (err: any) {
    119107                        setError(handleError(err));
     
    157145                        try {
    158146                                const response = await axiosInstance.get(`/users/na/${username}`);
    159                                 console.log(response.data);
    160147                                setUser(response.data);
    161148                        } catch (err: any) {
     
    168155        if (error) {
    169156                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>
    173164                        </div>
    174165                );
     
    180171
    181172        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">
    183174                        {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" />
    188177                                </div>
    189178                        )}
    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">
    201207                                                        {user.profilePhoto ? (
    202208                                                                <img
     
    206212                                                                />
    207213                                                        ) : (
    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>
    209217                                                        )}
    210218                                                </div>
    211219                                        </div>
    212220
    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
    217225                                                </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">
    220233                                                        <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`}
    222235                                                                onClick={
    223236                                                                        user.userType === "LISTENER" ? displayFollowers : undefined
    224237                                                                }
    225238                                                        >
    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>
    228243                                                        </div>
    229244                                                        <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`}
    231246                                                                onClick={
    232247                                                                        user.userType === "LISTENER" ? displayFollowing : undefined
    233248                                                                }
    234249                                                        >
    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>
    237254                                                        </div>
    238255                                                </div>
    239256
    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                                                )}
    257309                                        </div>
    258310                                </div>
    259311
     312                                {/* Content */}
    260313                                {user.userType === "ARTIST" ? (
    261314                                        <ArtistView contributions={user.contributions} />
  • frontend/src/utils/types.ts

    r0808ef2 r1579b4f  
    1515        entityType: string;
    1616        isLikedByCurrentUser: boolean;
     17        cover?: string | null;
     18        link?: string | null;
    1719}
    1820
Note: See TracChangeset for help on using the changeset viewer.