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: create song upload page

+304 -1
+5
next.config.ts
··· 3 3 const nextConfig: NextConfig = { 4 4 /* config options here */ 5 5 reactCompiler: true, 6 + experimental: { 7 + serverActions: { 8 + bodySizeLimit: "55mb", 9 + }, 10 + }, 6 11 images: { 7 12 remotePatterns: [ 8 13 {
+69
src/app/upload/actions.ts
··· 1 + "use server"; 2 + 3 + import { Agent } from "@atproto/api"; 4 + import { TID } from "@atproto/common-web"; 5 + import { getSession } from "@/lib/auth/session"; 6 + import { redirect } from "next/navigation"; 7 + 8 + export async function uploadSong(formData: FormData) { 9 + const session = await getSession(); 10 + if (!session) { 11 + redirect("/auth/login"); 12 + } 13 + 14 + const agent = new Agent(session); 15 + 16 + const title = formData.get("title") as string; 17 + const description = (formData.get("description") as string) || undefined; 18 + const genre = (formData.get("genre") as string) || undefined; 19 + const audio = formData.get("audio") as File; 20 + const coverArt = formData.get("coverArt") as File | null; 21 + const duration = Number(formData.get("duration")); 22 + 23 + if (!audio || !title || !duration) { 24 + return { error: "Missing required fields." }; 25 + } 26 + 27 + try { 28 + // Upload audio blob 29 + const { data: audioUpload } = await agent.uploadBlob(audio, { 30 + encoding: audio.type, 31 + }); 32 + 33 + // Upload cover art blob (if provided) 34 + const coverArtBlob = 35 + coverArt && coverArt.size > 0 36 + ? (await agent.uploadBlob(coverArt, { encoding: coverArt.type })).data 37 + .blob 38 + : undefined; 39 + 40 + // Create the track record 41 + const rkey = TID.nextStr(); 42 + 43 + await agent.com.atproto.repo.putRecord({ 44 + repo: agent.assertDid, 45 + collection: "app.musicsky.temp.track", 46 + rkey, 47 + record: { 48 + $type: "app.musicsky.temp.track", 49 + title, 50 + description, 51 + genre, 52 + audio: audioUpload.blob, 53 + coverArt: coverArtBlob, 54 + duration, 55 + createdAt: new Date().toISOString(), 56 + }, 57 + }); 58 + } catch (error) { 59 + console.error("Failed to upload song:", error); 60 + return { 61 + error: 62 + error instanceof Error 63 + ? error.message 64 + : "Something went wrong. Try again.", 65 + }; 66 + } 67 + 68 + redirect("/"); 69 + }
+17
src/app/upload/page.tsx
··· 1 + import { getSession } from "@/lib/auth/session"; 2 + import { redirect } from "next/navigation"; 3 + import SongUploadForm from "./song-upload-form"; 4 + 5 + export default async function UploadSongPage() { 6 + const session = await getSession(); 7 + 8 + if (!session) { 9 + redirect("/auth/login"); 10 + } 11 + return ( 12 + <main className="flex flex-col gap-6 w-full max-w-md mx-auto p-8"> 13 + <h1 className="text-2xl font-bold">Upload a song</h1> 14 + <SongUploadForm /> 15 + </main> 16 + ); 17 + }
+47
src/app/upload/song-schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + const AUDIO_MIME_TYPES = [ 4 + "audio/mpeg", 5 + "audio/ogg", 6 + "audio/wav", 7 + "audio/flac", 8 + "audio/aac", 9 + "audio/webm", 10 + ] as const; 11 + 12 + const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const; 13 + 14 + const MAX_AUDIO_SIZE = 52_428_800; // 50 MB 15 + const MAX_COVER_ART_SIZE = 1_000_000; // 1 MB 16 + 17 + export const songSchema = z.object({ 18 + title: z 19 + .string() 20 + .min(1, { error: "Title is required." }) 21 + .max(128, { error: "Title must be 128 characters or fewer." }), 22 + description: z 23 + .string() 24 + .max(5000, { error: "Description must be 5000 characters or fewer." }) 25 + .optional(), 26 + genre: z 27 + .string() 28 + .max(128, { error: "Genre must be 128 characters or fewer." }) 29 + .optional(), 30 + audio: z 31 + .file({ error: "An audio file is required." }) 32 + .mime([...AUDIO_MIME_TYPES], { error: "Must be a supported audio format." }) 33 + .max(MAX_AUDIO_SIZE, { error: "Audio file must be 50 MB or smaller." }), 34 + coverArt: z 35 + .file() 36 + .mime([...IMAGE_MIME_TYPES], { 37 + error: "Cover art must be PNG, JPEG, or WebP.", 38 + }) 39 + .max(MAX_COVER_ART_SIZE, { error: "Cover art must be 1 MB or smaller." }) 40 + .optional(), 41 + duration: z 42 + .number() 43 + .int({ error: "Duration must be a whole number." }) 44 + .min(1, { error: "Duration must be at least 1 second." }), 45 + }); 46 + 47 + export type SongFormData = z.infer<typeof songSchema>;
+147
src/app/upload/song-upload-form.tsx
··· 1 + "use client"; 2 + 3 + import { useForm } from "react-hook-form"; 4 + import { zodResolver } from "@hookform/resolvers/zod"; 5 + import { songSchema, type SongFormData } from "./song-schema"; 6 + import { uploadSong } from "./actions"; 7 + import { Button } from "@/components/ui/button"; 8 + import { Loader2 } from "lucide-react"; 9 + import { Input } from "@/components/ui/input"; 10 + import { Textarea } from "@/components/ui/textarea"; 11 + import { 12 + Field, 13 + FieldLabel, 14 + FieldDescription, 15 + FieldError, 16 + } from "@/components/ui/field"; 17 + 18 + function getAudioDuration(file: File): Promise<number> { 19 + return new Promise((resolve, reject) => { 20 + const audio = new Audio(); 21 + audio.addEventListener("loadedmetadata", () => { 22 + const duration = Math.round(audio.duration); 23 + URL.revokeObjectURL(audio.src); 24 + resolve(duration); 25 + }); 26 + audio.addEventListener("error", () => { 27 + URL.revokeObjectURL(audio.src); 28 + reject(new Error("Could not read audio file.")); 29 + }); 30 + audio.src = URL.createObjectURL(file); 31 + }); 32 + } 33 + 34 + export default function SongUploadForm() { 35 + const { 36 + register, 37 + handleSubmit, 38 + setValue, 39 + setError, 40 + formState: { errors, isSubmitting }, 41 + } = useForm<SongFormData>({ 42 + resolver: zodResolver(songSchema), 43 + }); 44 + 45 + async function onAudioChange(event: React.ChangeEvent<HTMLInputElement>) { 46 + const file = event.target.files?.[0]; 47 + if (!file) return; 48 + 49 + setValue("audio", file, { shouldValidate: true }); 50 + 51 + try { 52 + const duration = await getAudioDuration(file); 53 + setValue("duration", duration, { shouldValidate: true }); 54 + } catch { 55 + setError("audio", { message: "Could not read audio duration." }); 56 + } 57 + } 58 + 59 + async function onSubmit(data: SongFormData) { 60 + const formData = new FormData(); 61 + formData.set("title", data.title); 62 + if (data.description) formData.set("description", data.description); 63 + if (data.genre) formData.set("genre", data.genre); 64 + formData.set("audio", data.audio); 65 + if (data.coverArt) formData.set("coverArt", data.coverArt); 66 + formData.set("duration", String(data.duration)); 67 + 68 + const result = await uploadSong(formData); 69 + if (result?.error) { 70 + setError("root", { message: result.error }); 71 + } 72 + } 73 + 74 + return ( 75 + <fieldset disabled={isSubmitting}> 76 + <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> 77 + {errors.root && <FieldError>{errors.root.message}</FieldError>} 78 + 79 + <Field data-invalid={!!errors.title}> 80 + <FieldLabel htmlFor="title">Title</FieldLabel> 81 + <Input 82 + id="title" 83 + placeholder="Enter the title of your song" 84 + {...register("title")} 85 + /> 86 + <FieldError>{errors.title?.message}</FieldError> 87 + </Field> 88 + 89 + <Field data-invalid={!!errors.description}> 90 + <FieldLabel htmlFor="description">Description</FieldLabel> 91 + <Textarea 92 + id="description" 93 + placeholder="Tell listeners about this song..." 94 + {...register("description")} 95 + /> 96 + <FieldError>{errors.description?.message}</FieldError> 97 + </Field> 98 + 99 + <Field data-invalid={!!errors.genre}> 100 + <FieldLabel htmlFor="genre">Genre</FieldLabel> 101 + <Input 102 + id="genre" 103 + placeholder="Electronic, Hip-Hop, Jazz..." 104 + {...register("genre")} 105 + /> 106 + <FieldError>{errors.genre?.message}</FieldError> 107 + </Field> 108 + 109 + <Field data-invalid={!!errors.audio}> 110 + <FieldLabel htmlFor="audio">Audio file</FieldLabel> 111 + <FieldDescription> 112 + MP3, OGG, WAV, FLAC, AAC, or WebM. Max 50 MB. 113 + </FieldDescription> 114 + <Input 115 + id="audio" 116 + type="file" 117 + accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/webm" 118 + onChange={onAudioChange} 119 + /> 120 + <FieldError>{errors.audio?.message}</FieldError> 121 + </Field> 122 + 123 + <Field data-invalid={!!errors.coverArt}> 124 + <FieldLabel htmlFor="coverArt">Cover art</FieldLabel> 125 + <FieldDescription> 126 + PNG, JPEG, or WebP. Max 1 MB. Optional. 127 + </FieldDescription> 128 + <Input 129 + id="coverArt" 130 + type="file" 131 + accept="image/png,image/jpeg,image/webp" 132 + onChange={(event) => { 133 + const file = event.target.files?.[0]; 134 + if (file) setValue("coverArt", file, { shouldValidate: true }); 135 + }} 136 + /> 137 + <FieldError>{errors.coverArt?.message}</FieldError> 138 + </Field> 139 + 140 + <Button type="submit" className="w-full" disabled={isSubmitting}> 141 + {isSubmitting && <Loader2 className="animate-spin" />} 142 + {isSubmitting ? "Uploading..." : "Upload song"} 143 + </Button> 144 + </form> 145 + </fieldset> 146 + ); 147 + }
+18
src/components/ui/textarea.tsx
··· 1 + import * as React from "react"; 2 + 3 + import { cn } from "@/lib/utils"; 4 + 5 + function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { 6 + return ( 7 + <textarea 8 + data-slot="textarea" 9 + className={cn( 10 + "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 11 + className, 12 + )} 13 + {...props} 14 + /> 15 + ); 16 + } 17 + 18 + export { Textarea };
+1 -1
src/lib/auth/client.ts
··· 8 8 } from "@atproto/oauth-client-node"; 9 9 10 10 export const SCOPE = 11 - "atproto rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview"; 11 + "atproto transition:generic rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview blob:audio/* blob:image/*"; 12 12 13 13 // Use globalThis to persist across Next.js hot reloads 14 14 const globalAuth = globalThis as unknown as {