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: improve codebase architecture

Extract duplicated patterns into reusable modules:
- AT Protocol URI primitives (atproto.ts) for consistent URI parsing
- ActionResult type for standardized server action contracts
- useInteraction hook to deduplicate like/repost logic and fix player store sync bug
- resolveRecordsByAuthor for shared batch-fetch pattern
- useCoverArtPreview hook for cover art preview lifecycle
- ConfirmDeleteDialog for generic delete confirmation

Signed-off-by: mejsiejdev <mejsiejdev@gmail.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

authored by

mejsiejdev
Claude Opus 4.6
and committed by tangled.org d42d731b 7ac7058d

+543 -587
+2 -1
.gitignore
··· 44 44 app.db* 45 45 46 46 # claude 47 - .claude/settings.local.json 47 + .claude/settings.local.json 48 + .claude/worktrees/*
+12 -9
src/app/(main)/[handle]/likes/likes-list.tsx
··· 11 11 getUserInteractions, 12 12 mapRecordToSong, 13 13 } from "@/lib/songs"; 14 + import { 15 + getDidFromUri, 16 + getRkeyFromUri, 17 + isResourceOwner, 18 + COLLECTIONS, 19 + } from "@/lib/atproto"; 14 20 import { HeartCrackIcon } from "lucide-react"; 15 21 16 22 interface LikeRecord { ··· 39 45 40 46 const { data } = await agent.com.atproto.repo.listRecords({ 41 47 repo: profileDid, 42 - collection: "app.musicsky.temp.like", 48 + collection: COLLECTIONS.like, 43 49 limit: 50, 44 50 }); 45 51 46 - // Group likes by author DID to deduplicate PDS/handle resolution 47 52 const likesByAuthor = new Map< 48 53 string, 49 54 { rkey: string; createdAt: string }[] 50 55 >(); 51 56 for (const record of data.records) { 52 57 const like = record.value as unknown as LikeRecord; 53 - const parts = like.subject.uri.split("/"); 54 - const authorDid = parts[2]!; 55 - const rkey = parts[4]!; 58 + const authorDid = getDidFromUri(like.subject.uri); 59 + const rkey = getRkeyFromUri(like.subject.uri); 56 60 const existing = likesByAuthor.get(authorDid) ?? []; 57 61 existing.push({ rkey, createdAt: like.createdAt }); 58 62 likesByAuthor.set(authorDid, existing); 59 63 } 60 64 61 - // Resolve each author once, then fetch their songs in parallel 62 65 const songResults = await Promise.all( 63 66 [...likesByAuthor.entries()].map(async ([authorDid, likes]) => { 64 67 try { ··· 72 75 return songAgent.com.atproto.repo 73 76 .getRecord({ 74 77 repo: authorDid, 75 - collection: "app.musicsky.temp.song", 78 + collection: COLLECTIONS.song, 76 79 rkey, 77 80 }) 78 81 .then(({ data: songData }) => ({ ··· 82 85 .catch((error) => { 83 86 console.error( 84 87 "Failed to fetch liked song", 85 - `at://${authorDid}/app.musicsky.temp.song/${rkey}`, 88 + `at://${authorDid}/${COLLECTIONS.song}/${rkey}`, 86 89 error, 87 90 ); 88 91 return { songData: null, success: false as const }; ··· 160 163 return songs.map((song) => { 161 164 const songProps: SongProps = { 162 165 ...song, 163 - isOwner: session?.did === song.uri.split("/")[2], 166 + isOwner: isResourceOwner(session?.did, song.uri), 164 167 loggedIn: session !== null, 165 168 likeRkey: likedUris.get(song.uri) ?? null, 166 169 repostRkey: repostedUris.get(song.uri) ?? null,
+3 -2
src/app/(main)/[handle]/songs-list.tsx
··· 10 10 getUserInteractions, 11 11 mapRecordToSong, 12 12 } from "@/lib/songs"; 13 + import { COLLECTIONS, isResourceOwner } from "@/lib/atproto"; 13 14 14 15 async function getSongs(pds: string, did: string, handle: string) { 15 16 "use cache"; ··· 19 20 20 21 const { data } = await agent.com.atproto.repo.listRecords({ 21 22 repo: did, 22 - collection: "app.musicsky.temp.song", 23 + collection: COLLECTIONS.song, 23 24 limit: 50, 24 25 }); 25 26 return data.records.map((record) => { ··· 55 56 return songs.map((song) => { 56 57 const songProps: SongProps = { 57 58 ...song, 58 - isOwner: session?.did === song.uri.split("/")[2], 59 + isOwner: isResourceOwner(session?.did, song.uri), 59 60 loggedIn: session !== null, 60 61 likeRkey: likedUris.get(song.uri) ?? null, 61 62 repostRkey: repostedUris.get(song.uri) ?? null,
+10 -66
src/components/player-bar/player-bar.tsx
··· 1 1 "use client"; 2 2 3 - import { 4 - useEffect, 5 - useRef, 6 - useOptimistic, 7 - useTransition, 8 - useState, 9 - } from "react"; 3 + import { useEffect, useRef, useState } from "react"; 10 4 import { usePlayerStore } from "@/stores/player-store"; 11 - import { 12 - likeAction, 13 - unlikeAction, 14 - repostAction, 15 - unrepostAction, 16 - } from "@/components/song/interaction-actions"; 5 + import { useInteraction } from "@/hooks/use-interaction"; 17 6 import { SongInfo } from "./song-info"; 18 7 import { PlayerControls } from "./player-controls"; 19 8 import { ProgressBar } from "./progress-bar"; ··· 24 13 const pause = usePlayerStore((store) => store.pause); 25 14 const resume = usePlayerStore((store) => store.resume); 26 15 const stop = usePlayerStore((store) => store.stop); 27 - const setLikeRkey = usePlayerStore((store) => store.setLikeRkey); 28 - const setRepostRkey = usePlayerStore((store) => store.setRepostRkey); 29 16 30 17 const audioRef = useRef<HTMLAudioElement>(null); 31 - const [, startTransition] = useTransition(); 32 18 const [currentTime, setCurrentTime] = useState(0); 33 19 34 - const [optimisticLiked, setOptimisticLiked] = useOptimistic( 35 - !!currentSong?.likeRkey, 36 - ); 37 - const [optimisticReposted, setOptimisticReposted] = useOptimistic( 38 - !!currentSong?.repostRkey, 39 - ); 20 + const { optimisticLiked, optimisticReposted, handleLike, handleRepost } = 21 + useInteraction({ 22 + uri: currentSong?.uri ?? "", 23 + cid: currentSong?.cid, 24 + author: currentSong?.author ?? "", 25 + likeRkey: currentSong?.likeRkey ?? null, 26 + repostRkey: currentSong?.repostRkey ?? null, 27 + }); 40 28 41 29 useEffect(() => { 42 30 const audio = audioRef.current; ··· 59 47 }, [currentSong?.audio]); 60 48 61 49 if (!currentSong) return null; 62 - 63 - function handleLike() { 64 - if (!currentSong) return; 65 - startTransition(async () => { 66 - if (optimisticLiked) { 67 - setOptimisticLiked(false); 68 - if (currentSong.likeRkey) { 69 - await unlikeAction(currentSong.likeRkey, currentSong.author); 70 - setLikeRkey(null); 71 - } 72 - } else { 73 - if (!currentSong.cid) return; 74 - setOptimisticLiked(true); 75 - const newRkey = await likeAction( 76 - currentSong.uri, 77 - currentSong.cid, 78 - currentSong.author, 79 - ); 80 - setLikeRkey(newRkey ?? null); 81 - } 82 - }); 83 - } 84 - 85 - function handleRepost() { 86 - if (!currentSong) return; 87 - startTransition(async () => { 88 - if (optimisticReposted) { 89 - setOptimisticReposted(false); 90 - if (currentSong.repostRkey) { 91 - await unrepostAction(currentSong.repostRkey, currentSong.author); 92 - setRepostRkey(null); 93 - } 94 - } else { 95 - if (!currentSong.cid) return; 96 - setOptimisticReposted(true); 97 - const newRkey = await repostAction( 98 - currentSong.uri, 99 - currentSong.cid, 100 - currentSong.author, 101 - ); 102 - setRepostRkey(newRkey ?? null); 103 - } 104 - }); 105 - } 106 50 107 51 function handleSeek(time: number) { 108 52 setCurrentTime(time);
+51 -97
src/components/playlist/actions.ts
··· 1 1 "use server"; 2 2 3 - import { getSession } from "@/lib/auth/session"; 4 - import { redirect } from "next/navigation"; 5 3 import { Agent } from "@atproto/api"; 6 4 import { updateTag } from "next/cache"; 7 5 import { playlistSchema } from "./playlist-schema"; 8 6 import type { PlaylistRecord } from "@/types/playlist"; 9 7 import { getHandleFromDid } from "@/lib/songs"; 10 - const COLLECTION = "app.musicsky.temp.playlist"; 8 + import { COLLECTIONS, getRkeyFromUri } from "@/lib/atproto"; 9 + import { type ActionResult, ok, fail } from "@/lib/action-result"; 10 + import { requireSession } from "@/lib/repo"; 11 11 12 12 export async function createPlaylist( 13 - _prevState: { success?: boolean; error?: string; rkey?: string } | null, 13 + _prevState: ActionResult<{ rkey: string; handle: string }> | null, 14 14 formData: FormData, 15 - ) { 15 + ): Promise<ActionResult<{ rkey: string; handle: string }>> { 16 16 const coverArtFile = formData.get("coverArt") as File | null; 17 17 const raw = { 18 18 name: formData.get("name") as string, ··· 22 22 23 23 const parsed = playlistSchema.safeParse(raw); 24 24 if (!parsed.success) { 25 - return { error: parsed.error.issues[0]?.message ?? "Invalid input." }; 25 + return fail(new Error(parsed.error.issues[0]?.message ?? "Invalid input.")); 26 26 } 27 27 28 28 const trackUri = formData.get("trackUri") as string; 29 29 const trackCid = formData.get("trackCid") as string; 30 30 if (!trackUri || !trackCid) { 31 - return { error: "A playlist must have at least one track." }; 31 + return fail(new Error("A playlist must have at least one track.")); 32 32 } 33 33 34 - const session = await getSession(); 35 - if (!session) { 36 - redirect("/auth/login"); 37 - } 38 - 34 + const session = await requireSession(); 39 35 const agent = new Agent(session); 40 36 const did = agent.assertDid; 41 37 ··· 51 47 52 48 const { data } = await agent.com.atproto.repo.createRecord({ 53 49 repo: did, 54 - collection: COLLECTION, 50 + collection: COLLECTIONS.playlist, 55 51 record: { 56 - $type: COLLECTION, 52 + $type: COLLECTIONS.playlist, 57 53 name: parsed.data.name, 58 54 description: parsed.data.description, 59 55 coverArt, ··· 62 58 }, 63 59 }); 64 60 65 - const rkey = data.uri.split("/")[4]!; 61 + const rkey = getRkeyFromUri(data.uri); 66 62 const handle = await getHandleFromDid(did); 67 63 updateTag(`playlists-${did}`); 68 - return { success: true, rkey, handle }; 64 + return ok({ rkey, handle }); 69 65 } catch (error) { 70 66 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 - }; 67 + return fail(error); 77 68 } 78 69 } 79 70 80 71 export async function editPlaylist( 81 - _prevState: { success?: boolean; error?: string } | null, 72 + _prevState: ActionResult | null, 82 73 formData: FormData, 83 - ) { 74 + ): Promise<ActionResult> { 84 75 const rkey = formData.get("rkey") as string; 85 76 const coverArtFile = formData.get("coverArt") as File | null; 86 77 const raw = { ··· 91 82 92 83 const parsed = playlistSchema.safeParse(raw); 93 84 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"); 85 + return fail(new Error(parsed.error.issues[0]?.message ?? "Invalid input.")); 100 86 } 101 87 88 + const session = await requireSession(); 102 89 const agent = new Agent(session); 103 90 const did = agent.assertDid; 104 91 105 92 try { 106 93 const { data: existing } = await agent.com.atproto.repo.getRecord({ 107 94 repo: did, 108 - collection: COLLECTION, 95 + collection: COLLECTIONS.playlist, 109 96 rkey, 110 97 }); 111 98 ··· 122 109 123 110 await agent.com.atproto.repo.putRecord({ 124 111 repo: did, 125 - collection: COLLECTION, 112 + collection: COLLECTIONS.playlist, 126 113 rkey, 127 114 swapRecord: existing.cid, 128 115 record: { ··· 136 123 137 124 updateTag(`playlist-${did}-${rkey}`); 138 125 updateTag(`playlists-${did}`); 139 - return { success: true }; 126 + return ok(); 140 127 } catch (error) { 141 128 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 - }; 129 + return fail(error); 148 130 } 149 131 } 150 132 151 133 export async function deletePlaylist( 152 - _prevState: { success?: boolean; error?: string } | null, 134 + _prevState: ActionResult | null, 153 135 formData: FormData, 154 - ) { 136 + ): Promise<ActionResult> { 155 137 const rkey = formData.get("rkey") as string; 156 - const session = await getSession(); 157 - if (!session) { 158 - redirect("/auth/login"); 159 - } 138 + const session = await requireSession(); 160 139 const agent = new Agent(session); 161 140 try { 162 141 await agent.com.atproto.repo.deleteRecord({ 163 142 repo: agent.assertDid, 164 - collection: COLLECTION, 143 + collection: COLLECTIONS.playlist, 165 144 rkey, 166 145 }); 167 146 updateTag(`playlist-${agent.assertDid}-${rkey}`); 168 147 updateTag(`playlists-${agent.assertDid}`); 169 - return { success: true }; 148 + return ok(); 170 149 } catch (error) { 171 150 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 - }; 151 + return fail(error); 178 152 } 179 153 } 180 154 181 155 export async function addTrackToPlaylist( 182 - _prevState: { success?: boolean; error?: string } | null, 156 + _prevState: ActionResult | null, 183 157 formData: FormData, 184 - ) { 158 + ): Promise<ActionResult> { 185 159 const rkey = formData.get("rkey") as string; 186 160 const trackUri = formData.get("trackUri") as string; 187 161 const trackCid = formData.get("trackCid") as string; 188 162 189 163 if (!trackUri || !trackCid) { 190 - return { error: "Missing track information." }; 164 + return fail(new Error("Missing track information.")); 191 165 } 192 166 193 - const session = await getSession(); 194 - if (!session) { 195 - redirect("/auth/login"); 196 - } 197 - 167 + const session = await requireSession(); 198 168 const agent = new Agent(session); 199 169 const did = agent.assertDid; 200 170 201 171 try { 202 172 const { data: existing } = await agent.com.atproto.repo.getRecord({ 203 173 repo: did, 204 - collection: COLLECTION, 174 + collection: COLLECTIONS.playlist, 205 175 rkey, 206 176 }); 207 177 208 178 const existingValue = existing.value as unknown as PlaylistRecord; 209 179 210 180 if (existingValue.tracks.length >= 500) { 211 - return { error: "Playlist cannot have more than 500 tracks." }; 181 + return fail(new Error("Playlist cannot have more than 500 tracks.")); 212 182 } 213 183 214 184 if (existingValue.tracks.some((track) => track.uri === trackUri)) { 215 - return { error: "Track is already in this playlist." }; 185 + return fail(new Error("Track is already in this playlist.")); 216 186 } 217 187 218 188 await agent.com.atproto.repo.putRecord({ 219 189 repo: did, 220 - collection: COLLECTION, 190 + collection: COLLECTIONS.playlist, 221 191 rkey, 222 192 swapRecord: existing.cid, 223 193 record: { ··· 229 199 230 200 updateTag(`playlist-${did}-${rkey}`); 231 201 updateTag(`playlists-${did}`); 232 - return { success: true }; 202 + return ok(); 233 203 } catch (error) { 234 204 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 - }; 205 + return fail(error); 241 206 } 242 207 } 243 208 244 209 export async function removeTrackFromPlaylist( 245 - _prevState: { success?: boolean; error?: string } | null, 210 + _prevState: ActionResult | null, 246 211 formData: FormData, 247 - ) { 212 + ): Promise<ActionResult> { 248 213 const rkey = formData.get("rkey") as string; 249 214 const trackUri = formData.get("trackUri") as string; 250 215 251 - const session = await getSession(); 252 - if (!session) { 253 - redirect("/auth/login"); 254 - } 255 - 216 + const session = await requireSession(); 256 217 const agent = new Agent(session); 257 218 const did = agent.assertDid; 258 219 259 220 try { 260 221 const { data: existing } = await agent.com.atproto.repo.getRecord({ 261 222 repo: did, 262 - collection: COLLECTION, 223 + collection: COLLECTIONS.playlist, 263 224 rkey, 264 225 }); 265 226 ··· 269 230 ); 270 231 271 232 if (filtered.length === 0) { 272 - return { 273 - error: "Cannot remove the last track. Delete the playlist instead.", 274 - }; 233 + return fail( 234 + new Error("Cannot remove the last track. Delete the playlist instead."), 235 + ); 275 236 } 276 237 277 238 await agent.com.atproto.repo.putRecord({ 278 239 repo: did, 279 - collection: COLLECTION, 240 + collection: COLLECTIONS.playlist, 280 241 rkey, 281 242 swapRecord: existing.cid, 282 243 record: { ··· 288 249 289 250 updateTag(`playlist-${did}-${rkey}`); 290 251 updateTag(`playlists-${did}`); 291 - return { success: true }; 252 + return ok(); 292 253 } catch (error) { 293 254 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 - }; 255 + return fail(error); 300 256 } 301 257 } 302 258 303 259 export async function getUserPlaylistsAction() { 260 + const { getSession } = await import("@/lib/auth/session"); 304 261 const session = await getSession(); 305 - if (!session) { 306 - return []; 307 - } 308 - 262 + if (!session) return []; 309 263 const agent = new Agent(session); 310 264 const did = agent.assertDid; 311 265 312 266 try { 313 267 const { data } = await agent.com.atproto.repo.listRecords({ 314 268 repo: did, 315 - collection: COLLECTION, 269 + collection: COLLECTIONS.playlist, 316 270 limit: 50, 317 271 }); 318 272 319 273 return data.records.map((record) => { 320 274 const value = record.value as unknown as PlaylistRecord; 321 275 return { 322 - rkey: record.uri.split("/")[4]!, 276 + rkey: getRkeyFromUri(record.uri), 323 277 name: value.name, 324 278 }; 325 279 });
+5 -11
src/components/playlist/add-to-playlist-dialog.tsx
··· 17 17 createPlaylist, 18 18 getUserPlaylistsAction, 19 19 } from "./actions"; 20 + import type { ActionResult } from "@/lib/action-result"; 20 21 import { toast } from "sonner"; 21 22 22 23 export function AddToPlaylistDialog({ ··· 50 51 }, []); 51 52 52 53 const [, addAction, addPending] = useActionState( 53 - async ( 54 - prevState: { success?: boolean; error?: string } | null, 55 - formData: FormData, 56 - ) => { 54 + async (prevState: ActionResult | null, formData: FormData) => { 57 55 const result = await addTrackToPlaylist(prevState, formData); 58 56 if (result.success) { 59 57 toast.success("Added to playlist"); 60 58 setOpen(false); 61 - } else if (result.error) { 59 + } else { 62 60 setError(result.error); 63 61 } 64 62 return result; ··· 68 66 69 67 const [, createAction, createPending] = useActionState( 70 68 async ( 71 - prevState: { 72 - success?: boolean; 73 - error?: string; 74 - rkey?: string; 75 - } | null, 69 + prevState: ActionResult<{ rkey: string; handle: string }> | null, 76 70 formData: FormData, 77 71 ) => { 78 72 const result = await createPlaylist(prevState, formData); 79 73 if (result.success) { 80 74 toast.success("Playlist created"); 81 75 setOpen(false); 82 - } else if (result.error) { 76 + } else { 83 77 setError(result.error); 84 78 } 85 79 return result;
+11 -69
src/components/playlist/delete-dialog.tsx
··· 1 1 "use client"; 2 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 3 import { deletePlaylist } from "./actions"; 17 - import { useActionState, useState } from "react"; 4 + import { ConfirmDeleteDialog } from "../ui/confirm-delete-dialog"; 18 5 import { useRouter } from "next/navigation"; 6 + import type { ActionResult } from "@/lib/action-result"; 19 7 20 8 export function DeletePlaylistDialog({ 21 9 rkey, ··· 24 12 rkey: string; 25 13 author: string; 26 14 }) { 27 - const [open, setOpen] = useState(false); 28 15 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 16 44 17 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> 18 + <ConfirmDeleteDialog 19 + title="Delete playlist" 20 + description="Are you sure you want to delete this playlist?" 21 + action={async (prevState: ActionResult | null, formData: FormData) => { 22 + formData.set("rkey", rkey); 23 + return deletePlaylist(prevState, formData); 24 + }} 25 + onSuccess={() => router.push(`/${author}/playlists`)} 26 + /> 85 27 ); 86 28 }
+9 -37
src/components/playlist/edit-dialog.tsx
··· 17 17 import { Loader2Icon, PencilIcon, ListMusicIcon } from "lucide-react"; 18 18 import Image from "next/image"; 19 19 import { editPlaylist } from "./actions"; 20 - import { 21 - startTransition, 22 - useActionState, 23 - useEffect, 24 - useRef, 25 - useState, 26 - } from "react"; 20 + import type { ActionResult } from "@/lib/action-result"; 21 + import { startTransition, useActionState, useEffect, useState } from "react"; 22 + import { useCoverArtPreview } from "@/hooks/use-cover-art-preview"; 27 23 import { useForm } from "react-hook-form"; 28 24 import { zodResolver } from "@hookform/resolvers/zod"; 29 25 import { playlistSchema, type PlaylistFormData } from "./playlist-schema"; ··· 55 51 }, 56 52 }); 57 53 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 - ); 54 + const { fileInputRef, previewUrl, onFileChange, resetPreview } = 55 + useCoverArtPreview(coverArt); 75 56 76 57 useEffect(() => { 77 58 if (open) { ··· 83 64 }, [open, reset, name, description]); 84 65 85 66 const [state, action, pending] = useActionState( 86 - async ( 87 - prevState: { success?: boolean; error?: string } | null, 88 - formData: FormData, 89 - ) => { 67 + async (prevState: ActionResult | null, formData: FormData) => { 90 68 const result = await editPlaylist(prevState, formData); 91 69 if (result.success) { 92 70 setOpen(false); ··· 115 93 onOpenChange={(value) => { 116 94 setOpen(value); 117 95 if (!value) { 118 - setPreviewUrl((prev) => { 119 - if (prev && prev !== coverArt) URL.revokeObjectURL(prev); 120 - return coverArt; 121 - }); 96 + resetPreview(); 122 97 setValue("coverArt", undefined); 123 98 } 124 99 }} ··· 133 108 <DialogHeader> 134 109 <DialogTitle>Edit playlist</DialogTitle> 135 110 </DialogHeader> 136 - {state?.error && ( 111 + {state && !state.success && ( 137 112 <p className="text-sm text-destructive">{state.error}</p> 138 113 )} 139 114 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> ··· 176 151 const file = event.target.files?.[0]; 177 152 if (file) { 178 153 setValue("coverArt", file, { shouldValidate: true }); 179 - setPreviewUrl((prev) => { 180 - if (prev && prev !== coverArt) URL.revokeObjectURL(prev); 181 - return URL.createObjectURL(file); 182 - }); 154 + onFileChange(file); 183 155 } 184 156 }} 185 157 />
+17 -33
src/components/song/actions.ts
··· 1 1 "use server"; 2 2 3 - import { getSession } from "@/lib/auth/session"; 4 - import { redirect } from "next/navigation"; 5 3 import { Agent } from "@atproto/api"; 6 4 import { updateTag } from "next/cache"; 7 5 import { editSongSchema } from "./edit-schema"; 8 6 import type { TrackRecord } from "@/types/song"; 7 + import { COLLECTIONS } from "@/lib/atproto"; 8 + import { type ActionResult, ok, fail } from "@/lib/action-result"; 9 + import { requireSession } from "@/lib/repo"; 9 10 10 11 export async function editSong( 11 - _prevState: { success?: boolean; error?: string } | null, 12 + _prevState: ActionResult | null, 12 13 formData: FormData, 13 - ) { 14 + ): Promise<ActionResult> { 14 15 const rkey = formData.get("rkey") as string; 15 16 const coverArtFile = formData.get("coverArt") as File | null; 16 17 const raw = { ··· 22 23 23 24 const parsed = editSongSchema.safeParse(raw); 24 25 if (!parsed.success) { 25 - return { error: parsed.error.issues[0]?.message ?? "Invalid input." }; 26 + return fail(new Error(parsed.error.issues[0]?.message ?? "Invalid input.")); 26 27 } 27 28 28 - const session = await getSession(); 29 - if (!session) { 30 - redirect("/auth/login"); 31 - } 32 - 29 + const session = await requireSession(); 33 30 const agent = new Agent(session); 34 31 const did = agent.assertDid; 35 32 36 33 try { 37 34 const { data: existing } = await agent.com.atproto.repo.getRecord({ 38 35 repo: did, 39 - collection: "app.musicsky.temp.song", 36 + collection: COLLECTIONS.song, 40 37 rkey, 41 38 }); 42 39 ··· 53 50 54 51 await agent.com.atproto.repo.putRecord({ 55 52 repo: did, 56 - collection: "app.musicsky.temp.song", 53 + collection: COLLECTIONS.song, 57 54 rkey, 58 55 record: { 59 56 ...existingValue, ··· 66 63 67 64 updateTag(`song-${did}-${rkey}`); 68 65 updateTag(`songs-${did}`); 69 - return { success: true }; 66 + return ok(); 70 67 } catch (error) { 71 68 console.error("Failed to edit song:", error); 72 - return { 73 - error: 74 - error instanceof Error 75 - ? error.message 76 - : "Something went wrong. Try again.", 77 - }; 69 + return fail(error); 78 70 } 79 71 } 80 72 81 73 export async function deleteSong( 82 - _prevState: { success?: boolean; error?: string } | null, 74 + _prevState: ActionResult | null, 83 75 formData: FormData, 84 - ) { 76 + ): Promise<ActionResult> { 85 77 const rkey = formData.get("rkey") as string; 86 - const session = await getSession(); 87 - if (!session) { 88 - redirect("/auth/login"); 89 - } 78 + const session = await requireSession(); 90 79 const agent = new Agent(session); 91 80 try { 92 81 await agent.com.atproto.repo.deleteRecord({ 93 82 repo: agent.assertDid, 94 - collection: "app.musicsky.temp.song", 83 + collection: COLLECTIONS.song, 95 84 rkey, 96 85 }); 97 86 updateTag(`songs-${agent.assertDid}`); 98 - return { success: true }; 87 + return ok(); 99 88 } catch (error) { 100 89 console.error("Failed to delete song:", error); 101 - return { 102 - error: 103 - error instanceof Error 104 - ? error.message 105 - : "Something went wrong. Try again.", 106 - }; 90 + return fail(error); 107 91 } 108 92 }
+10 -69
src/components/song/delete-dialog.tsx
··· 1 1 "use client"; 2 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 3 import { deleteSong } from "@/components/song/actions"; 17 - import { useActionState, useState } from "react"; 4 + import { ConfirmDeleteDialog } from "../ui/confirm-delete-dialog"; 5 + import type { ActionResult } from "@/lib/action-result"; 18 6 19 7 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 8 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> 9 + <ConfirmDeleteDialog 10 + title="Delete song" 11 + description="Are you sure you want to delete this song?" 12 + action={async (prevState: ActionResult | null, formData: FormData) => { 13 + formData.set("rkey", rkey); 14 + return deleteSong(prevState, formData); 15 + }} 16 + /> 76 17 ); 77 18 }
+10 -38
src/components/song/edit-dialog.tsx
··· 17 17 import { Loader2Icon, PencilIcon } from "lucide-react"; 18 18 import Image from "next/image"; 19 19 import { editSong } from "@/components/song/actions"; 20 - import { 21 - startTransition, 22 - useActionState, 23 - useEffect, 24 - useRef, 25 - useState, 26 - } from "react"; 20 + import type { ActionResult } from "@/lib/action-result"; 21 + import { startTransition, useActionState, useEffect, useState } from "react"; 22 + import { useCoverArtPreview } from "@/hooks/use-cover-art-preview"; 27 23 import { useForm } from "react-hook-form"; 28 24 import { zodResolver } from "@hookform/resolvers/zod"; 29 25 import { editSongSchema, type EditSongFormData } from "./edit-schema"; ··· 58 54 }, 59 55 }); 60 56 61 - const fileInputRef = useRef<HTMLInputElement>(null); 62 - const [previewUrl, setPreviewUrl] = useState(coverArt); 63 - 64 - const previewUrlRef = useRef(previewUrl); 65 - 66 - useEffect(() => { 67 - previewUrlRef.current = previewUrl; 68 - }, [previewUrl]); 69 - 70 - useEffect( 71 - () => () => { 72 - if (previewUrlRef.current !== coverArt) { 73 - URL.revokeObjectURL(previewUrlRef.current); 74 - } 75 - }, 76 - [coverArt], 77 - ); 57 + const { fileInputRef, previewUrl, onFileChange, resetPreview } = 58 + useCoverArtPreview(coverArt); 78 59 79 60 useEffect(() => { 80 61 if (open) { ··· 87 68 }, [open, reset, title, description, genre]); 88 69 89 70 const [state, action, pending] = useActionState( 90 - async ( 91 - prevState: { success?: boolean; error?: string } | null, 92 - formData: FormData, 93 - ) => { 71 + async (prevState: ActionResult | null, formData: FormData) => { 94 72 const result = await editSong(prevState, formData); 95 73 if (result.success) { 96 74 setOpen(false); ··· 120 98 onOpenChange={(value) => { 121 99 setOpen(value); 122 100 if (!value) { 123 - setPreviewUrl((prev) => { 124 - if (prev !== coverArt) URL.revokeObjectURL(prev); 125 - return coverArt; 126 - }); 101 + resetPreview(); 127 102 setValue("coverArt", undefined); 128 103 } 129 104 }} ··· 138 113 <DialogHeader> 139 114 <DialogTitle>Edit song</DialogTitle> 140 115 </DialogHeader> 141 - {state?.error && ( 116 + {state && !state.success && ( 142 117 <p className="text-sm text-destructive">{state.error}</p> 143 118 )} 144 119 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> ··· 152 127 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" 153 128 > 154 129 <Image 155 - src={previewUrl} 130 + src={previewUrl ?? coverArt} 156 131 alt="Current cover art" 157 132 className="size-full object-cover" 158 133 width={500} ··· 173 148 const file = event.target.files?.[0]; 174 149 if (file) { 175 150 setValue("coverArt", file, { shouldValidate: true }); 176 - setPreviewUrl((prev) => { 177 - if (prev !== coverArt) URL.revokeObjectURL(prev); 178 - return URL.createObjectURL(file); 179 - }); 151 + onFileChange(file); 180 152 } 181 153 }} 182 154 />
+9 -8
src/components/song/interaction-actions.ts
··· 3 3 import { Agent } from "@atproto/api"; 4 4 import { getSession } from "@/lib/auth/session"; 5 5 import { revalidatePath, updateTag } from "next/cache"; 6 + import { getRkeyFromUri, COLLECTIONS } from "@/lib/atproto"; 6 7 7 8 export async function likeAction(uri: string, cid: string, handle: string) { 8 9 const session = await getSession(); ··· 11 12 const agent = new Agent(session); 12 13 const result = await agent.com.atproto.repo.createRecord({ 13 14 repo: agent.assertDid, 14 - collection: "app.musicsky.temp.like", 15 + collection: COLLECTIONS.like, 15 16 record: { 16 - $type: "app.musicsky.temp.like", 17 + $type: COLLECTIONS.like, 17 18 subject: { uri, cid }, 18 19 createdAt: new Date().toISOString(), 19 20 }, 20 21 }); 21 22 revalidatePath(`/${handle}`); 22 23 updateTag(`likes-${agent.assertDid}`); 23 - return result.data.uri.split("/").at(-1); 24 + return getRkeyFromUri(result.data.uri); 24 25 } 25 26 26 27 export async function unlikeAction(rkey: string, handle: string) { ··· 30 31 const agent = new Agent(session); 31 32 await agent.com.atproto.repo.deleteRecord({ 32 33 repo: agent.assertDid, 33 - collection: "app.musicsky.temp.like", 34 + collection: COLLECTIONS.like, 34 35 rkey, 35 36 }); 36 37 revalidatePath(`/${handle}`); ··· 44 45 const agent = new Agent(session); 45 46 const result = await agent.com.atproto.repo.createRecord({ 46 47 repo: agent.assertDid, 47 - collection: "app.musicsky.temp.repost", 48 + collection: COLLECTIONS.repost, 48 49 record: { 49 - $type: "app.musicsky.temp.repost", 50 + $type: COLLECTIONS.repost, 50 51 subject: { uri, cid }, 51 52 createdAt: new Date().toISOString(), 52 53 }, 53 54 }); 54 55 revalidatePath(`/${handle}`); 55 - return result.data.uri.split("/").at(-1); 56 + return getRkeyFromUri(result.data.uri); 56 57 } 57 58 58 59 export async function unrepostAction(rkey: string, handle: string) { ··· 62 63 const agent = new Agent(session); 63 64 await agent.com.atproto.repo.deleteRecord({ 64 65 repo: agent.assertDid, 65 - collection: "app.musicsky.temp.repost", 66 + collection: COLLECTIONS.repost, 66 67 rkey, 67 68 }); 68 69 revalidatePath(`/${handle}`);
+3 -5
src/components/song/song-menu.tsx
··· 12 12 import { EditDialog } from "./edit-dialog"; 13 13 import { AddToPlaylistDialog } from "@/components/playlist/add-to-playlist-dialog"; 14 14 import { removeTrackFromPlaylist } from "@/components/playlist/actions"; 15 + import type { ActionResult } from "@/lib/action-result"; 15 16 import { toast } from "sonner"; 16 17 import { 17 18 Tooltip, ··· 45 46 isLastTrack?: boolean; 46 47 }) { 47 48 const [, removeAction, _removePending] = useActionState( 48 - async ( 49 - prevState: { success?: boolean; error?: string } | null, 50 - formData: FormData, 51 - ) => { 49 + async (prevState: ActionResult | null, formData: FormData) => { 52 50 const result = await removeTrackFromPlaylist(prevState, formData); 53 - if (result?.error) { 51 + if (!result.success) { 54 52 toast.error(result.error); 55 53 } else { 56 54 toast.success("Removed from playlist");
+4 -47
src/components/song/song.tsx
··· 2 2 3 3 import Image from "next/image"; 4 4 import { RepeatIcon, HeartIcon, PlayIcon, PauseIcon } from "lucide-react"; 5 - import { useOptimistic, useTransition, useEffect } from "react"; 6 5 import { usePlayerStore } from "@/stores/player-store"; 7 - import { 8 - likeAction, 9 - unlikeAction, 10 - repostAction, 11 - unrepostAction, 12 - } from "./interaction-actions"; 6 + import { useInteraction } from "@/hooks/use-interaction"; 13 7 import { cn } from "@/lib/utils"; 14 8 import { PUBLIC_URL } from "@/lib/api"; 15 9 import { SharePopover } from "./share-popover"; ··· 41 35 isLastTrack, 42 36 }: SongProps) { 43 37 const shareUrl = `${PUBLIC_URL}/${author}/${rkey}`; 44 - const [, startTransition] = useTransition(); 45 38 const currentSong = usePlayerStore((song) => song.currentSong); 46 39 const isPlaying = usePlayerStore((song) => song.isPlaying); 47 40 const isCurrentSong = currentSong?.rkey === rkey; 41 + 42 + const { optimisticLiked, optimisticReposted, handleLike, handleRepost } = 43 + useInteraction({ uri, cid, author, likeRkey, repostRkey }); 48 44 49 45 const createdAtDate = new Date(createdAt); 50 46 ··· 67 63 repostRkey, 68 64 }); 69 65 } 70 - } 71 - 72 - useEffect(() => { 73 - if (isCurrentSong) { 74 - usePlayerStore.getState().setLikeRkey(likeRkey); 75 - usePlayerStore.getState().setRepostRkey(repostRkey); 76 - } 77 - }, [isCurrentSong, likeRkey, repostRkey]); 78 - const [optimisticLiked, setOptimisticLiked] = useOptimistic( 79 - likeRkey !== null, 80 - ); 81 - const [optimisticReposted, setOptimisticReposted] = useOptimistic( 82 - repostRkey !== null, 83 - ); 84 - 85 - function handleLike() { 86 - startTransition(async () => { 87 - if (optimisticLiked) { 88 - setOptimisticLiked(false); 89 - if (likeRkey) await unlikeAction(likeRkey, author); 90 - } else { 91 - if (!cid) return; 92 - setOptimisticLiked(true); 93 - await likeAction(uri, cid, author); 94 - } 95 - }); 96 - } 97 - 98 - function handleRepost() { 99 - startTransition(async () => { 100 - if (optimisticReposted) { 101 - setOptimisticReposted(false); 102 - if (repostRkey) await unrepostAction(repostRkey, author); 103 - } else { 104 - if (!cid) return; 105 - setOptimisticReposted(true); 106 - await repostAction(uri, cid, author); 107 - } 108 - }); 109 66 } 110 67 111 68 return (
+85
src/components/ui/confirm-delete-dialog.tsx
··· 1 + "use client"; 2 + 3 + import { useActionState, useState } from "react"; 4 + import { Button } from "./button"; 5 + import { 6 + Dialog, 7 + DialogClose, 8 + DialogContent, 9 + DialogDescription, 10 + DialogFooter, 11 + DialogHeader, 12 + DialogTitle, 13 + DialogTrigger, 14 + } from "./dialog"; 15 + import { DropdownMenuItem } from "./dropdown-menu"; 16 + import { Loader2Icon, TrashIcon } from "lucide-react"; 17 + import type { ActionResult } from "@/lib/action-result"; 18 + 19 + export function ConfirmDeleteDialog({ 20 + title, 21 + description, 22 + action, 23 + onSuccess, 24 + }: { 25 + title: string; 26 + description: string; 27 + action: ( 28 + prevState: ActionResult | null, 29 + formData: FormData, 30 + ) => Promise<ActionResult>; 31 + onSuccess?: () => void; 32 + }) { 33 + const [open, setOpen] = useState(false); 34 + const [state, formAction, pending] = useActionState( 35 + async (prevState: ActionResult | null, formData: FormData) => { 36 + const result = await action(prevState, formData); 37 + if (result.success) { 38 + setOpen(false); 39 + onSuccess?.(); 40 + } 41 + return result; 42 + }, 43 + null, 44 + ); 45 + 46 + return ( 47 + <Dialog open={open} onOpenChange={setOpen}> 48 + <DialogTrigger asChild> 49 + <DropdownMenuItem 50 + onSelect={(event) => event.preventDefault()} 51 + variant="destructive" 52 + > 53 + <TrashIcon /> 54 + Delete 55 + </DropdownMenuItem> 56 + </DialogTrigger> 57 + <DialogContent> 58 + <DialogHeader> 59 + <DialogTitle>{title}</DialogTitle> 60 + <DialogDescription>{description}</DialogDescription> 61 + </DialogHeader> 62 + {state && !state.success && ( 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={formAction}> 70 + <Button type="submit" variant="destructive" disabled={pending}> 71 + {pending ? ( 72 + <> 73 + <Loader2Icon className="animate-spin" /> 74 + Deleting... 75 + </> 76 + ) : ( 77 + "Delete" 78 + )} 79 + </Button> 80 + </form> 81 + </DialogFooter> 82 + </DialogContent> 83 + </Dialog> 84 + ); 85 + }
+38
src/hooks/use-cover-art-preview.ts
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState } from "react"; 4 + 5 + export function useCoverArtPreview(initialUrl: string | null) { 6 + const fileInputRef = useRef<HTMLInputElement>(null); 7 + const [previewUrl, setPreviewUrl] = useState(initialUrl); 8 + const previewUrlRef = useRef(previewUrl); 9 + 10 + useEffect(() => { 11 + previewUrlRef.current = previewUrl; 12 + }, [previewUrl]); 13 + 14 + useEffect( 15 + () => () => { 16 + if (previewUrlRef.current && previewUrlRef.current !== initialUrl) { 17 + URL.revokeObjectURL(previewUrlRef.current); 18 + } 19 + }, 20 + [initialUrl], 21 + ); 22 + 23 + function onFileChange(file: File) { 24 + setPreviewUrl((prev) => { 25 + if (prev && prev !== initialUrl) URL.revokeObjectURL(prev); 26 + return URL.createObjectURL(file); 27 + }); 28 + } 29 + 30 + function resetPreview() { 31 + setPreviewUrl((prev) => { 32 + if (prev && prev !== initialUrl) URL.revokeObjectURL(prev); 33 + return initialUrl; 34 + }); 35 + } 36 + 37 + return { fileInputRef, previewUrl, onFileChange, resetPreview }; 38 + }
+75
src/hooks/use-interaction.ts
··· 1 + "use client"; 2 + 3 + import { useOptimistic, useTransition } from "react"; 4 + import { usePlayerStore } from "@/stores/player-store"; 5 + import { getRkeyFromUri } from "@/lib/atproto"; 6 + import { 7 + likeAction, 8 + unlikeAction, 9 + repostAction, 10 + unrepostAction, 11 + } from "@/components/song/interaction-actions"; 12 + 13 + interface UseInteractionOptions { 14 + uri: string; 15 + cid?: string; 16 + author: string; 17 + likeRkey: string | null; 18 + repostRkey: string | null; 19 + } 20 + 21 + export function useInteraction({ 22 + uri, 23 + cid, 24 + author, 25 + likeRkey, 26 + repostRkey, 27 + }: UseInteractionOptions) { 28 + const [, startTransition] = useTransition(); 29 + const [optimisticLiked, setOptimisticLiked] = useOptimistic( 30 + likeRkey !== null, 31 + ); 32 + const [optimisticReposted, setOptimisticReposted] = useOptimistic( 33 + repostRkey !== null, 34 + ); 35 + const currentSong = usePlayerStore((store) => store.currentSong); 36 + const isCurrentSong = currentSong?.rkey === getRkeyFromUri(uri); 37 + 38 + function handleLike() { 39 + startTransition(async () => { 40 + if (optimisticLiked) { 41 + setOptimisticLiked(false); 42 + if (likeRkey) { 43 + await unlikeAction(likeRkey, author); 44 + if (isCurrentSong) usePlayerStore.getState().setLikeRkey(null); 45 + } 46 + } else { 47 + if (!cid) return; 48 + setOptimisticLiked(true); 49 + const newRkey = await likeAction(uri, cid, author); 50 + if (isCurrentSong) 51 + usePlayerStore.getState().setLikeRkey(newRkey ?? null); 52 + } 53 + }); 54 + } 55 + 56 + function handleRepost() { 57 + startTransition(async () => { 58 + if (optimisticReposted) { 59 + setOptimisticReposted(false); 60 + if (repostRkey) { 61 + await unrepostAction(repostRkey, author); 62 + if (isCurrentSong) usePlayerStore.getState().setRepostRkey(null); 63 + } 64 + } else { 65 + if (!cid) return; 66 + setOptimisticReposted(true); 67 + const newRkey = await repostAction(uri, cid, author); 68 + if (isCurrentSong) 69 + usePlayerStore.getState().setRepostRkey(newRkey ?? null); 70 + } 71 + }); 72 + } 73 + 74 + return { optimisticLiked, optimisticReposted, handleLike, handleRepost }; 75 + }
+19
src/lib/action-result.ts
··· 1 + export type ActionResult<T = void> = 2 + | { success: true; data: T } 3 + | { success: false; error: string }; 4 + 5 + export function ok(): ActionResult<void>; 6 + export function ok<T>(data: T): ActionResult<T>; 7 + export function ok<T>(data?: T): ActionResult<T> { 8 + return { success: true as const, data: data as T }; 9 + } 10 + 11 + export function fail(error: unknown): ActionResult<never> { 12 + return { 13 + success: false as const, 14 + error: 15 + error instanceof Error 16 + ? error.message 17 + : "Something went wrong. Try again.", 18 + }; 19 + }
+38
src/lib/atproto.ts
··· 1 + export function parseAtUri(uri: string): { 2 + repo: string; 3 + collection: string; 4 + rkey: string; 5 + } { 6 + const parts = uri.split("/"); 7 + return { repo: parts[2]!, collection: parts[3]!, rkey: parts[4]! }; 8 + } 9 + 10 + export function getDidFromUri(uri: string): string { 11 + return uri.split("/")[2]!; 12 + } 13 + 14 + export function getRkeyFromUri(uri: string): string { 15 + return uri.split("/")[4]!; 16 + } 17 + 18 + export function buildBlobUrl( 19 + pds: string, 20 + did: string, 21 + ref: { toString(): string }, 22 + ): string { 23 + return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${ref.toString()}`; 24 + } 25 + 26 + export function isResourceOwner( 27 + sessionDid: string | undefined, 28 + resourceUri: string, 29 + ): boolean { 30 + return sessionDid === getDidFromUri(resourceUri); 31 + } 32 + 33 + export const COLLECTIONS = { 34 + song: "app.musicsky.temp.song", 35 + playlist: "app.musicsky.temp.playlist", 36 + like: "app.musicsky.temp.like", 37 + repost: "app.musicsky.temp.repost", 38 + } as const;
+19 -82
src/lib/playlists.ts
··· 1 1 import { TID } from "@atproto/common-web"; 2 - import { Agent } from "@atproto/api"; 3 2 import type { OAuthSession } from "@atproto/oauth-client-node"; 4 3 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"; 4 + import type { SongProps } from "@/types/song"; 5 + import { getUserInteractions } from "@/lib/songs"; 6 + import { getRkeyFromUri, buildBlobUrl } from "@/lib/atproto"; 13 7 14 8 export function mapRecordToPlaylist( 15 9 uri: string, ··· 18 12 did: string, 19 13 handle: string, 20 14 ): Omit<PlaylistProps, "isOwner"> { 15 + const rkey = getRkeyFromUri(uri); 21 16 return { 22 17 uri, 23 - rkey: getRkey(uri), 18 + rkey, 24 19 name: value.name, 25 20 description: value.description ?? null, 26 21 coverArt: value.coverArt 27 - ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt.ref.toString()}` 22 + ? buildBlobUrl(pds, did, value.coverArt.ref) 28 23 : null, 29 24 trackCount: value.tracks.length, 30 25 author: handle, 31 - createdAt: new Date( 32 - TID.fromStr(getRkey(uri)).timestamp() / 1000, 33 - ).toISOString(), 26 + createdAt: new Date(TID.fromStr(rkey).timestamp() / 1000).toISOString(), 34 27 }; 35 28 } 36 29 ··· 38 31 tracks: { uri: string; cid: string }[], 39 32 session: OAuthSession | null, 40 33 ): 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 - } 34 + const uris = tracks.map((track) => track.uri); 35 + const { resolveRecordsByAuthor } = await import("@/lib/repo"); 54 36 55 - const results: (SongProps | null)[] = new Array(tracks.length).fill(null); 56 - 57 - const [{ likedUris, repostedUris }] = await Promise.all([ 37 + const [resolved, { likedUris, repostedUris }] = await Promise.all([ 38 + resolveRecordsByAuthor(uris, session), 58 39 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 40 ]); 111 41 112 - return results; 42 + return resolved.map((song) => { 43 + if (!song) return null; 44 + return { 45 + ...song, 46 + likeRkey: likedUris.get(song.uri) ?? null, 47 + repostRkey: repostedUris.get(song.uri) ?? null, 48 + }; 49 + }); 113 50 }
+101
src/lib/repo.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { redirect } from "next/navigation"; 3 + import { getSession } from "@/lib/auth/session"; 4 + import { COLLECTIONS, getDidFromUri, getRkeyFromUri } from "@/lib/atproto"; 5 + import { getHandleFromDid, getPds, mapRecordToSong } from "@/lib/songs"; 6 + import type { SongProps, TrackRecord } from "@/types/song"; 7 + 8 + export async function requireSession() { 9 + const session = await getSession(); 10 + if (!session) redirect("/auth/login"); 11 + return session; 12 + } 13 + 14 + export async function getRecord<T>( 15 + agent: Agent, 16 + collection: string, 17 + did: string, 18 + rkey: string, 19 + ): Promise<{ value: T; cid: string; uri: string }> { 20 + const { data } = await agent.com.atproto.repo.getRecord({ 21 + repo: did, 22 + collection, 23 + rkey, 24 + }); 25 + return { value: data.value as unknown as T, cid: data.cid!, uri: data.uri }; 26 + } 27 + 28 + interface ResolveByAuthorItem { 29 + uri: string; 30 + index: number; 31 + extra?: Record<string, unknown>; 32 + } 33 + 34 + export async function resolveRecordsByAuthor( 35 + uris: string[], 36 + session: { did: string } | null, 37 + ): Promise<(SongProps | null)[]> { 38 + const tracksByAuthor = new Map<string, ResolveByAuthorItem[]>(); 39 + for (let i = 0; i < uris.length; i++) { 40 + const uri = uris[i]!; 41 + const authorDid = getDidFromUri(uri); 42 + const existing = tracksByAuthor.get(authorDid) ?? []; 43 + existing.push({ uri, index: i }); 44 + tracksByAuthor.set(authorDid, existing); 45 + } 46 + 47 + const results: (SongProps | null)[] = new Array(uris.length).fill(null); 48 + 49 + await Promise.all( 50 + [...tracksByAuthor.entries()].map(async ([authorDid, authorTracks]) => { 51 + try { 52 + const authorPds = await getPds(authorDid); 53 + if (!authorPds) return; 54 + 55 + const songAgent = new Agent(authorPds); 56 + const [authorHandle, ...songResponses] = await Promise.all([ 57 + getHandleFromDid(authorDid, authorPds), 58 + ...authorTracks.map(({ uri }) => { 59 + const rkey = getRkeyFromUri(uri); 60 + return songAgent.com.atproto.repo 61 + .getRecord({ 62 + repo: authorDid, 63 + collection: COLLECTIONS.song, 64 + rkey, 65 + }) 66 + .then(({ data }) => ({ data, success: true as const })) 67 + .catch((error) => { 68 + console.error("Failed to fetch track", uri, error); 69 + return { data: null, success: false as const }; 70 + }); 71 + }), 72 + ]); 73 + 74 + for (let i = 0; i < authorTracks.length; i++) { 75 + const result = songResponses[i]!; 76 + if (!result.success || !result.data) continue; 77 + const value = result.data.value as unknown as TrackRecord; 78 + const songUri = result.data.uri; 79 + results[authorTracks[i]!.index] = { 80 + ...mapRecordToSong( 81 + songUri, 82 + value, 83 + authorPds, 84 + authorDid, 85 + authorHandle, 86 + result.data.cid, 87 + ), 88 + isOwner: session?.did === authorDid, 89 + loggedIn: session !== null, 90 + likeRkey: null, 91 + repostRkey: null, 92 + }; 93 + } 94 + } catch (error) { 95 + console.error("Failed to resolve tracks for author", authorDid, error); 96 + } 97 + }), 98 + ); 99 + 100 + return results; 101 + }
+12 -13
src/lib/songs.ts
··· 3 3 import { IdResolver } from "@atproto/identity"; 4 4 import type { OAuthSession } from "@atproto/oauth-client-node"; 5 5 import type { SongProps, TrackRecord } from "@/types/song"; 6 + import { getRkeyFromUri, buildBlobUrl, COLLECTIONS } from "@/lib/atproto"; 6 7 7 - export function getRkey(uri: string) { 8 - return uri.split("/")[4]!; 9 - } 8 + /** @deprecated Use `getRkeyFromUri` from `@/lib/atproto` instead */ 9 + export const getRkey = getRkeyFromUri; 10 10 11 11 export function mapRecordToSong( 12 12 uri: string, ··· 16 16 handle: string, 17 17 cid?: string, 18 18 ): Omit<SongProps, "isOwner" | "loggedIn" | "likeRkey" | "repostRkey"> { 19 + const rkey = getRkeyFromUri(uri); 19 20 return { 20 21 uri, 21 22 cid: cid, 22 - rkey: getRkey(uri), 23 + rkey, 23 24 title: value.title, 24 - coverArt: `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt.ref.toString()}`, 25 - audio: `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.audio.ref.toString()}`, 25 + coverArt: buildBlobUrl(pds, did, value.coverArt.ref), 26 + audio: buildBlobUrl(pds, did, value.audio.ref), 26 27 genre: value.genre ?? null, 27 28 duration: value.duration, 28 29 description: value.description ?? null, 29 30 author: handle, 30 - createdAt: new Date( 31 - TID.fromStr(getRkey(uri)).timestamp() / 1000, 32 - ).toISOString(), 31 + createdAt: new Date(TID.fromStr(rkey).timestamp() / 1000).toISOString(), 33 32 }; 34 33 } 35 34 ··· 84 83 const [likesRes, repostsRes] = await Promise.all([ 85 84 agent.com.atproto.repo.listRecords({ 86 85 repo: session.did, 87 - collection: "app.musicsky.temp.like", 86 + collection: COLLECTIONS.like, 88 87 limit: 100, 89 88 }), 90 89 agent.com.atproto.repo.listRecords({ 91 90 repo: session.did, 92 - collection: "app.musicsky.temp.repost", 91 + collection: COLLECTIONS.repost, 93 92 limit: 100, 94 93 }), 95 94 ]); 96 95 for (const record of likesRes.data.records) { 97 96 const subjectUri = (record.value as { subject: { uri: string } }).subject 98 97 .uri; 99 - likedUris.set(subjectUri, getRkey(record.uri)); 98 + likedUris.set(subjectUri, getRkeyFromUri(record.uri)); 100 99 } 101 100 for (const record of repostsRes.data.records) { 102 101 const subjectUri = (record.value as { subject: { uri: string } }).subject 103 102 .uri; 104 - repostedUris.set(subjectUri, getRkey(record.uri)); 103 + repostedUris.set(subjectUri, getRkeyFromUri(record.uri)); 105 104 } 106 105 return { likedUris, repostedUris }; 107 106 }