Ignore:
Timestamp:
02/15/26 00:39:55 (5 months ago)
Author:
Dimitar Arsov <dimitararsov04@…>
Branches:
main
Children:
c8baad1
Parents:
85512ff
Message:

add song to playlist

File:
1 edited

Legend:

Unmodified
Added
Removed
  • frontend/src/components/SongItem.tsx

    r85512ff rce45c7a  
    1 import { useEffect, useRef, useState } from "react";
     1import { useRef, useState } from "react";
    22import { useNavigate } from "react-router-dom";
    33import { baseURL } from "../api/axiosInstance";
    44import { usePlayer } from "../context/playerContext";
    55import { toEmbedUrl } from "../utils/utils";
     6import PlaylistDropdown from "./PlaylistDropdown";
    67
    78export interface SongItemData {
    8         id: number;
    9         title: string;
    10         cover?: string | null;
    11         genre?: string;
    12         link?: string | null;
    13         releasedBy?: string;
    14         isLikedByCurrentUser?: boolean;
     9  id: number;
     10  title: string;
     11  cover?: string | null;
     12  genre?: string;
     13  link?: string | null;
     14  releasedBy?: string;
     15  isLikedByCurrentUser?: boolean;
    1516}
    1617
    1718interface SongItemProps {
    18         song: SongItemData;
    19         /** Optional label shown before the artist, e.g. "Song" for search results */
    20         label?: string;
    21         /** Optional role badge for artist contributions, e.g. "PERFORMER" */
    22         role?: string;
    23         /** Optional index number for playlist/collection views */
    24         index?: number;
    25         /** Callback when the like button is clicked */
    26         onLikeToggle?: (songId: number) => void;
     19  song: SongItemData;
     20  /** Optional label shown before the artist, e.g. "Song" for search results */
     21  label?: string;
     22  /** Optional role badge for artist contributions, e.g. "PERFORMER" */
     23  role?: string;
     24  /** Optional index number for playlist/collection views */
     25  index?: number;
     26  /** Callback when the like button is clicked */
     27  onLikeToggle?: (songId: number) => void;
     28  /** Controlled: whether the playlist dropdown is open */
     29  isDropdownOpen?: boolean;
     30  /** Controlled: callback when dropdown should open/close */
     31  onDropdownToggle?: (songId: number | null) => void;
    2732}
    2833
    2934const ROLE_COLORS: { [key: string]: string } = {
    30         COMPOSER: "bg-purple-500/20 text-purple-300",
    31         PERFORMER: "bg-blue-500/20 text-blue-300",
    32         PRODUCER: "bg-green-500/20 text-green-300",
    33         MAIN_VOCAL: "bg-pink-500/20 text-pink-300",
     35  COMPOSER: "bg-purple-500/20 text-purple-300",
     36  PERFORMER: "bg-blue-500/20 text-blue-300",
     37  PRODUCER: "bg-green-500/20 text-green-300",
     38  MAIN_VOCAL: "bg-pink-500/20 text-pink-300",
    3439};
    3540
    3641const SongItem = ({
    37         song,
    38         label,
    39         role,
    40         index,
    41         onLikeToggle,
     42  song,
     43  label,
     44  role,
     45  index,
     46  onLikeToggle,
     47  isDropdownOpen,
     48  onDropdownToggle,
    4249}: SongItemProps) => {
    43         const navigate = useNavigate();
    44         const { play, currentSong } = usePlayer();
    45         const [playlistOpen, setPlaylistOpen] = useState(false);
    46         const dropdownRef = useRef<HTMLDivElement>(null);
    47 
    48         const isPlaying = currentSong?.id === song.id;
    49 
    50         useEffect(() => {
    51                 const handleClickOutside = (event: MouseEvent) => {
    52                         if (
    53                                 dropdownRef.current &&
    54                                 !dropdownRef.current.contains(event.target as Node)
    55                         ) {
    56                                 setPlaylistOpen(false);
    57                         }
    58                 };
    59                 if (playlistOpen) {
    60                         document.addEventListener("mousedown", handleClickOutside);
    61                 }
    62                 return () => document.removeEventListener("mousedown", handleClickOutside);
    63         }, [playlistOpen]);
    64 
    65         const handleAddToPlaylist = (playlistName: string) => {
    66                 console.log(`Adding song ${song.id} to ${playlistName}`);
    67                 // TODO: Implement actual API call
    68                 setPlaylistOpen(false);
    69         };
    70 
    71         const handleCreateNewPlaylist = () => {
    72                 console.log(`Creating new playlist for song ${song.id}`);
    73                 // TODO: Implement actual playlist creation
    74                 setPlaylistOpen(false);
    75         };
    76 
    77         // Build subtitle
    78         const subtitleParts: string[] = [];
    79         if (label) subtitleParts.push(label);
    80         if (song.releasedBy) subtitleParts.push(song.releasedBy);
    81         const subtitle = subtitleParts.join(" • ");
    82 
    83         return (
    84                 <div
    85                         onClick={() => navigate(`/songs/${song.id}`)}
    86                         className="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group"
    87                 >
    88                         {/* Optional index */}
    89                         {index != null && (
    90                                 <span className="text-gray-500 font-medium w-8 text-center text-sm shrink-0">
    91                                         {index}
    92                                 </span>
    93                         )}
    94 
    95                         {/* Cover */}
    96                         <img
    97                                 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"}
    98                                 alt={song.title}
    99                                 className="w-12 h-12 rounded object-cover shrink-0"
    100                                 onError={(e) => {
    101                                         (e.target as HTMLImageElement).src = "/favicon.png";
    102                                 }}
    103                         />
    104 
    105                         {/* Title & subtitle */}
    106                         <div className="flex-1 min-w-0">
    107                                 <p
    108                                         className={`font-medium truncate ${
    109                                                 isPlaying ? "text-[#1db954]" : "text-white"
    110                                         }`}
    111                                 >
    112                                         {song.title}
    113                                 </p>
    114                                 {subtitle && (
    115                                         <p className="text-sm text-gray-400 truncate">{subtitle}</p>
    116                                 )}
    117                         </div>
    118 
    119                         {/* Role badge (artist contributions) */}
    120                         {role && (
    121                                 <span
    122                                         className={`px-3 py-1 rounded-full text-xs font-medium hidden sm:block ${
    123                                                 ROLE_COLORS[role] || "bg-white/10 text-gray-300"
    124                                         }`}
    125                                 >
    126                                         {role.replace("_", " ")}
    127                                 </span>
    128                         )}
    129 
    130                         {/* Genre */}
    131                         {song.genre && (
    132                                 <span className="text-xs text-gray-500 uppercase tracking-wider mr-2 hidden sm:block">
    133                                         {song.genre}
    134                                 </span>
    135                         )}
    136 
    137                         {/* Play button */}
    138                         {song.link && (
    139                                 <button
    140                                         onClick={(e) => {
    141                                                 e.stopPropagation();
    142                                                 play({
    143                                                         id: song.id,
    144                                                         title: song.title,
    145                                                         artist: song.releasedBy || "",
    146                                                         cover: song.cover,
    147                                                         embedUrl: toEmbedUrl(song.link!),
    148                                                 });
    149                                         }}
    150                                         className={`p-2 rounded-full transition-all cursor-pointer ${
    151                                                 isPlaying
    152                                                         ? "bg-white text-[#1db954] opacity-100"
    153                                                         : "bg-[#1db954] text-black hover:scale-110 opacity-0 group-hover:opacity-100"
    154                                         }`}
    155                                         aria-label={isPlaying ? "Now playing" : "Play song"}
    156                                 >
    157                                         {isPlaying ? (
    158                                                 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
    159                                                         <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
    160                                                 </svg>
    161                                         ) : (
    162                                                 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
    163                                                         <path d="M8 5v14l11-7z" />
    164                                                 </svg>
    165                                         )}
    166                                 </button>
    167                         )}
    168 
    169                         {/* Like button */}
    170                         {onLikeToggle && (
    171                                 <button
    172                                         onClick={(e) => {
    173                                                 e.stopPropagation();
    174                                                 onLikeToggle(song.id);
    175                                         }}
    176                                         className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
    177                                         aria-label={song.isLikedByCurrentUser ? "Unlike" : "Like"}
    178                                 >
    179                                         <svg
    180                                                 className="w-5 h-5"
    181                                                 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"}
    182                                                 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"}
    183                                                 strokeWidth="2"
    184                                                 viewBox="0 0 24 24"
    185                                         >
    186                                                 <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" />
    187                                         </svg>
    188                                 </button>
    189                         )}
    190 
    191                         {/* Three-dot menu */}
    192                         <div className="relative" ref={dropdownRef}>
    193                                 <button
    194                                         onClick={(e) => {
    195                                                 e.stopPropagation();
    196                                                 setPlaylistOpen((prev) => !prev);
    197                                         }}
    198                                         className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer text-gray-400 hover:text-white"
    199                                         aria-label="More options"
    200                                 >
    201                                         <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
    202                                                 <circle cx="12" cy="5" r="1.5" />
    203                                                 <circle cx="12" cy="12" r="1.5" />
    204                                                 <circle cx="12" cy="19" r="1.5" />
    205                                         </svg>
    206                                 </button>
    207 
    208                                 {playlistOpen && (
    209                                         <div className="absolute right-0 bottom-full mb-2 w-48 bg-[#282828] border border-white/10 rounded-lg shadow-lg py-1 z-50">
    210                                                 <div className="px-3 py-2 text-xs text-gray-400 border-b border-white/10">
    211                                                         Add to playlist
    212                                                 </div>
    213                                                 <button
    214                                                         onClick={(e) => {
    215                                                                 e.stopPropagation();
    216                                                                 handleAddToPlaylist("Playlist 1");
    217                                                         }}
    218                                                         className="w-full text-left px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors"
    219                                                 >
    220                                                         Playlist 1
    221                                                 </button>
    222                                                 <button
    223                                                         onClick={(e) => {
    224                                                                 e.stopPropagation();
    225                                                                 handleAddToPlaylist("Playlist 2");
    226                                                         }}
    227                                                         className="w-full text-left px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors"
    228                                                 >
    229                                                         Playlist 2
    230                                                 </button>
    231                                                 <button
    232                                                         onClick={(e) => {
    233                                                                 e.stopPropagation();
    234                                                                 handleCreateNewPlaylist();
    235                                                         }}
    236                                                         className="w-full text-left px-4 py-2 text-sm text-[#1db954] hover:bg-white/10 transition-colors border-t border-white/10 flex items-center gap-2"
    237                                                 >
    238                                                         <svg
    239                                                                 className="w-4 h-4"
    240                                                                 fill="none"
    241                                                                 stroke="currentColor"
    242                                                                 viewBox="0 0 24 24"
    243                                                         >
    244                                                                 <path
    245                                                                         strokeLinecap="round"
    246                                                                         strokeLinejoin="round"
    247                                                                         strokeWidth={2}
    248                                                                         d="M12 4v16m8-8H4"
    249                                                                 />
    250                                                         </svg>
    251                                                         Create new playlist
    252                                                 </button>
    253                                         </div>
    254                                 )}
    255                         </div>
    256                 </div>
    257         );
     50  const navigate = useNavigate();
     51  const { play, currentSong } = usePlayer();
     52  const [internalOpen, setInternalOpen] = useState(false);
     53  const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
     54  const [dropdownDirection, setDropdownDirection] = useState<"above" | "below">(
     55    "below",
     56  );
     57  const buttonRef = useRef<HTMLButtonElement>(null);
     58
     59  const playlistOpen = isDropdownOpen ?? internalOpen;
     60  const setPlaylistOpen = (open: boolean) => {
     61    if (onDropdownToggle) {
     62      onDropdownToggle(open ? song.id : null);
     63    } else {
     64      setInternalOpen(open);
     65    }
     66  };
     67
     68  const isPlaying = currentSong?.id === song.id;
     69
     70  const handleCreateNewPlaylist = () => {
     71    console.log(`Creating new playlist for song ${song.id}`);
     72    // TODO: Implement actual playlist creation
     73    setPlaylistOpen(false);
     74  };
     75
     76  const subtitleParts: string[] = [];
     77  if (label) subtitleParts.push(label);
     78  if (song.releasedBy) subtitleParts.push(song.releasedBy);
     79  const subtitle = subtitleParts.join(" • ");
     80
     81  return (
     82    <div
     83      onClick={() => navigate(`/songs/${song.id}`)}
     84      onMouseEnter={() => {
     85        if (onDropdownToggle && !playlistOpen) {
     86          onDropdownToggle(null);
     87        }
     88      }}
     89      className="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group"
     90    >
     91      {/* Optional index */}
     92      {index != null && (
     93        <span className="text-gray-500 font-medium w-8 text-center text-sm shrink-0">
     94          {index}
     95        </span>
     96      )}
     97
     98      {/* Cover */}
     99      <img
     100        src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"}
     101        alt={song.title}
     102        className="w-12 h-12 rounded object-cover shrink-0"
     103        onError={(e) => {
     104          (e.target as HTMLImageElement).src = "/favicon.png";
     105        }}
     106      />
     107
     108      {/* Title & subtitle */}
     109      <div className="flex-1 min-w-0">
     110        <p
     111          className={`font-medium truncate ${
     112            isPlaying ? "text-[#1db954]" : "text-white"
     113          }`}
     114        >
     115          {song.title}
     116        </p>
     117        {subtitle && (
     118          <p className="text-sm text-gray-400 truncate">{subtitle}</p>
     119        )}
     120      </div>
     121
     122      {/* Role badge (artist contributions) */}
     123      {role && (
     124        <span
     125          className={`px-3 py-1 rounded-full text-xs font-medium hidden sm:block ${
     126            ROLE_COLORS[role] || "bg-white/10 text-gray-300"
     127          }`}
     128        >
     129          {role.replace("_", " ")}
     130        </span>
     131      )}
     132
     133      {/* Genre */}
     134      {song.genre && (
     135        <span className="text-xs text-gray-500 uppercase tracking-wider mr-2 hidden sm:block">
     136          {song.genre}
     137        </span>
     138      )}
     139
     140      {/* Play button */}
     141      {song.link && (
     142        <button
     143          onClick={(e) => {
     144            e.stopPropagation();
     145            play({
     146              id: song.id,
     147              title: song.title,
     148              artist: song.releasedBy || "",
     149              cover: song.cover,
     150              embedUrl: toEmbedUrl(song.link!),
     151            });
     152          }}
     153          className={`p-2 rounded-full transition-all cursor-pointer ${
     154            isPlaying
     155              ? "bg-white text-[#1db954] opacity-100"
     156              : "bg-[#1db954] text-black hover:scale-110 opacity-0 group-hover:opacity-100"
     157          }`}
     158          aria-label={isPlaying ? "Now playing" : "Play song"}
     159        >
     160          {isPlaying ? (
     161            <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
     162              <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
     163            </svg>
     164          ) : (
     165            <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
     166              <path d="M8 5v14l11-7z" />
     167            </svg>
     168          )}
     169        </button>
     170      )}
     171
     172      {/* Like button */}
     173      {onLikeToggle && (
     174        <button
     175          onClick={(e) => {
     176            e.stopPropagation();
     177            onLikeToggle(song.id);
     178          }}
     179          className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
     180          aria-label={song.isLikedByCurrentUser ? "Unlike" : "Like"}
     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      )}
     193
     194      {/* Three-dot menu */}
     195      <div className="relative">
     196        <button
     197          ref={buttonRef}
     198          onClick={(e) => {
     199            e.stopPropagation();
     200            if (playlistOpen) {
     201              setPlaylistOpen(false);
     202            } else {
     203              if (buttonRef.current) {
     204                const rect = buttonRef.current.getBoundingClientRect();
     205                const spaceBelow = window.innerHeight - rect.bottom;
     206                const dropdownHeight = 260;
     207
     208                if (spaceBelow < dropdownHeight) {
     209                  setDropdownDirection("above");
     210                  setDropdownPosition({
     211                    top: rect.top - 8,
     212                    left: rect.right - 224,
     213                  });
     214                } else {
     215                  setDropdownDirection("below");
     216                  setDropdownPosition({
     217                    top: rect.bottom + 8,
     218                    left: rect.right - 224,
     219                  });
     220                }
     221              }
     222              setPlaylistOpen(true);
     223            }
     224          }}
     225          className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer text-gray-400 hover:text-white"
     226          aria-label="More options"
     227        >
     228          <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
     229            <circle cx="12" cy="5" r="1.5" />
     230            <circle cx="12" cy="12" r="1.5" />
     231            <circle cx="12" cy="19" r="1.5" />
     232          </svg>
     233        </button>
     234
     235        <PlaylistDropdown
     236          songId={song.id}
     237          isOpen={playlistOpen}
     238          onClose={() => setPlaylistOpen(false)}
     239          position={dropdownPosition}
     240          usePortal={true}
     241          direction={dropdownDirection}
     242          onCreateNewPlaylist={handleCreateNewPlaylist}
     243        />
     244      </div>
     245    </div>
     246  );
    258247};
    259248
Note: See TracChangeset for help on using the changeset viewer.