Listen to and share the music in the Atmosphere. musicsky.up.railway.app/
nextjs atproto music typescript react
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: playlists

Signed-off-by: mejsiejdev <mejsiejdev@gmail.com>

authored by

mejsiejdev and committed by tangled.org 7ac7058d 7561dcb9

+1546 -13
+1
package.json
··· 38 38 "react": "^19.2.4", 39 39 "react-dom": "^19.2.4", 40 40 "react-hook-form": "^7.71.2", 41 + "sonner": "^2.0.7", 41 42 "tailwind-merge": "^3.5.0", 42 43 "zod": "^4.3.6", 43 44 "zustand": "^5.0.11"
+17
pnpm-lock.yaml
··· 67 67 react-hook-form: 68 68 specifier: ^7.71.2 69 69 version: 7.71.2(react@19.2.4) 70 + sonner: 71 + specifier: ^2.0.7 72 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 70 73 tailwind-merge: 71 74 specifier: ^3.5.0 72 75 version: 3.5.0 ··· 6361 6364 } 6362 6365 engines: { node: ">= 18" } 6363 6366 6367 + sonner@2.0.7: 6368 + resolution: 6369 + { 6370 + integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==, 6371 + } 6372 + peerDependencies: 6373 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc 6374 + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc 6375 + 6364 6376 source-map-js@1.2.1: 6365 6377 resolution: 6366 6378 { ··· 11362 11374 is-fullwidth-code-point: 5.1.0 11363 11375 11364 11376 smol-toml@1.6.0: {} 11377 + 11378 + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 11379 + dependencies: 11380 + react: 19.2.4 11381 + react-dom: 19.2.4(react@19.2.4) 11365 11382 11366 11383 source-map-js@1.2.1: {} 11367 11384
+1
src/app/(main)/[handle]/[rkey]/song-view.tsx
··· 63 63 <Song 64 64 {...song} 65 65 isOwner={isOwner} 66 + loggedIn={session !== null} 66 67 likeRkey={likedUris.get(song.uri) ?? null} 67 68 repostRkey={repostedUris.get(song.uri) ?? null} 68 69 />
+5 -1
src/app/(main)/[handle]/likes/likes-list.tsx
··· 21 21 createdAt: string; 22 22 } 23 23 24 - type LikedSong = Omit<SongProps, "isOwner" | "likeRkey" | "repostRkey"> & { 24 + type LikedSong = Omit< 25 + SongProps, 26 + "isOwner" | "loggedIn" | "likeRkey" | "repostRkey" 27 + > & { 25 28 likedAt: string; 26 29 }; 27 30 ··· 158 161 const songProps: SongProps = { 159 162 ...song, 160 163 isOwner: session?.did === song.uri.split("/")[2], 164 + loggedIn: session !== null, 161 165 likeRkey: likedUris.get(song.uri) ?? null, 162 166 repostRkey: repostedUris.get(song.uri) ?? null, 163 167 };
+37
src/app/(main)/[handle]/playlists/[rkey]/page.tsx
··· 1 + import { Skeleton } from "@/components/ui/skeleton"; 2 + import { Suspense } from "react"; 3 + import { PlaylistView } from "./playlist-view"; 4 + 5 + export default async function PlaylistPage({ 6 + params, 7 + }: { 8 + params: Promise<{ handle: string; rkey: string }>; 9 + }) { 10 + return ( 11 + <Suspense 12 + fallback={ 13 + <div className="flex flex-col gap-6"> 14 + <div className="flex flex-row gap-4"> 15 + <Skeleton className="size-24 rounded-md" /> 16 + <div className="flex flex-col gap-2"> 17 + <Skeleton className="w-48 h-6" /> 18 + <Skeleton className="w-32 h-4" /> 19 + <Skeleton className="w-24 h-4" /> 20 + </div> 21 + </div> 22 + {Array.from({ length: 3 }).map((_, index) => ( 23 + <div key={index} className="flex flex-row gap-4"> 24 + <Skeleton className="size-24 rounded-md" /> 25 + <div className="flex flex-col gap-2"> 26 + <Skeleton className="w-48 h-6" /> 27 + <Skeleton className="w-32 h-4" /> 28 + </div> 29 + </div> 30 + ))} 31 + </div> 32 + } 33 + > 34 + <PlaylistView params={params} /> 35 + </Suspense> 36 + ); 37 + }
+143
src/app/(main)/[handle]/playlists/[rkey]/playlist-view.tsx
··· 1 + import Image from "next/image"; 2 + import { Song } from "@/components/song"; 3 + import { type PlaylistProps, type PlaylistRecord } from "@/types/playlist"; 4 + import { Agent } from "@atproto/api"; 5 + import { notFound } from "next/navigation"; 6 + import { cacheTag } from "next/cache"; 7 + import { getSession } from "@/lib/auth/session"; 8 + import { getDid, getPds } from "@/lib/songs"; 9 + import { mapRecordToPlaylist, resolvePlaylistTracks } from "@/lib/playlists"; 10 + import { PlaylistMenu } from "@/components/playlist/playlist-menu"; 11 + import { ListMusicIcon } from "lucide-react"; 12 + 13 + async function getPlaylist( 14 + pds: string, 15 + did: string, 16 + handle: string, 17 + rkey: string, 18 + ): Promise< 19 + | (Omit<PlaylistProps, "isOwner"> & { 20 + tracks: { uri: string; cid: string }[]; 21 + }) 22 + | null 23 + > { 24 + "use cache"; 25 + cacheTag(`playlist-${did}-${rkey}`); 26 + const agent = new Agent(pds); 27 + try { 28 + const { data } = await agent.com.atproto.repo.getRecord({ 29 + repo: did, 30 + collection: "app.musicsky.temp.playlist", 31 + rkey, 32 + }); 33 + const value = data.value as unknown as PlaylistRecord; 34 + return { 35 + ...mapRecordToPlaylist(data.uri, value, pds, did, handle), 36 + tracks: value.tracks, 37 + }; 38 + } catch (error) { 39 + console.error("Failed to fetch playlist", rkey, "for", did, error); 40 + return null; 41 + } 42 + } 43 + 44 + export async function PlaylistView({ 45 + params, 46 + }: { 47 + params: Promise<{ handle: string; rkey: string }>; 48 + }) { 49 + const { handle, rkey } = await params; 50 + 51 + const [profileDid, session] = await Promise.all([ 52 + getDid(handle), 53 + getSession(), 54 + ]); 55 + if (!profileDid) { 56 + notFound(); 57 + } 58 + 59 + const pds = await getPds(profileDid); 60 + if (!pds) { 61 + notFound(); 62 + } 63 + 64 + const playlist = await getPlaylist(pds, profileDid, handle, rkey); 65 + 66 + if (!playlist) { 67 + notFound(); 68 + } 69 + 70 + const isOwner = session?.did === playlist.uri.split("/")[2]; 71 + const resolvedTracks = await resolvePlaylistTracks(playlist.tracks, session); 72 + 73 + return ( 74 + <div className="flex flex-col gap-6"> 75 + <div className="flex flex-row items-start justify-between gap-4"> 76 + <div className="flex flex-row items-center gap-4"> 77 + {playlist.coverArt ? ( 78 + <Image 79 + className="rounded-md size-24 object-cover" 80 + src={playlist.coverArt} 81 + alt={playlist.name} 82 + width={96} 83 + height={96} 84 + /> 85 + ) : ( 86 + <div className="rounded-md size-24 bg-muted flex items-center justify-center"> 87 + <ListMusicIcon className="size-8 text-muted-foreground" /> 88 + </div> 89 + )} 90 + <div className="flex flex-col gap-1"> 91 + <h1 className="text-2xl font-bold">{playlist.name}</h1> 92 + {playlist.description && ( 93 + <p className="text-sm text-muted-foreground"> 94 + {playlist.description} 95 + </p> 96 + )} 97 + <p className="text-sm text-muted-foreground"> 98 + {playlist.trackCount}{" "} 99 + {playlist.trackCount === 1 ? "track" : "tracks"} 100 + </p> 101 + </div> 102 + </div> 103 + {isOwner && ( 104 + <PlaylistMenu 105 + rkey={rkey} 106 + author={handle} 107 + name={playlist.name} 108 + description={playlist.description} 109 + coverArt={playlist.coverArt} 110 + /> 111 + )} 112 + </div> 113 + 114 + <div className="flex flex-col gap-4"> 115 + {resolvedTracks.map((track, index) => { 116 + if (!track) { 117 + return ( 118 + <div 119 + key={index} 120 + className="flex flex-row items-center gap-4 opacity-50" 121 + > 122 + <div className="rounded-md size-24 bg-muted flex items-center justify-center"> 123 + <ListMusicIcon className="size-6 text-muted-foreground" /> 124 + </div> 125 + <p className="text-sm text-muted-foreground"> 126 + Track unavailable 127 + </p> 128 + </div> 129 + ); 130 + } 131 + return ( 132 + <Song 133 + key={track.uri} 134 + {...track} 135 + playlistRkey={isOwner ? rkey : undefined} 136 + isLastTrack={playlist.trackCount <= 1} 137 + /> 138 + ); 139 + })} 140 + </div> 141 + </div> 142 + ); 143 + }
+31
src/app/(main)/[handle]/playlists/page.tsx
··· 1 + import { Skeleton } from "@/components/ui/skeleton"; 2 + import { Suspense } from "react"; 3 + import { PlaylistsList } from "./playlists-list"; 4 + 5 + export default async function PlaylistsPage({ 6 + params, 7 + }: { 8 + params: Promise<{ handle: string }>; 9 + }) { 10 + return ( 11 + <div className="flex flex-col gap-4"> 12 + <Suspense 13 + fallback={ 14 + <> 15 + {Array.from({ length: 5 }).map((_, index) => ( 16 + <div key={index} className="flex flex-row gap-4"> 17 + <Skeleton className="size-16 rounded-md" /> 18 + <div className="flex flex-col gap-2"> 19 + <Skeleton className="w-48 h-6" /> 20 + <Skeleton className="w-32 h-4" /> 21 + </div> 22 + </div> 23 + ))} 24 + </> 25 + } 26 + > 27 + <PlaylistsList params={params} /> 28 + </Suspense> 29 + </div> 30 + ); 31 + }
+71
src/app/(main)/[handle]/playlists/playlists-list.tsx
··· 1 + import { PlaylistCard } from "@/components/playlist"; 2 + import { type PlaylistProps, type PlaylistRecord } from "@/types/playlist"; 3 + import { Agent } from "@atproto/api"; 4 + import { cacheTag } from "next/cache"; 5 + import { notFound } from "next/navigation"; 6 + import { getSession } from "@/lib/auth/session"; 7 + import { getDid, getPds } from "@/lib/songs"; 8 + import { mapRecordToPlaylist } from "@/lib/playlists"; 9 + import { ListMusicIcon } from "lucide-react"; 10 + 11 + async function getPlaylists( 12 + pds: string, 13 + did: string, 14 + handle: string, 15 + ): Promise<Omit<PlaylistProps, "isOwner">[]> { 16 + "use cache"; 17 + cacheTag(`playlists-${did}`); 18 + try { 19 + const agent = new Agent(pds); 20 + 21 + const { data } = await agent.com.atproto.repo.listRecords({ 22 + repo: did, 23 + collection: "app.musicsky.temp.playlist", 24 + limit: 50, 25 + }); 26 + return data.records.map((record) => { 27 + const value = record.value as unknown as PlaylistRecord; 28 + return mapRecordToPlaylist(record.uri, value, pds, did, handle); 29 + }); 30 + } catch (error) { 31 + console.error("Failed to fetch playlists for", did, error); 32 + return []; 33 + } 34 + } 35 + 36 + export async function PlaylistsList({ 37 + params, 38 + }: { 39 + params: Promise<{ handle: string }>; 40 + }) { 41 + const { handle } = await params; 42 + const [profileDid, session] = await Promise.all([ 43 + getDid(handle), 44 + getSession(), 45 + ]); 46 + if (!profileDid) { 47 + notFound(); 48 + } 49 + const pds = await getPds(profileDid); 50 + if (!pds) { 51 + notFound(); 52 + } 53 + const playlists = await getPlaylists(pds, profileDid, handle); 54 + 55 + if (playlists.length === 0) { 56 + return ( 57 + <div className="flex flex-row text-muted-foreground items-center gap-4"> 58 + <ListMusicIcon /> 59 + <p className="font-semibold">No playlists yet.</p> 60 + </div> 61 + ); 62 + } 63 + 64 + return playlists.map((playlist) => ( 65 + <PlaylistCard 66 + key={playlist.uri} 67 + {...playlist} 68 + isOwner={session?.did === playlist.uri.split("/")[2]} 69 + /> 70 + )); 71 + }
+1
src/app/(main)/[handle]/songs-list.tsx
··· 56 56 const songProps: SongProps = { 57 57 ...song, 58 58 isOwner: session?.did === song.uri.split("/")[2], 59 + loggedIn: session !== null, 59 60 likeRkey: likedUris.get(song.uri) ?? null, 60 61 repostRkey: repostedUris.get(song.uri) ?? null, 61 62 };
+2
src/app/layout.tsx
··· 1 1 import type { Metadata } from "next"; 2 2 import { ThemeProvider } from "@/components/theme-provider"; 3 + import { Toaster } from "@/components/ui/sonner"; 3 4 import { TooltipProvider } from "@/components/ui/tooltip"; 4 5 import { Geist, Geist_Mono } from "next/font/google"; 5 6 import "./globals.css"; ··· 36 37 disableTransitionOnChange 37 38 > 38 39 <TooltipProvider>{children}</TooltipProvider> 40 + <Toaster /> 39 41 </ThemeProvider> 40 42 </body> 41 43 </html>
+3 -1
src/components/pager/pager-link.tsx
··· 8 8 export function PagerLink({ 9 9 href, 10 10 children, 11 + exact = false, 11 12 }: { 12 13 href: string; 13 14 children: React.ReactNode; 15 + exact?: boolean; 14 16 }) { 15 17 const pathname = usePathname(); 16 - const isActive = pathname === href; 18 + const isActive = exact ? pathname === href : pathname.startsWith(href); 17 19 return ( 18 20 <Button 19 21 variant={isActive ? "secondary" : "ghost"}
+5 -2
src/components/pager/pager.tsx
··· 1 - import { HeartIcon, MusicIcon } from "lucide-react"; 1 + import { HeartIcon, ListMusicIcon, MusicIcon } from "lucide-react"; 2 2 import { PagerLink } from "./pager-link"; 3 3 4 4 export async function Pager({ ··· 9 9 const { handle } = await params; 10 10 return ( 11 11 <div className="flex flex-row gap-4"> 12 - <PagerLink href={`/${handle}`}> 12 + <PagerLink href={`/${handle}`} exact> 13 13 <MusicIcon /> Songs 14 + </PagerLink> 15 + <PagerLink href={`/${handle}/playlists`}> 16 + <ListMusicIcon /> Playlists 14 17 </PagerLink> 15 18 <PagerLink href={`/${handle}/likes`}> 16 19 <HeartIcon /> Likes
+330
src/components/playlist/actions.ts
··· 1 + "use server"; 2 + 3 + import { getSession } from "@/lib/auth/session"; 4 + import { redirect } from "next/navigation"; 5 + import { Agent } from "@atproto/api"; 6 + import { updateTag } from "next/cache"; 7 + import { playlistSchema } from "./playlist-schema"; 8 + import type { PlaylistRecord } from "@/types/playlist"; 9 + import { getHandleFromDid } from "@/lib/songs"; 10 + const COLLECTION = "app.musicsky.temp.playlist"; 11 + 12 + export async function createPlaylist( 13 + _prevState: { success?: boolean; error?: string; rkey?: string } | null, 14 + formData: FormData, 15 + ) { 16 + const coverArtFile = formData.get("coverArt") as File | null; 17 + const raw = { 18 + name: formData.get("name") as string, 19 + description: (formData.get("description") as string) || undefined, 20 + coverArt: coverArtFile && coverArtFile.size > 0 ? coverArtFile : undefined, 21 + }; 22 + 23 + const parsed = playlistSchema.safeParse(raw); 24 + if (!parsed.success) { 25 + return { error: parsed.error.issues[0]?.message ?? "Invalid input." }; 26 + } 27 + 28 + const trackUri = formData.get("trackUri") as string; 29 + const trackCid = formData.get("trackCid") as string; 30 + if (!trackUri || !trackCid) { 31 + return { error: "A playlist must have at least one track." }; 32 + } 33 + 34 + const session = await getSession(); 35 + if (!session) { 36 + redirect("/auth/login"); 37 + } 38 + 39 + const agent = new Agent(session); 40 + const did = agent.assertDid; 41 + 42 + try { 43 + let coverArt; 44 + if (parsed.data.coverArt) { 45 + const { data: coverArtUpload } = await agent.uploadBlob( 46 + parsed.data.coverArt, 47 + { encoding: parsed.data.coverArt.type }, 48 + ); 49 + coverArt = coverArtUpload.blob; 50 + } 51 + 52 + const { data } = await agent.com.atproto.repo.createRecord({ 53 + repo: did, 54 + collection: COLLECTION, 55 + record: { 56 + $type: COLLECTION, 57 + name: parsed.data.name, 58 + description: parsed.data.description, 59 + coverArt, 60 + tracks: [{ uri: trackUri, cid: trackCid }], 61 + createdAt: new Date().toISOString(), 62 + }, 63 + }); 64 + 65 + const rkey = data.uri.split("/")[4]!; 66 + const handle = await getHandleFromDid(did); 67 + updateTag(`playlists-${did}`); 68 + return { success: true, rkey, handle }; 69 + } catch (error) { 70 + console.error("Failed to create playlist:", error); 71 + return { 72 + error: 73 + error instanceof Error 74 + ? error.message 75 + : "Something went wrong. Try again.", 76 + }; 77 + } 78 + } 79 + 80 + export async function editPlaylist( 81 + _prevState: { success?: boolean; error?: string } | null, 82 + formData: FormData, 83 + ) { 84 + const rkey = formData.get("rkey") as string; 85 + const coverArtFile = formData.get("coverArt") as File | null; 86 + const raw = { 87 + name: formData.get("name") as string, 88 + description: (formData.get("description") as string) || undefined, 89 + coverArt: coverArtFile && coverArtFile.size > 0 ? coverArtFile : undefined, 90 + }; 91 + 92 + const parsed = playlistSchema.safeParse(raw); 93 + if (!parsed.success) { 94 + return { error: parsed.error.issues[0]?.message ?? "Invalid input." }; 95 + } 96 + 97 + const session = await getSession(); 98 + if (!session) { 99 + redirect("/auth/login"); 100 + } 101 + 102 + const agent = new Agent(session); 103 + const did = agent.assertDid; 104 + 105 + try { 106 + const { data: existing } = await agent.com.atproto.repo.getRecord({ 107 + repo: did, 108 + collection: COLLECTION, 109 + rkey, 110 + }); 111 + 112 + const existingValue = existing.value as unknown as PlaylistRecord; 113 + 114 + let coverArt = existingValue.coverArt; 115 + if (parsed.data.coverArt) { 116 + const { data: coverArtUpload } = await agent.uploadBlob( 117 + parsed.data.coverArt, 118 + { encoding: parsed.data.coverArt.type }, 119 + ); 120 + coverArt = coverArtUpload.blob; 121 + } 122 + 123 + await agent.com.atproto.repo.putRecord({ 124 + repo: did, 125 + collection: COLLECTION, 126 + rkey, 127 + swapRecord: existing.cid, 128 + record: { 129 + ...existingValue, 130 + name: parsed.data.name, 131 + description: parsed.data.description, 132 + coverArt, 133 + updatedAt: new Date().toISOString(), 134 + }, 135 + }); 136 + 137 + updateTag(`playlist-${did}-${rkey}`); 138 + updateTag(`playlists-${did}`); 139 + return { success: true }; 140 + } catch (error) { 141 + console.error("Failed to edit playlist:", error); 142 + return { 143 + error: 144 + error instanceof Error 145 + ? error.message 146 + : "Something went wrong. Try again.", 147 + }; 148 + } 149 + } 150 + 151 + export async function deletePlaylist( 152 + _prevState: { success?: boolean; error?: string } | null, 153 + formData: FormData, 154 + ) { 155 + const rkey = formData.get("rkey") as string; 156 + const session = await getSession(); 157 + if (!session) { 158 + redirect("/auth/login"); 159 + } 160 + const agent = new Agent(session); 161 + try { 162 + await agent.com.atproto.repo.deleteRecord({ 163 + repo: agent.assertDid, 164 + collection: COLLECTION, 165 + rkey, 166 + }); 167 + updateTag(`playlist-${agent.assertDid}-${rkey}`); 168 + updateTag(`playlists-${agent.assertDid}`); 169 + return { success: true }; 170 + } catch (error) { 171 + console.error("Failed to delete playlist:", error); 172 + return { 173 + error: 174 + error instanceof Error 175 + ? error.message 176 + : "Something went wrong. Try again.", 177 + }; 178 + } 179 + } 180 + 181 + export async function addTrackToPlaylist( 182 + _prevState: { success?: boolean; error?: string } | null, 183 + formData: FormData, 184 + ) { 185 + const rkey = formData.get("rkey") as string; 186 + const trackUri = formData.get("trackUri") as string; 187 + const trackCid = formData.get("trackCid") as string; 188 + 189 + if (!trackUri || !trackCid) { 190 + return { error: "Missing track information." }; 191 + } 192 + 193 + const session = await getSession(); 194 + if (!session) { 195 + redirect("/auth/login"); 196 + } 197 + 198 + const agent = new Agent(session); 199 + const did = agent.assertDid; 200 + 201 + try { 202 + const { data: existing } = await agent.com.atproto.repo.getRecord({ 203 + repo: did, 204 + collection: COLLECTION, 205 + rkey, 206 + }); 207 + 208 + const existingValue = existing.value as unknown as PlaylistRecord; 209 + 210 + if (existingValue.tracks.length >= 500) { 211 + return { error: "Playlist cannot have more than 500 tracks." }; 212 + } 213 + 214 + if (existingValue.tracks.some((track) => track.uri === trackUri)) { 215 + return { error: "Track is already in this playlist." }; 216 + } 217 + 218 + await agent.com.atproto.repo.putRecord({ 219 + repo: did, 220 + collection: COLLECTION, 221 + rkey, 222 + swapRecord: existing.cid, 223 + record: { 224 + ...existingValue, 225 + tracks: [...existingValue.tracks, { uri: trackUri, cid: trackCid }], 226 + updatedAt: new Date().toISOString(), 227 + }, 228 + }); 229 + 230 + updateTag(`playlist-${did}-${rkey}`); 231 + updateTag(`playlists-${did}`); 232 + return { success: true }; 233 + } catch (error) { 234 + console.error("Failed to add track to playlist:", error); 235 + return { 236 + error: 237 + error instanceof Error 238 + ? error.message 239 + : "Something went wrong. Try again.", 240 + }; 241 + } 242 + } 243 + 244 + export async function removeTrackFromPlaylist( 245 + _prevState: { success?: boolean; error?: string } | null, 246 + formData: FormData, 247 + ) { 248 + const rkey = formData.get("rkey") as string; 249 + const trackUri = formData.get("trackUri") as string; 250 + 251 + const session = await getSession(); 252 + if (!session) { 253 + redirect("/auth/login"); 254 + } 255 + 256 + const agent = new Agent(session); 257 + const did = agent.assertDid; 258 + 259 + try { 260 + const { data: existing } = await agent.com.atproto.repo.getRecord({ 261 + repo: did, 262 + collection: COLLECTION, 263 + rkey, 264 + }); 265 + 266 + const existingValue = existing.value as unknown as PlaylistRecord; 267 + const filtered = existingValue.tracks.filter( 268 + (track) => track.uri !== trackUri, 269 + ); 270 + 271 + if (filtered.length === 0) { 272 + return { 273 + error: "Cannot remove the last track. Delete the playlist instead.", 274 + }; 275 + } 276 + 277 + await agent.com.atproto.repo.putRecord({ 278 + repo: did, 279 + collection: COLLECTION, 280 + rkey, 281 + swapRecord: existing.cid, 282 + record: { 283 + ...existingValue, 284 + tracks: filtered, 285 + updatedAt: new Date().toISOString(), 286 + }, 287 + }); 288 + 289 + updateTag(`playlist-${did}-${rkey}`); 290 + updateTag(`playlists-${did}`); 291 + return { success: true }; 292 + } catch (error) { 293 + console.error("Failed to remove track from playlist:", error); 294 + return { 295 + error: 296 + error instanceof Error 297 + ? error.message 298 + : "Something went wrong. Try again.", 299 + }; 300 + } 301 + } 302 + 303 + export async function getUserPlaylistsAction() { 304 + const session = await getSession(); 305 + if (!session) { 306 + return []; 307 + } 308 + 309 + const agent = new Agent(session); 310 + const did = agent.assertDid; 311 + 312 + try { 313 + const { data } = await agent.com.atproto.repo.listRecords({ 314 + repo: did, 315 + collection: COLLECTION, 316 + limit: 50, 317 + }); 318 + 319 + return data.records.map((record) => { 320 + const value = record.value as unknown as PlaylistRecord; 321 + return { 322 + rkey: record.uri.split("/")[4]!, 323 + name: value.name, 324 + }; 325 + }); 326 + } catch (error) { 327 + console.error("Failed to fetch user playlists:", error); 328 + return []; 329 + } 330 + }
+185
src/components/playlist/add-to-playlist-dialog.tsx
··· 1 + "use client"; 2 + 3 + import { startTransition, useActionState, useCallback, useState } from "react"; 4 + import { 5 + Dialog, 6 + DialogContent, 7 + DialogHeader, 8 + DialogTitle, 9 + DialogTrigger, 10 + } from "../ui/dialog"; 11 + import { DropdownMenuItem } from "../ui/dropdown-menu"; 12 + import { Button } from "../ui/button"; 13 + import { Input } from "../ui/input"; 14 + import { ListMusicIcon, Loader2Icon, PlusIcon, CheckIcon } from "lucide-react"; 15 + import { 16 + addTrackToPlaylist, 17 + createPlaylist, 18 + getUserPlaylistsAction, 19 + } from "./actions"; 20 + import { toast } from "sonner"; 21 + 22 + export function AddToPlaylistDialog({ 23 + trackUri, 24 + trackCid, 25 + }: { 26 + trackUri: string; 27 + trackCid: string; 28 + }) { 29 + const [open, setOpen] = useState(false); 30 + const [playlists, setPlaylists] = useState<{ rkey: string; name: string }[]>( 31 + [], 32 + ); 33 + const [loading, setLoading] = useState(false); 34 + const [showNewInput, setShowNewInput] = useState(false); 35 + const [newName, setNewName] = useState(""); 36 + const [error, setError] = useState<string | null>(null); 37 + 38 + const handleOpenChange = useCallback((value: boolean) => { 39 + setOpen(value); 40 + if (value) { 41 + setLoading(true); 42 + setShowNewInput(false); 43 + setNewName(""); 44 + setError(null); 45 + getUserPlaylistsAction().then((result) => { 46 + setPlaylists(result); 47 + setLoading(false); 48 + }); 49 + } 50 + }, []); 51 + 52 + const [, addAction, addPending] = useActionState( 53 + async ( 54 + prevState: { success?: boolean; error?: string } | null, 55 + formData: FormData, 56 + ) => { 57 + const result = await addTrackToPlaylist(prevState, formData); 58 + if (result.success) { 59 + toast.success("Added to playlist"); 60 + setOpen(false); 61 + } else if (result.error) { 62 + setError(result.error); 63 + } 64 + return result; 65 + }, 66 + null, 67 + ); 68 + 69 + const [, createAction, createPending] = useActionState( 70 + async ( 71 + prevState: { 72 + success?: boolean; 73 + error?: string; 74 + rkey?: string; 75 + } | null, 76 + formData: FormData, 77 + ) => { 78 + const result = await createPlaylist(prevState, formData); 79 + if (result.success) { 80 + toast.success("Playlist created"); 81 + setOpen(false); 82 + } else if (result.error) { 83 + setError(result.error); 84 + } 85 + return result; 86 + }, 87 + null, 88 + ); 89 + 90 + function handleAddToExisting(rkey: string) { 91 + const formData = new FormData(); 92 + formData.set("rkey", rkey); 93 + formData.set("trackUri", trackUri); 94 + formData.set("trackCid", trackCid); 95 + startTransition(() => { 96 + addAction(formData); 97 + }); 98 + } 99 + 100 + function handleCreateNew() { 101 + if (!newName.trim()) return; 102 + const formData = new FormData(); 103 + formData.set("name", newName.trim()); 104 + formData.set("trackUri", trackUri); 105 + formData.set("trackCid", trackCid); 106 + startTransition(() => { 107 + createAction(formData); 108 + }); 109 + } 110 + 111 + const pending = addPending || createPending; 112 + 113 + return ( 114 + <Dialog open={open} onOpenChange={handleOpenChange}> 115 + <DialogTrigger asChild> 116 + <DropdownMenuItem onSelect={(event) => event.preventDefault()}> 117 + <ListMusicIcon /> 118 + Add to playlist 119 + </DropdownMenuItem> 120 + </DialogTrigger> 121 + <DialogContent> 122 + <DialogHeader> 123 + <DialogTitle>Add to playlist</DialogTitle> 124 + </DialogHeader> 125 + {error && <p className="text-sm text-destructive">{error}</p>} 126 + {loading ? ( 127 + <div className="flex items-center justify-center py-4"> 128 + <Loader2Icon className="animate-spin" /> 129 + </div> 130 + ) : ( 131 + <div className="flex flex-col gap-2"> 132 + {showNewInput ? ( 133 + <div className="flex flex-row gap-2"> 134 + <Input 135 + value={newName} 136 + onChange={(event) => setNewName(event.target.value)} 137 + placeholder="Playlist name" 138 + autoFocus 139 + onKeyDown={(event) => { 140 + if (event.key === "Enter") { 141 + event.preventDefault(); 142 + handleCreateNew(); 143 + } 144 + }} 145 + /> 146 + <Button 147 + onClick={handleCreateNew} 148 + disabled={!newName.trim() || pending} 149 + size="sm" 150 + > 151 + {createPending ? ( 152 + <Loader2Icon className="animate-spin" /> 153 + ) : ( 154 + <CheckIcon /> 155 + )} 156 + </Button> 157 + </div> 158 + ) : ( 159 + <Button 160 + variant="outline" 161 + onClick={() => setShowNewInput(true)} 162 + className="justify-start" 163 + > 164 + <PlusIcon /> 165 + New playlist 166 + </Button> 167 + )} 168 + {playlists.map((playlist) => ( 169 + <Button 170 + key={playlist.rkey} 171 + variant="ghost" 172 + onClick={() => handleAddToExisting(playlist.rkey)} 173 + disabled={pending} 174 + className="justify-start" 175 + > 176 + <ListMusicIcon /> 177 + {playlist.name} 178 + </Button> 179 + ))} 180 + </div> 181 + )} 182 + </DialogContent> 183 + </Dialog> 184 + ); 185 + }
+86
src/components/playlist/delete-dialog.tsx
··· 1 + "use client"; 2 + 3 + import { Button } from "../ui/button"; 4 + import { 5 + Dialog, 6 + DialogClose, 7 + DialogContent, 8 + DialogDescription, 9 + DialogFooter, 10 + DialogHeader, 11 + DialogTitle, 12 + DialogTrigger, 13 + } from "../ui/dialog"; 14 + import { DropdownMenuItem } from "../ui/dropdown-menu"; 15 + import { Loader2Icon, TrashIcon } from "lucide-react"; 16 + import { deletePlaylist } from "./actions"; 17 + import { useActionState, useState } from "react"; 18 + import { useRouter } from "next/navigation"; 19 + 20 + export function DeletePlaylistDialog({ 21 + rkey, 22 + author, 23 + }: { 24 + rkey: string; 25 + author: string; 26 + }) { 27 + const [open, setOpen] = useState(false); 28 + const router = useRouter(); 29 + const [state, action, pending] = useActionState( 30 + async ( 31 + prevState: { success?: boolean; error?: string } | null, 32 + formData: FormData, 33 + ) => { 34 + const result = await deletePlaylist(prevState, formData); 35 + if (result.success) { 36 + setOpen(false); 37 + router.push(`/${author}/playlists`); 38 + } 39 + return result; 40 + }, 41 + null, 42 + ); 43 + 44 + return ( 45 + <Dialog open={open} onOpenChange={setOpen}> 46 + <DialogTrigger asChild> 47 + <DropdownMenuItem 48 + onSelect={(event) => event.preventDefault()} 49 + variant="destructive" 50 + > 51 + <TrashIcon /> 52 + Delete 53 + </DropdownMenuItem> 54 + </DialogTrigger> 55 + <DialogContent> 56 + <DialogHeader> 57 + <DialogTitle>Delete playlist</DialogTitle> 58 + <DialogDescription> 59 + Are you sure you want to delete this playlist? 60 + </DialogDescription> 61 + </DialogHeader> 62 + {state?.error && ( 63 + <p className="text-sm text-destructive">{state.error}</p> 64 + )} 65 + <DialogFooter> 66 + <DialogClose asChild> 67 + <Button variant="outline">Cancel</Button> 68 + </DialogClose> 69 + <form action={action}> 70 + <input type="hidden" name="rkey" value={rkey} /> 71 + <Button type="submit" variant="destructive" disabled={pending}> 72 + {pending ? ( 73 + <> 74 + <Loader2Icon className="animate-spin" /> 75 + Deleting... 76 + </> 77 + ) : ( 78 + "Delete" 79 + )} 80 + </Button> 81 + </form> 82 + </DialogFooter> 83 + </DialogContent> 84 + </Dialog> 85 + ); 86 + }
+225
src/components/playlist/edit-dialog.tsx
··· 1 + "use client"; 2 + 3 + import { Button } from "../ui/button"; 4 + import { 5 + Dialog, 6 + DialogClose, 7 + DialogContent, 8 + DialogFooter, 9 + DialogHeader, 10 + DialogTitle, 11 + DialogTrigger, 12 + } from "../ui/dialog"; 13 + import { DropdownMenuItem } from "../ui/dropdown-menu"; 14 + import { Input } from "../ui/input"; 15 + import { Textarea } from "../ui/textarea"; 16 + import { Field, FieldLabel, FieldError, FieldDescription } from "../ui/field"; 17 + import { Loader2Icon, PencilIcon, ListMusicIcon } from "lucide-react"; 18 + import Image from "next/image"; 19 + import { editPlaylist } from "./actions"; 20 + import { 21 + startTransition, 22 + useActionState, 23 + useEffect, 24 + useRef, 25 + useState, 26 + } from "react"; 27 + import { useForm } from "react-hook-form"; 28 + import { zodResolver } from "@hookform/resolvers/zod"; 29 + import { playlistSchema, type PlaylistFormData } from "./playlist-schema"; 30 + 31 + export function EditPlaylistDialog({ 32 + rkey, 33 + name, 34 + description, 35 + coverArt, 36 + }: { 37 + rkey: string; 38 + name: string; 39 + description: string | null; 40 + coverArt: string | null; 41 + }) { 42 + const [open, setOpen] = useState(false); 43 + 44 + const { 45 + register, 46 + handleSubmit, 47 + setValue, 48 + formState: { errors }, 49 + reset, 50 + } = useForm<PlaylistFormData>({ 51 + resolver: zodResolver(playlistSchema), 52 + defaultValues: { 53 + name, 54 + description: description ?? undefined, 55 + }, 56 + }); 57 + 58 + const fileInputRef = useRef<HTMLInputElement>(null); 59 + const [previewUrl, setPreviewUrl] = useState(coverArt); 60 + 61 + const previewUrlRef = useRef(previewUrl); 62 + 63 + useEffect(() => { 64 + previewUrlRef.current = previewUrl; 65 + }, [previewUrl]); 66 + 67 + useEffect( 68 + () => () => { 69 + if (previewUrlRef.current && previewUrlRef.current !== coverArt) { 70 + URL.revokeObjectURL(previewUrlRef.current); 71 + } 72 + }, 73 + [coverArt], 74 + ); 75 + 76 + useEffect(() => { 77 + if (open) { 78 + reset({ 79 + name, 80 + description: description ?? undefined, 81 + }); 82 + } 83 + }, [open, reset, name, description]); 84 + 85 + const [state, action, pending] = useActionState( 86 + async ( 87 + prevState: { success?: boolean; error?: string } | null, 88 + formData: FormData, 89 + ) => { 90 + const result = await editPlaylist(prevState, formData); 91 + if (result.success) { 92 + setOpen(false); 93 + } 94 + return result; 95 + }, 96 + null, 97 + ); 98 + 99 + async function onSubmit(data: PlaylistFormData) { 100 + const formData = new FormData(); 101 + formData.set("rkey", rkey); 102 + formData.set("name", data.name); 103 + formData.set("description", data.description ?? ""); 104 + if (data.coverArt) { 105 + formData.set("coverArt", data.coverArt); 106 + } 107 + startTransition(() => { 108 + action(formData); 109 + }); 110 + } 111 + 112 + return ( 113 + <Dialog 114 + open={open} 115 + onOpenChange={(value) => { 116 + setOpen(value); 117 + if (!value) { 118 + setPreviewUrl((prev) => { 119 + if (prev && prev !== coverArt) URL.revokeObjectURL(prev); 120 + return coverArt; 121 + }); 122 + setValue("coverArt", undefined); 123 + } 124 + }} 125 + > 126 + <DialogTrigger asChild> 127 + <DropdownMenuItem onSelect={(event) => event.preventDefault()}> 128 + <PencilIcon /> 129 + Edit 130 + </DropdownMenuItem> 131 + </DialogTrigger> 132 + <DialogContent> 133 + <DialogHeader> 134 + <DialogTitle>Edit playlist</DialogTitle> 135 + </DialogHeader> 136 + {state?.error && ( 137 + <p className="text-sm text-destructive">{state.error}</p> 138 + )} 139 + <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> 140 + <Field data-invalid={!!errors.coverArt} className="w-auto"> 141 + <FieldLabel>Cover art</FieldLabel> 142 + <FieldDescription> 143 + PNG, JPEG, or WebP. Max 10 MB. Optional. 144 + </FieldDescription> 145 + <button 146 + type="button" 147 + onClick={() => fileInputRef.current?.click()} 148 + aria-label="Change cover art" 149 + className="group relative aspect-square max-w-48 overflow-hidden rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" 150 + > 151 + {previewUrl ? ( 152 + <Image 153 + src={previewUrl} 154 + alt="Current cover art" 155 + className="size-full object-cover" 156 + width={500} 157 + height={500} 158 + unoptimized={previewUrl !== coverArt} 159 + /> 160 + ) : ( 161 + <div className="size-full bg-muted flex items-center justify-center"> 162 + <ListMusicIcon className="size-8 text-muted-foreground" /> 163 + </div> 164 + )} 165 + <div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/50 group-focus-visible:bg-black/50"> 166 + <PencilIcon className="size-8 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" /> 167 + </div> 168 + </button> 169 + <input 170 + ref={fileInputRef} 171 + type="file" 172 + accept="image/png,image/jpeg,image/webp" 173 + className="sr-only" 174 + tabIndex={-1} 175 + onChange={(event) => { 176 + const file = event.target.files?.[0]; 177 + if (file) { 178 + setValue("coverArt", file, { shouldValidate: true }); 179 + setPreviewUrl((prev) => { 180 + if (prev && prev !== coverArt) URL.revokeObjectURL(prev); 181 + return URL.createObjectURL(file); 182 + }); 183 + } 184 + }} 185 + /> 186 + <FieldError>{errors.coverArt?.message}</FieldError> 187 + </Field> 188 + 189 + <Field data-invalid={!!errors.name}> 190 + <FieldLabel htmlFor="edit-playlist-name">Name</FieldLabel> 191 + <Input id="edit-playlist-name" {...register("name")} /> 192 + <FieldError>{errors.name?.message}</FieldError> 193 + </Field> 194 + 195 + <Field data-invalid={!!errors.description}> 196 + <FieldLabel htmlFor="edit-playlist-description"> 197 + Description 198 + </FieldLabel> 199 + <Textarea 200 + id="edit-playlist-description" 201 + {...register("description")} 202 + /> 203 + <FieldError>{errors.description?.message}</FieldError> 204 + </Field> 205 + 206 + <DialogFooter> 207 + <DialogClose asChild> 208 + <Button variant="outline">Cancel</Button> 209 + </DialogClose> 210 + <Button type="submit" disabled={pending}> 211 + {pending ? ( 212 + <> 213 + <Loader2Icon className="animate-spin" /> 214 + Saving... 215 + </> 216 + ) : ( 217 + "Save" 218 + )} 219 + </Button> 220 + </DialogFooter> 221 + </form> 222 + </DialogContent> 223 + </Dialog> 224 + ); 225 + }
+1
src/components/playlist/index.tsx
··· 1 + export { PlaylistCard } from "./playlist-card";
+69
src/components/playlist/playlist-card.tsx
··· 1 + import Image from "next/image"; 2 + import Link from "next/link"; 3 + import { ListMusicIcon } from "lucide-react"; 4 + import { formatDistanceToNow, format } from "date-fns"; 5 + import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; 6 + import { PlaylistMenu } from "./playlist-menu"; 7 + import type { PlaylistProps } from "@/types/playlist"; 8 + 9 + export function PlaylistCard({ 10 + rkey, 11 + name, 12 + description, 13 + coverArt, 14 + trackCount, 15 + author, 16 + isOwner, 17 + createdAt, 18 + }: PlaylistProps) { 19 + const createdAtDate = new Date(createdAt); 20 + 21 + return ( 22 + <div className="flex flex-row items-center justify-between gap-4"> 23 + <Link 24 + href={`/${author}/playlists/${rkey}`} 25 + className="flex flex-row items-center gap-4 min-w-0 hover:opacity-80 transition-opacity" 26 + > 27 + {coverArt ? ( 28 + <Image 29 + className="rounded-md size-16 object-cover" 30 + src={coverArt} 31 + alt={name} 32 + width={64} 33 + height={64} 34 + /> 35 + ) : ( 36 + <div className="rounded-md size-16 bg-muted flex items-center justify-center"> 37 + <ListMusicIcon className="size-6 text-muted-foreground" /> 38 + </div> 39 + )} 40 + <div className="flex flex-col gap-1 min-w-0"> 41 + <span className="font-semibold truncate">{name}</span> 42 + <div className="flex flex-row items-center gap-2 text-sm text-muted-foreground"> 43 + <span> 44 + {trackCount} {trackCount === 1 ? "track" : "tracks"} 45 + </span> 46 + <span>&middot;</span> 47 + <Tooltip> 48 + <TooltipTrigger asChild> 49 + <time dateTime={createdAt}> 50 + {formatDistanceToNow(createdAtDate, { addSuffix: true })} 51 + </time> 52 + </TooltipTrigger> 53 + <TooltipContent>{format(createdAtDate, "PPP p")}</TooltipContent> 54 + </Tooltip> 55 + </div> 56 + </div> 57 + </Link> 58 + {isOwner && ( 59 + <PlaylistMenu 60 + rkey={rkey} 61 + author={author} 62 + name={name} 63 + description={description} 64 + coverArt={coverArt} 65 + /> 66 + )} 67 + </div> 68 + ); 69 + }
+47
src/components/playlist/playlist-menu.tsx
··· 1 + "use client"; 2 + 3 + import { EllipsisIcon } from "lucide-react"; 4 + import { 5 + DropdownMenu, 6 + DropdownMenuContent, 7 + DropdownMenuTrigger, 8 + } from "@/components/ui/dropdown-menu"; 9 + import { EditPlaylistDialog } from "./edit-dialog"; 10 + import { DeletePlaylistDialog } from "./delete-dialog"; 11 + 12 + export function PlaylistMenu({ 13 + rkey, 14 + author, 15 + name, 16 + description, 17 + coverArt, 18 + }: { 19 + rkey: string; 20 + author: string; 21 + name: string; 22 + description: string | null; 23 + coverArt: string | null; 24 + }) { 25 + return ( 26 + <DropdownMenu> 27 + <DropdownMenuTrigger asChild> 28 + <button 29 + type="button" 30 + className="inline-flex items-center justify-center rounded-md p-1 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" 31 + aria-label="Playlist options" 32 + > 33 + <EllipsisIcon size={18} /> 34 + </button> 35 + </DropdownMenuTrigger> 36 + <DropdownMenuContent align="end"> 37 + <EditPlaylistDialog 38 + rkey={rkey} 39 + name={name} 40 + description={description} 41 + coverArt={coverArt} 42 + /> 43 + <DeletePlaylistDialog rkey={rkey} author={author} /> 44 + </DropdownMenuContent> 45 + </DropdownMenu> 46 + ); 47 + }
+21
src/components/playlist/playlist-schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const playlistSchema = z.object({ 4 + name: z 5 + .string() 6 + .min(1, { error: "Name is required." }) 7 + .max(512, { error: "Name must be 512 characters or fewer." }), 8 + description: z 9 + .string() 10 + .max(5000, { error: "Description must be 5000 characters or fewer." }) 11 + .optional(), 12 + coverArt: z 13 + .file({ error: "Cover art must be a file." }) 14 + .mime(["image/png", "image/jpeg", "image/webp"], { 15 + error: "Cover art must be PNG, JPEG, or WebP.", 16 + }) 17 + .max(10_000_000, { error: "Cover art must be 10 MB or smaller." }) 18 + .optional(), 19 + }); 20 + 21 + export type PlaylistFormData = z.infer<typeof playlistSchema>;
+77 -7
src/components/song/song-menu.tsx
··· 1 1 "use client"; 2 2 3 - import { EllipsisIcon } from "lucide-react"; 3 + import { startTransition, useActionState } from "react"; 4 + import { EllipsisIcon, ListXIcon } from "lucide-react"; 4 5 import { 5 6 DropdownMenu, 6 7 DropdownMenuContent, 8 + DropdownMenuItem, 7 9 DropdownMenuTrigger, 8 10 } from "@/components/ui/dropdown-menu"; 9 11 import { DeleteDialog } from "./delete-dialog"; 10 12 import { EditDialog } from "./edit-dialog"; 13 + import { AddToPlaylistDialog } from "@/components/playlist/add-to-playlist-dialog"; 14 + import { removeTrackFromPlaylist } from "@/components/playlist/actions"; 15 + import { toast } from "sonner"; 16 + import { 17 + Tooltip, 18 + TooltipContent, 19 + TooltipTrigger, 20 + } from "@/components/ui/tooltip"; 11 21 12 22 export function SongMenu({ 13 23 isOwner, 24 + loggedIn, 14 25 rkey, 26 + uri, 27 + cid, 15 28 title, 16 29 description, 17 30 genre, 18 31 coverArt, 32 + playlistRkey, 33 + isLastTrack, 19 34 }: { 20 35 isOwner: boolean; 36 + loggedIn: boolean; 21 37 rkey: string; 38 + uri: string; 39 + cid: string | undefined; 22 40 title: string; 23 41 description: string | null; 24 42 genre: string | null; 25 43 coverArt: string; 44 + playlistRkey?: string; 45 + isLastTrack?: boolean; 26 46 }) { 27 - /** 28 - * the menu for now does not have non-owner actions, 29 - * return nothing until they get added. 30 - */ 31 - if (!isOwner) { 47 + const [, removeAction, _removePending] = useActionState( 48 + async ( 49 + prevState: { success?: boolean; error?: string } | null, 50 + formData: FormData, 51 + ) => { 52 + const result = await removeTrackFromPlaylist(prevState, formData); 53 + if (result?.error) { 54 + toast.error(result.error); 55 + } else { 56 + toast.success("Removed from playlist"); 57 + } 58 + return result; 59 + }, 60 + null, 61 + ); 62 + 63 + const hasPlaylistActions = loggedIn && !!cid; 64 + if (!isOwner && !hasPlaylistActions && !playlistRkey) { 32 65 return null; 33 66 } 67 + 68 + function handleRemoveFromPlaylist() { 69 + if (!playlistRkey) return; 70 + const formData = new FormData(); 71 + formData.set("rkey", playlistRkey); 72 + formData.set("trackUri", uri); 73 + startTransition(() => { 74 + removeAction(formData); 75 + }); 76 + } 77 + 34 78 return ( 35 79 <DropdownMenu> 36 80 <DropdownMenuTrigger asChild> 37 - <EllipsisIcon size={18} className="cursor-pointer" /> 81 + <button 82 + type="button" 83 + className="inline-flex items-center justify-center rounded-md p-1 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" 84 + aria-label="Song options" 85 + > 86 + <EllipsisIcon size={18} /> 87 + </button> 38 88 </DropdownMenuTrigger> 39 89 <DropdownMenuContent align="end"> 90 + {loggedIn && cid && ( 91 + <AddToPlaylistDialog trackUri={uri} trackCid={cid} /> 92 + )} 93 + {playlistRkey && 94 + (isLastTrack ? ( 95 + <Tooltip> 96 + <TooltipTrigger asChild> 97 + <DropdownMenuItem disabled> 98 + <ListXIcon size={16} /> 99 + Remove from playlist 100 + </DropdownMenuItem> 101 + </TooltipTrigger> 102 + <TooltipContent>Delete the playlist instead</TooltipContent> 103 + </Tooltip> 104 + ) : ( 105 + <DropdownMenuItem onClick={handleRemoveFromPlaylist}> 106 + <ListXIcon size={16} /> 107 + Remove from playlist 108 + </DropdownMenuItem> 109 + ))} 40 110 {isOwner && ( 41 111 <> 42 112 <EditDialog
+8
src/components/song/song.tsx
··· 33 33 duration, 34 34 author, 35 35 isOwner, 36 + loggedIn, 36 37 likeRkey, 37 38 repostRkey, 38 39 createdAt, 40 + playlistRkey, 41 + isLastTrack, 39 42 }: SongProps) { 40 43 const shareUrl = `${PUBLIC_URL}/${author}/${rkey}`; 41 44 const [, startTransition] = useTransition(); ··· 196 199 <SharePopover shareUrl={shareUrl} /> 197 200 <SongMenu 198 201 isOwner={isOwner} 202 + loggedIn={loggedIn} 199 203 rkey={rkey} 204 + uri={uri} 205 + cid={cid} 200 206 title={title} 201 207 description={description} 202 208 genre={genre} 203 209 coverArt={coverArt} 210 + playlistRkey={playlistRkey} 211 + isLastTrack={isLastTrack} 204 212 /> 205 213 </div> 206 214 </div>
+40
src/components/ui/sonner.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + CircleCheckIcon, 5 + InfoIcon, 6 + Loader2Icon, 7 + OctagonXIcon, 8 + TriangleAlertIcon, 9 + } from "lucide-react"; 10 + import { useTheme } from "next-themes"; 11 + import { Toaster as Sonner, type ToasterProps } from "sonner"; 12 + 13 + const Toaster = ({ ...props }: ToasterProps) => { 14 + const { theme = "system" } = useTheme(); 15 + 16 + return ( 17 + <Sonner 18 + theme={theme as ToasterProps["theme"]} 19 + className="toaster group" 20 + icons={{ 21 + success: <CircleCheckIcon className="size-4" />, 22 + info: <InfoIcon className="size-4" />, 23 + warning: <TriangleAlertIcon className="size-4" />, 24 + error: <OctagonXIcon className="size-4" />, 25 + loading: <Loader2Icon className="size-4 animate-spin" />, 26 + }} 27 + style={ 28 + { 29 + "--normal-bg": "var(--popover)", 30 + "--normal-text": "var(--popover-foreground)", 31 + "--normal-border": "var(--border)", 32 + "--border-radius": "var(--radius)", 33 + } as React.CSSProperties 34 + } 35 + {...props} 36 + /> 37 + ); 38 + }; 39 + 40 + export { Toaster };
+113
src/lib/playlists.ts
··· 1 + import { TID } from "@atproto/common-web"; 2 + import { Agent } from "@atproto/api"; 3 + import type { OAuthSession } from "@atproto/oauth-client-node"; 4 + import type { PlaylistProps, PlaylistRecord } from "@/types/playlist"; 5 + import type { SongProps, TrackRecord } from "@/types/song"; 6 + import { 7 + getHandleFromDid, 8 + getPds, 9 + getRkey, 10 + getUserInteractions, 11 + mapRecordToSong, 12 + } from "@/lib/songs"; 13 + 14 + export function mapRecordToPlaylist( 15 + uri: string, 16 + value: PlaylistRecord, 17 + pds: string, 18 + did: string, 19 + handle: string, 20 + ): Omit<PlaylistProps, "isOwner"> { 21 + return { 22 + uri, 23 + rkey: getRkey(uri), 24 + name: value.name, 25 + description: value.description ?? null, 26 + coverArt: value.coverArt 27 + ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt.ref.toString()}` 28 + : null, 29 + trackCount: value.tracks.length, 30 + author: handle, 31 + createdAt: new Date( 32 + TID.fromStr(getRkey(uri)).timestamp() / 1000, 33 + ).toISOString(), 34 + }; 35 + } 36 + 37 + export async function resolvePlaylistTracks( 38 + tracks: { uri: string; cid: string }[], 39 + session: OAuthSession | null, 40 + ): Promise<(SongProps | null)[]> { 41 + // Group tracks by author DID 42 + const tracksByAuthor = new Map< 43 + string, 44 + { uri: string; rkey: string; index: number }[] 45 + >(); 46 + for (let i = 0; i < tracks.length; i++) { 47 + const parts = tracks[i]!.uri.split("/"); 48 + const authorDid = parts[2]!; 49 + const rkey = parts[4]!; 50 + const existing = tracksByAuthor.get(authorDid) ?? []; 51 + existing.push({ uri: tracks[i]!.uri, rkey, index: i }); 52 + tracksByAuthor.set(authorDid, existing); 53 + } 54 + 55 + const results: (SongProps | null)[] = new Array(tracks.length).fill(null); 56 + 57 + const [{ likedUris, repostedUris }] = await Promise.all([ 58 + getUserInteractions(session), 59 + ...[...tracksByAuthor.entries()].map(async ([authorDid, authorTracks]) => { 60 + try { 61 + const authorPds = await getPds(authorDid); 62 + if (!authorPds) return; 63 + 64 + const songAgent = new Agent(authorPds); 65 + const [authorHandle, ...songResponses] = await Promise.all([ 66 + getHandleFromDid(authorDid, authorPds), 67 + ...authorTracks.map(({ rkey }) => 68 + songAgent.com.atproto.repo 69 + .getRecord({ 70 + repo: authorDid, 71 + collection: "app.musicsky.temp.song", 72 + rkey, 73 + }) 74 + .then(({ data }) => ({ data, success: true as const })) 75 + .catch((error) => { 76 + console.error( 77 + "Failed to fetch track", 78 + `at://${authorDid}/app.musicsky.temp.song/${rkey}`, 79 + error, 80 + ); 81 + return { data: null, success: false as const }; 82 + }), 83 + ), 84 + ]); 85 + 86 + for (let i = 0; i < authorTracks.length; i++) { 87 + const result = songResponses[i]!; 88 + if (!result.success || !result.data) continue; 89 + const value = result.data.value as unknown as TrackRecord; 90 + const songUri = result.data.uri; 91 + results[authorTracks[i]!.index] = { 92 + ...mapRecordToSong( 93 + songUri, 94 + value, 95 + authorPds, 96 + authorDid, 97 + authorHandle, 98 + result.data.cid, 99 + ), 100 + isOwner: session?.did === authorDid, 101 + loggedIn: session !== null, 102 + likeRkey: likedUris.get(songUri) ?? null, 103 + repostRkey: repostedUris.get(songUri) ?? null, 104 + }; 105 + } 106 + } catch (error) { 107 + console.error("Failed to resolve tracks for author", authorDid, error); 108 + } 109 + }), 110 + ]); 111 + 112 + return results; 113 + }
+2 -2
src/lib/songs.ts
··· 4 4 import type { OAuthSession } from "@atproto/oauth-client-node"; 5 5 import type { SongProps, TrackRecord } from "@/types/song"; 6 6 7 - function getRkey(uri: string) { 7 + export function getRkey(uri: string) { 8 8 return uri.split("/")[4]!; 9 9 } 10 10 ··· 15 15 did: string, 16 16 handle: string, 17 17 cid?: string, 18 - ): Omit<SongProps, "isOwner" | "likeRkey" | "repostRkey"> { 18 + ): Omit<SongProps, "isOwner" | "loggedIn" | "likeRkey" | "repostRkey"> { 19 19 return { 20 20 uri, 21 21 cid: cid,
+22
src/types/playlist.ts
··· 1 + import { type BlobRef } from "@atproto/lexicon"; 2 + 3 + export interface PlaylistRecord { 4 + name: string; 5 + description?: string; 6 + coverArt?: BlobRef; 7 + tracks: { uri: string; cid: string }[]; 8 + createdAt: string; 9 + updatedAt?: string; 10 + } 11 + 12 + export interface PlaylistProps { 13 + uri: string; 14 + rkey: string; 15 + name: string; 16 + description: string | null; 17 + coverArt: string | null; 18 + trackCount: number; 19 + author: string; 20 + isOwner: boolean; 21 + createdAt: string; 22 + }
+3
src/types/song.ts
··· 22 22 description: string | null; 23 23 author: string; 24 24 isOwner: boolean; 25 + loggedIn: boolean; 25 26 likeRkey: string | null; 26 27 repostRkey: string | null; 27 28 createdAt: string; 29 + playlistRkey?: string; 30 + isLastTrack?: boolean; 28 31 }