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.

refactor: move `song` to components

+336 -61
+1
next.config.ts
··· 2 2 3 3 const nextConfig: NextConfig = { 4 4 /* config options here */ 5 + cacheComponents: true, 5 6 reactCompiler: true, 6 7 experimental: { 7 8 serverActions: {
+7 -1
src/app/[handle]/actions.ts src/components/song/actions.ts
··· 3 3 import { getSession } from "@/lib/auth/session"; 4 4 import { redirect } from "next/navigation"; 5 5 import { Agent } from "@atproto/api"; 6 + import { updateTag } from "next/cache"; 6 7 7 - export async function deleteSong(rkey: string) { 8 + export async function deleteSong( 9 + _prevState: { success?: boolean; error?: string } | null, 10 + formData: FormData, 11 + ) { 12 + const rkey = formData.get("rkey") as string; 8 13 const session = await getSession(); 9 14 if (!session) { 10 15 redirect("/auth/login"); ··· 16 21 collection: "app.musicsky.temp.track", 17 22 rkey, 18 23 }); 24 + updateTag("songs"); 19 25 return { success: true }; 20 26 } catch (error) { 21 27 console.error("Failed to delete song:", error);
+11 -9
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"; 3 + import { type Record as TrackRecord } from "@/lib/lexicons/types/app/musicsky/temp/track"; 4 + import { Song } from "@/components/song"; 5 5 import { notFound } from "next/navigation"; 6 + import { cacheTag } from "next/cache"; 6 7 7 8 export async function getDid(handle: string) { 8 9 const agent = new Agent("https://public.api.bsky.app"); ··· 35 36 } 36 37 37 38 export async function getSongs(pds: string, did: string) { 39 + "use cache"; 40 + cacheTag("songs"); 38 41 try { 39 42 const agent = new Agent(pds); 40 43 ··· 43 46 collection: "app.musicsky.temp.track", 44 47 limit: 50, 45 48 }); 46 - 47 - console.log(data); 48 - 49 49 return data.records.map((record) => { 50 50 const value = record.value as TrackRecord; 51 51 return { 52 - rkey: record.uri.split("/")[4], 52 + rkey: record.uri.split("/")[4]!, 53 53 title: value.title, 54 - coverArt: `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt?.ref?.toString()}`, 54 + coverArt: value.coverArt 55 + ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt?.ref?.toString()}` 56 + : null, 55 57 audio: `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.audio.ref.toString()}`, 56 - genre: value.genre, 58 + genre: value.genre ?? null, 57 59 duration: value.duration, 58 - description: value.description, 60 + description: value.description ?? null, 59 61 isOwner: did === record.uri.split("/")[2], 60 62 }; 61 63 });
-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 - }
+77
src/components/song/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 { deleteSong } from "@/components/song/actions"; 17 + import { useActionState, useState } from "react"; 18 + 19 + export function DeleteDialog({ rkey }: { rkey: string }) { 20 + const [open, setOpen] = useState(false); 21 + const [state, action, pending] = useActionState( 22 + async ( 23 + prevState: { success?: boolean; error?: string } | null, 24 + formData: FormData, 25 + ) => { 26 + const result = await deleteSong(prevState, formData); 27 + if (result.success) { 28 + setOpen(false); 29 + } 30 + return result; 31 + }, 32 + null, 33 + ); 34 + 35 + return ( 36 + <Dialog open={open} onOpenChange={setOpen}> 37 + <DialogTrigger asChild> 38 + <DropdownMenuItem 39 + onSelect={(event) => event.preventDefault()} 40 + variant="destructive" 41 + > 42 + <TrashIcon /> 43 + Delete 44 + </DropdownMenuItem> 45 + </DialogTrigger> 46 + <DialogContent> 47 + <DialogHeader> 48 + <DialogTitle>Delete song</DialogTitle> 49 + <DialogDescription> 50 + Are you sure you want to delete this song? 51 + </DialogDescription> 52 + </DialogHeader> 53 + {state?.error && ( 54 + <p className="text-sm text-destructive">{state.error}</p> 55 + )} 56 + <DialogFooter> 57 + <DialogClose asChild> 58 + <Button variant="outline">Cancel</Button> 59 + </DialogClose> 60 + <form action={action}> 61 + <input type="hidden" name="rkey" value={rkey} /> 62 + <Button type="submit" variant="destructive" disabled={pending}> 63 + {pending ? ( 64 + <> 65 + <Loader2Icon className="animate-spin" /> 66 + Deleting... 67 + </> 68 + ) : ( 69 + "Delete" 70 + )} 71 + </Button> 72 + </form> 73 + </DialogFooter> 74 + </DialogContent> 75 + </Dialog> 76 + ); 77 + }
+1
src/components/song/index.tsx
··· 1 + export { Song } from "./song";
+81
src/components/song/song.tsx
··· 1 + "use client"; 2 + 3 + import Image from "next/image"; 4 + import { 5 + DownloadIcon, 6 + EllipsisIcon, 7 + PencilIcon, 8 + Share2Icon, 9 + } from "lucide-react"; 10 + import { 11 + DropdownMenu, 12 + DropdownMenuContent, 13 + DropdownMenuItem, 14 + DropdownMenuSeparator, 15 + DropdownMenuTrigger, 16 + } from "@/components/ui/dropdown-menu"; 17 + import { DeleteDialog } from "./delete-dialog"; 18 + 19 + export function Song({ 20 + rkey, 21 + title, 22 + coverArt, 23 + audio, 24 + genre, 25 + duration, 26 + description, 27 + isOwner, 28 + }: { 29 + rkey: string; 30 + title: string; 31 + coverArt: string | null; 32 + audio: string; 33 + genre: string | null; 34 + duration: number; 35 + description: string | null; 36 + isOwner: boolean; 37 + }) { 38 + return ( 39 + <div key={title} className="flex flex-col gap-4"> 40 + <div className="flex flex-row gap-4"> 41 + <div className="w-full flex flex-row gap-4"> 42 + {coverArt && ( 43 + <Image src={coverArt} alt={title} width={100} height={100} /> 44 + )} 45 + <div className="flex flex-col"> 46 + <h2 className="text-xl font-semibold">{title}</h2> 47 + {genre && <h3>{genre}</h3>} 48 + {description && <p>{description}</p>} 49 + <p>{duration}</p> 50 + </div> 51 + </div> 52 + <DropdownMenu> 53 + <DropdownMenuTrigger asChild> 54 + <EllipsisIcon className="cursor-pointer" /> 55 + </DropdownMenuTrigger> 56 + <DropdownMenuContent> 57 + <DropdownMenuItem> 58 + <Share2Icon /> 59 + Share 60 + </DropdownMenuItem> 61 + <DropdownMenuItem> 62 + <DownloadIcon /> 63 + Download 64 + </DropdownMenuItem> 65 + {isOwner && ( 66 + <> 67 + <DropdownMenuItem> 68 + <PencilIcon /> 69 + Edit 70 + </DropdownMenuItem> 71 + <DropdownMenuSeparator /> 72 + <DeleteDialog rkey={rkey} /> 73 + </> 74 + )} 75 + </DropdownMenuContent> 76 + </DropdownMenu> 77 + </div> 78 + <audio controls src={audio} /> 79 + </div> 80 + ); 81 + }
+158
src/components/ui/dialog.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { XIcon } from "lucide-react"; 5 + import { Dialog as DialogPrimitive } from "radix-ui"; 6 + 7 + import { cn } from "@/lib/utils"; 8 + import { Button } from "@/components/ui/button"; 9 + 10 + function Dialog({ 11 + ...props 12 + }: React.ComponentProps<typeof DialogPrimitive.Root>) { 13 + return <DialogPrimitive.Root data-slot="dialog" {...props} />; 14 + } 15 + 16 + function DialogTrigger({ 17 + ...props 18 + }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { 19 + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; 20 + } 21 + 22 + function DialogPortal({ 23 + ...props 24 + }: React.ComponentProps<typeof DialogPrimitive.Portal>) { 25 + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; 26 + } 27 + 28 + function DialogClose({ 29 + ...props 30 + }: React.ComponentProps<typeof DialogPrimitive.Close>) { 31 + return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; 32 + } 33 + 34 + function DialogOverlay({ 35 + className, 36 + ...props 37 + }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { 38 + return ( 39 + <DialogPrimitive.Overlay 40 + data-slot="dialog-overlay" 41 + className={cn( 42 + "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", 43 + className, 44 + )} 45 + {...props} 46 + /> 47 + ); 48 + } 49 + 50 + function DialogContent({ 51 + className, 52 + children, 53 + showCloseButton = true, 54 + ...props 55 + }: React.ComponentProps<typeof DialogPrimitive.Content> & { 56 + showCloseButton?: boolean; 57 + }) { 58 + return ( 59 + <DialogPortal data-slot="dialog-portal"> 60 + <DialogOverlay /> 61 + <DialogPrimitive.Content 62 + data-slot="dialog-content" 63 + className={cn( 64 + "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg", 65 + className, 66 + )} 67 + {...props} 68 + > 69 + {children} 70 + {showCloseButton && ( 71 + <DialogPrimitive.Close 72 + data-slot="dialog-close" 73 + className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" 74 + > 75 + <XIcon /> 76 + <span className="sr-only">Close</span> 77 + </DialogPrimitive.Close> 78 + )} 79 + </DialogPrimitive.Content> 80 + </DialogPortal> 81 + ); 82 + } 83 + 84 + function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 85 + return ( 86 + <div 87 + data-slot="dialog-header" 88 + className={cn("flex flex-col gap-2 text-center sm:text-left", className)} 89 + {...props} 90 + /> 91 + ); 92 + } 93 + 94 + function DialogFooter({ 95 + className, 96 + showCloseButton = false, 97 + children, 98 + ...props 99 + }: React.ComponentProps<"div"> & { 100 + showCloseButton?: boolean; 101 + }) { 102 + return ( 103 + <div 104 + data-slot="dialog-footer" 105 + className={cn( 106 + "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", 107 + className, 108 + )} 109 + {...props} 110 + > 111 + {children} 112 + {showCloseButton && ( 113 + <DialogPrimitive.Close asChild> 114 + <Button variant="outline">Close</Button> 115 + </DialogPrimitive.Close> 116 + )} 117 + </div> 118 + ); 119 + } 120 + 121 + function DialogTitle({ 122 + className, 123 + ...props 124 + }: React.ComponentProps<typeof DialogPrimitive.Title>) { 125 + return ( 126 + <DialogPrimitive.Title 127 + data-slot="dialog-title" 128 + className={cn("text-lg leading-none font-semibold", className)} 129 + {...props} 130 + /> 131 + ); 132 + } 133 + 134 + function DialogDescription({ 135 + className, 136 + ...props 137 + }: React.ComponentProps<typeof DialogPrimitive.Description>) { 138 + return ( 139 + <DialogPrimitive.Description 140 + data-slot="dialog-description" 141 + className={cn("text-sm text-muted-foreground", className)} 142 + {...props} 143 + /> 144 + ); 145 + } 146 + 147 + export { 148 + Dialog, 149 + DialogClose, 150 + DialogContent, 151 + DialogDescription, 152 + DialogFooter, 153 + DialogHeader, 154 + DialogOverlay, 155 + DialogPortal, 156 + DialogTitle, 157 + DialogTrigger, 158 + };