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: songs page

+138 -19
+4
next.config.ts
··· 16 16 port: "", 17 17 pathname: "/img/avatar/plain/**", 18 18 }, 19 + { 20 + protocol: "https", 21 + hostname: "*.bsky.network", 22 + }, 19 23 ], 20 24 }, 21 25 };
+29
src/app/[handle]/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 + 7 + export async function deleteSong(rkey: string) { 8 + const session = await getSession(); 9 + if (!session) { 10 + redirect("/auth/login"); 11 + } 12 + const agent = new Agent(session); 13 + try { 14 + await agent.com.atproto.repo.deleteRecord({ 15 + repo: agent.assertDid, 16 + collection: "app.musicsky.temp.track", 17 + rkey, 18 + }); 19 + return { success: true }; 20 + } catch (error) { 21 + console.error("Failed to delete song:", error); 22 + return { 23 + error: 24 + error instanceof Error 25 + ? error.message 26 + : "Something went wrong. Try again.", 27 + }; 28 + } 29 + }
+54 -19
src/app/[handle]/page.tsx
··· 1 1 import { Agent } from "@atproto/api"; 2 2 import { IdResolver } from "@atproto/identity"; 3 + import { Record as TrackRecord } from "@/lib/lexicons/types/app/musicsky/temp/track"; 4 + import { Song } from "./song"; 5 + import { notFound } from "next/navigation"; 3 6 4 - export async function getSongs(handle: string) { 7 + export async function getDid(handle: string) { 5 8 const agent = new Agent("https://public.api.bsky.app"); 6 - const resolver = new IdResolver(); 7 - 8 9 try { 9 10 const { data: identity } = await agent.resolveHandle({ handle }); 10 11 const did = identity.did; 12 + return did; 13 + } catch (error) { 14 + console.error("Failed to fetch DID for", handle, error); 15 + return null; 16 + } 17 + } 11 18 19 + export async function getPds(did: string) { 20 + const resolver = new IdResolver(); 21 + try { 12 22 const doc = await resolver.did.resolve(did); 13 23 14 24 const pdsService = doc?.service?.find( ··· 17 27 if (!pdsService?.serviceEndpoint) { 18 28 throw new Error("No PDS endpoint found for this user"); 19 29 } 30 + return pdsService.serviceEndpoint as string; 31 + } catch (error) { 32 + console.error("Failed to fetch PDS URL for", did, error); 33 + return null; 34 + } 35 + } 20 36 21 - const pdsAgent = new Agent(pdsService.serviceEndpoint as string); 37 + export async function getSongs(pds: string, did: string) { 38 + try { 39 + const agent = new Agent(pds); 22 40 23 - const { data } = await pdsAgent.com.atproto.repo.listRecords({ 41 + const { data } = await agent.com.atproto.repo.listRecords({ 24 42 repo: did, 25 43 collection: "app.musicsky.temp.track", 26 44 limit: 50, 27 45 }); 28 46 29 - return data.records; 47 + console.log(data); 48 + 49 + return data.records.map((record) => { 50 + const value = record.value as TrackRecord; 51 + return { 52 + rkey: record.uri.split("/")[4], 53 + title: value.title, 54 + coverArt: `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt?.ref?.toString()}`, 55 + audio: `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.audio.ref.toString()}`, 56 + genre: value.genre, 57 + duration: value.duration, 58 + description: value.description, 59 + isOwner: did === record.uri.split("/")[2], 60 + }; 61 + }); 30 62 } catch (error) { 31 - console.error("Failed to fetch songs for", handle, error); 63 + console.error("Failed to fetch songs for", did, error); 32 64 return []; 33 65 } 34 66 } ··· 39 71 params: Promise<{ handle: string }>; 40 72 }) { 41 73 const { handle } = await params; 42 - const songs = await getSongs(handle); 74 + const did = await getDid(handle); 75 + if (!did) { 76 + notFound(); 77 + } 78 + const pds = await getPds(did); 79 + if (!pds) { 80 + notFound(); 81 + } 82 + const songs = await getSongs(pds, did); 43 83 44 84 return ( 45 85 <main className="flex flex-col gap-6 w-full max-w-2xl mx-auto p-8"> ··· 49 89 <p className="text-muted-foreground">No songs found for this user.</p> 50 90 ) : ( 51 91 <ul className="space-y-4"> 52 - {songs.map((song) => { 53 - const record = song.value as unknown as { 54 - title: string; 55 - description?: string; 56 - genre?: string; 57 - duration: number; 58 - createdAt: string; 59 - }; 60 - return ( 92 + {songs.map((song) => ( 93 + <Song key={song.title} {...song} /> 94 + ))} 95 + {/*songs.map((song) => ( 61 96 <li key={song.uri} className="bg-card p-4 rounded-lg border"> 62 97 <h2 className="text-xl font-semibold">{record.title}</h2> 63 98 {record.description && ( ··· 73 108 </span> 74 109 </div> 75 110 </li> 76 - ); 77 - })} 111 + ) 112 + )*/} 78 113 </ul> 79 114 )} 80 115 </main>
+51
src/app/[handle]/song.tsx
··· 1 + "use client"; 2 + 3 + import Image from "next/image"; 4 + import { TrashIcon } from "lucide-react"; 5 + import { deleteSong } from "./actions"; 6 + 7 + export function Song({ 8 + rkey, 9 + title, 10 + coverArt, 11 + audio, 12 + genre, 13 + duration, 14 + description, 15 + isOwner, 16 + }: { 17 + rkey: string; 18 + title: string; 19 + coverArt?: string; 20 + audio: string; 21 + genre?: string; 22 + duration: number; 23 + description?: string; 24 + isOwner: boolean; 25 + }) { 26 + return ( 27 + <div key={title} className="flex flex-col gap-4"> 28 + <div className="flex flex-row gap-4 justify-between"> 29 + <div className="flex flex-row gap-4"> 30 + {coverArt && ( 31 + <Image src={coverArt} alt={title} width={100} height={100} /> 32 + )} 33 + <div className="flex flex-col"> 34 + <h2 className="text-xl font-semibold">{title}</h2> 35 + {genre && <h3>{genre}</h3>} 36 + {description && <p>{description}</p>} 37 + </div> 38 + </div> 39 + <div> 40 + {isOwner && ( 41 + <TrashIcon 42 + className="text-red-500 cursor-pointer" 43 + onClick={() => deleteSong(rkey)} 44 + /> 45 + )} 46 + </div> 47 + </div> 48 + <audio controls src={audio} /> 49 + </div> 50 + ); 51 + }