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