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: make song upload a dialog

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

authored by

mejsiejdev and committed by tangled.org 6c1956e8 baff87c2

+275 -297
-7
lexicons/app/musicsky/temp/song.json
··· 12 12 "title", 13 13 "audio", 14 14 "coverArt", 15 - "slug", 16 15 "duration", 17 16 "createdAt" 18 17 ], ··· 73 72 "type": "integer", 74 73 "minimum": 1, 75 74 "description": "Duration of the track in seconds." 76 - }, 77 - "slug": { 78 - "type": "string", 79 - "maxLength": 128, 80 - "maxGraphemes": 128, 81 - "description": "URL-friendly slug for the track." 82 75 }, 83 76 "labels": { 84 77 "type": "union",
+1
next.config.ts
··· 5 5 cacheComponents: true, 6 6 reactCompiler: true, 7 7 serverExternalPackages: ["better-sqlite3"], 8 + allowedDevOrigins: ["127.0.0.1"], 8 9 experimental: { 9 10 serverActions: { 10 11 bodySizeLimit: "55mb",
-71
src/app/(main)/upload/actions.ts
··· 1 - "use server"; 2 - 3 - import { Agent } from "@atproto/api"; 4 - import { getSession } from "@/lib/auth/session"; 5 - import { redirect } from "next/navigation"; 6 - 7 - export async function uploadSong(formData: FormData) { 8 - const session = await getSession(); 9 - if (!session) { 10 - redirect("/auth/login"); 11 - } 12 - 13 - const agent = new Agent(session); 14 - 15 - const title = formData.get("title") as string; 16 - const slug = formData.get("slug") 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; 21 - const duration = Number(formData.get("duration")); 22 - 23 - if (!title || !slug) { 24 - return { error: "Missing required fields." }; 25 - } 26 - 27 - if (audio.size === 0) { 28 - return { error: "Audio file is required." }; 29 - } 30 - 31 - if (coverArt.size === 0) { 32 - return { error: "Cover art is required." }; 33 - } 34 - 35 - try { 36 - // Upload audio and cover art blobs in parallel 37 - const [{ data: audioUpload }, { data: coverArtUpload }] = await Promise.all( 38 - [ 39 - agent.uploadBlob(audio, { encoding: audio.type }), 40 - agent.uploadBlob(coverArt, { encoding: coverArt.type }), 41 - ], 42 - ); 43 - 44 - // Create the song record 45 - await agent.com.atproto.repo.createRecord({ 46 - repo: agent.assertDid, 47 - collection: "app.musicsky.temp.song", 48 - record: { 49 - $type: "app.musicsky.temp.song", 50 - title, 51 - slug, 52 - description, 53 - genre, 54 - audio: audioUpload.blob, 55 - coverArt: coverArtUpload.blob, 56 - duration, 57 - createdAt: new Date().toISOString(), 58 - }, 59 - }); 60 - } catch (error) { 61 - console.error("Failed to upload song:", error); 62 - return { 63 - error: 64 - error instanceof Error 65 - ? error.message 66 - : "Something went wrong. Try again.", 67 - }; 68 - } 69 - 70 - redirect("/"); 71 - }
-12
src/app/(main)/upload/layout.tsx
··· 1 - export default function UploadSongLayout({ 2 - children, 3 - }: { 4 - children: React.ReactNode; 5 - }) { 6 - return ( 7 - <div className="flex flex-col gap-6"> 8 - <p className="text-3xl font-bold text-primary">Upload a song</p> 9 - {children} 10 - </div> 11 - ); 12 - }
-189
src/app/(main)/upload/page.tsx
··· 1 - "use client"; 2 - 3 - import { useForm } from "react-hook-form"; 4 - import { useEffect, useRef } from "react"; 5 - import { zodResolver } from "@hookform/resolvers/zod"; 6 - import { songSchema, type SongFormData } from "./song-schema"; 7 - import { uploadSong } from "./actions"; 8 - import { Button } from "@/components/ui/button"; 9 - import { Loader2 } from "lucide-react"; 10 - import { Input } from "@/components/ui/input"; 11 - import { Textarea } from "@/components/ui/textarea"; 12 - import { 13 - Field, 14 - FieldLabel, 15 - FieldDescription, 16 - FieldError, 17 - } from "@/components/ui/field"; 18 - 19 - function toSlug(title: string): string { 20 - return title 21 - .toLowerCase() 22 - .trim() 23 - .replace(/[^a-z0-9\s-]/g, "") 24 - .replace(/\s+/g, "-") 25 - .replace(/-+/g, "-") 26 - .replace(/^-|-$/g, ""); 27 - } 28 - 29 - function getAudioDuration(file: File): Promise<number> { 30 - return new Promise((resolve, reject) => { 31 - const audio = new Audio(); 32 - audio.addEventListener("loadedmetadata", () => { 33 - const duration = Math.round(audio.duration); 34 - URL.revokeObjectURL(audio.src); 35 - resolve(duration); 36 - }); 37 - audio.addEventListener("error", () => { 38 - URL.revokeObjectURL(audio.src); 39 - reject(new Error("Could not read audio file.")); 40 - }); 41 - audio.src = URL.createObjectURL(file); 42 - }); 43 - } 44 - 45 - export default function SongUploadForm() { 46 - const { 47 - register, 48 - handleSubmit, 49 - setValue, 50 - watch, 51 - setError, 52 - formState: { errors, isSubmitting }, 53 - } = useForm<SongFormData>({ 54 - resolver: zodResolver(songSchema), 55 - }); 56 - 57 - const slugManuallyEdited = useRef(false); 58 - const title = watch("title"); 59 - 60 - useEffect(() => { 61 - if (!slugManuallyEdited.current) { 62 - setValue("slug", toSlug(title), { shouldValidate: false }); 63 - } 64 - }, [title, setValue]); 65 - 66 - async function onAudioChange(event: React.ChangeEvent<HTMLInputElement>) { 67 - const file = event.target.files?.[0]; 68 - if (!file) return; 69 - 70 - setValue("audio", file, { shouldValidate: true }); 71 - 72 - try { 73 - const duration = await getAudioDuration(file); 74 - setValue("duration", duration, { shouldValidate: true }); 75 - } catch { 76 - setError("audio", { message: "Could not read audio duration." }); 77 - } 78 - } 79 - 80 - async function onSubmit(data: SongFormData) { 81 - const formData = new FormData(); 82 - formData.set("title", data.title); 83 - formData.set("slug", data.slug); 84 - if (data.description) formData.set("description", data.description); 85 - if (data.genre) formData.set("genre", data.genre); 86 - formData.set("audio", data.audio); 87 - formData.set("coverArt", data.coverArt); 88 - formData.set("duration", String(data.duration)); 89 - 90 - const result = await uploadSong(formData); 91 - if (result.error) { 92 - setError("root", { message: result.error }); 93 - } 94 - } 95 - 96 - return ( 97 - <fieldset disabled={isSubmitting}> 98 - <form 99 - onSubmit={(event) => void handleSubmit(onSubmit)(event)} 100 - className="space-y-6" 101 - > 102 - {errors.root && <FieldError>{errors.root.message}</FieldError>} 103 - 104 - <Field data-invalid={!!errors.title}> 105 - <FieldLabel htmlFor="title">Title</FieldLabel> 106 - <Input 107 - id="title" 108 - placeholder="Enter the title of your song" 109 - {...register("title")} 110 - /> 111 - <FieldError>{errors.title?.message}</FieldError> 112 - </Field> 113 - 114 - <Field data-invalid={!!errors.slug}> 115 - <FieldLabel htmlFor="slug">Slug</FieldLabel> 116 - <FieldDescription> 117 - Used in the song&apos;s URL. Lowercase letters, numbers, and hyphens 118 - only. 119 - </FieldDescription> 120 - <Input 121 - id="slug" 122 - placeholder="my-song-title" 123 - {...register("slug", { 124 - onChange: () => { 125 - slugManuallyEdited.current = true; 126 - }, 127 - })} 128 - /> 129 - <FieldError>{errors.slug?.message}</FieldError> 130 - </Field> 131 - 132 - <Field data-invalid={!!errors.description}> 133 - <FieldLabel htmlFor="description">Description</FieldLabel> 134 - <Textarea 135 - id="description" 136 - placeholder="Tell listeners about this song..." 137 - {...register("description")} 138 - /> 139 - <FieldError>{errors.description?.message}</FieldError> 140 - </Field> 141 - 142 - <Field data-invalid={!!errors.genre}> 143 - <FieldLabel htmlFor="genre">Genre</FieldLabel> 144 - <Input 145 - id="genre" 146 - placeholder="Electronic, Hip-Hop, Jazz..." 147 - {...register("genre")} 148 - /> 149 - <FieldError>{errors.genre?.message}</FieldError> 150 - </Field> 151 - 152 - <Field data-invalid={!!errors.audio}> 153 - <FieldLabel htmlFor="audio">Audio file</FieldLabel> 154 - <FieldDescription> 155 - MP3, OGG, WAV, FLAC, AAC, or WebM. Max 50 MB. 156 - </FieldDescription> 157 - <Input 158 - id="audio" 159 - type="file" 160 - accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/webm" 161 - onChange={(event) => void onAudioChange(event)} 162 - /> 163 - <FieldError>{errors.audio?.message}</FieldError> 164 - </Field> 165 - 166 - <Field data-invalid={!!errors.coverArt}> 167 - <FieldLabel htmlFor="coverArt">Cover art</FieldLabel> 168 - <FieldDescription>PNG, JPEG, or WebP. Max 10 MB.</FieldDescription> 169 - <Input 170 - id="coverArt" 171 - type="file" 172 - accept="image/png,image/jpeg,image/webp" 173 - onChange={(event) => { 174 - const file = event.target.files?.[0]; 175 - if (file) setValue("coverArt", file, { shouldValidate: true }); 176 - }} 177 - required 178 - /> 179 - <FieldError>{errors.coverArt?.message}</FieldError> 180 - </Field> 181 - 182 - <Button type="submit" className="w-full" disabled={isSubmitting}> 183 - {isSubmitting && <Loader2 className="animate-spin" />} 184 - {isSubmitting ? "Uploading..." : "Upload song"} 185 - </Button> 186 - </form> 187 - </fieldset> 188 - ); 189 - }
+2 -10
src/app/(main)/upload/song-schema.ts src/components/song/upload-schema.ts
··· 14 14 const MAX_AUDIO_SIZE = 52_428_800; // 50 MB 15 15 const MAX_COVER_ART_SIZE = 10_000_000; // 10 MB 16 16 17 - export const songSchema = z.object({ 17 + export const uploadSongSchema = z.object({ 18 18 title: z 19 19 .string() 20 20 .min(1, { error: "Title is required." }) 21 21 .max(128, { error: "Title must be 128 characters or fewer." }), 22 - slug: z 23 - .string() 24 - .min(1, { error: "Slug is required." }) 25 - .max(128, { error: "Slug must be 128 characters or fewer." }) 26 - .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { 27 - error: 28 - "Slug must be lowercase letters, numbers, and hyphens only, with no leading or trailing hyphens.", 29 - }), 30 22 description: z 31 23 .string() 32 24 .max(5000, { error: "Description must be 5000 characters or fewer." }) ··· 51 43 .min(1, { error: "Duration must be at least 1 second." }), 52 44 }); 53 45 54 - export type SongFormData = z.infer<typeof songSchema>; 46 + export type UploadSongFormData = z.infer<typeof uploadSongSchema>;
+2 -3
src/components/avatar-section/avatar-section.tsx
··· 13 13 import { LogoutButton } from "./logout-button"; 14 14 import { EllipsisIcon } from "lucide-react"; 15 15 import { Button } from "@/components/ui/button"; 16 + import { UploadDialog } from "@/components/song/upload-dialog"; 16 17 17 18 export async function AvatarSection() { 18 19 const session = await getSession(); ··· 57 58 </DropdownMenuContent> 58 59 </DropdownMenu> 59 60 </div> 60 - <Button asChild> 61 - <Link href="/upload">Upload a song</Link> 62 - </Button> 61 + <UploadDialog /> 63 62 </> 64 63 ); 65 64 }
+2 -2
src/components/song/edit-schema.ts
··· 1 1 import { z } from "zod"; 2 - import { songSchema } from "@/app/(main)/upload/song-schema"; 2 + import { uploadSongSchema } from "./upload-schema"; 3 3 4 - export const editSongSchema = songSchema 4 + export const editSongSchema = uploadSongSchema 5 5 .pick({ 6 6 title: true, 7 7 description: true,
+67
src/components/song/upload-action.ts
··· 1 + "use server"; 2 + 3 + import { Agent } from "@atproto/api"; 4 + import { type ActionResult, ok, fail } from "@/lib/action-result"; 5 + import { requireSession } from "@/lib/repo"; 6 + import { COLLECTIONS } from "@/lib/atproto"; 7 + 8 + export async function uploadSong( 9 + _prevState: ActionResult<{ handle: string; rkey: string }> | null, 10 + formData: FormData, 11 + ): Promise<ActionResult<{ handle: string; rkey: string }>> { 12 + const title = formData.get("title") as string; 13 + const description = (formData.get("description") as string) || undefined; 14 + const genre = (formData.get("genre") as string) || undefined; 15 + const audio = formData.get("audio") as File; 16 + const coverArt = formData.get("coverArt") as File; 17 + const duration = Number(formData.get("duration")); 18 + 19 + if (!title) { 20 + return fail(new Error("Title is required.")); 21 + } 22 + 23 + if (audio.size === 0) { 24 + return fail(new Error("Audio file is required.")); 25 + } 26 + 27 + if (coverArt.size === 0) { 28 + return fail(new Error("Cover art is required.")); 29 + } 30 + 31 + const session = await requireSession(); 32 + const agent = new Agent(session); 33 + 34 + try { 35 + const [{ data: audioUpload }, { data: coverArtUpload }] = await Promise.all( 36 + [ 37 + agent.uploadBlob(audio, { encoding: audio.type }), 38 + agent.uploadBlob(coverArt, { encoding: coverArt.type }), 39 + ], 40 + ); 41 + 42 + const { data: record } = await agent.com.atproto.repo.createRecord({ 43 + repo: agent.assertDid, 44 + collection: COLLECTIONS.song, 45 + record: { 46 + $type: COLLECTIONS.song, 47 + title, 48 + description, 49 + genre, 50 + audio: audioUpload.blob, 51 + coverArt: coverArtUpload.blob, 52 + duration, 53 + createdAt: new Date().toISOString(), 54 + }, 55 + }); 56 + 57 + const rkey = record.uri.split("/").pop()!; 58 + const { data: repo } = await agent.com.atproto.repo.describeRepo({ 59 + repo: agent.assertDid, 60 + }); 61 + 62 + return ok({ handle: repo.handle, rkey }); 63 + } catch (error) { 64 + console.error("Failed to upload song:", error); 65 + return fail(error); 66 + } 67 + }
+201
src/components/song/upload-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 { Input } from "../ui/input"; 14 + import { Textarea } from "../ui/textarea"; 15 + import { Field, FieldLabel, FieldDescription, FieldError } from "../ui/field"; 16 + import { Loader2Icon } from "lucide-react"; 17 + import { uploadSong } from "@/components/song/upload-action"; 18 + import type { ActionResult } from "@/lib/action-result"; 19 + import { startTransition, useActionState, useState } from "react"; 20 + import { useForm } from "react-hook-form"; 21 + import { zodResolver } from "@hookform/resolvers/zod"; 22 + import { uploadSongSchema, type UploadSongFormData } from "./upload-schema"; 23 + import { useRouter } from "next/navigation"; 24 + 25 + function getAudioDuration(file: File): Promise<number> { 26 + return new Promise((resolve, reject) => { 27 + const audio = new Audio(); 28 + audio.addEventListener("loadedmetadata", () => { 29 + const duration = Math.round(audio.duration); 30 + URL.revokeObjectURL(audio.src); 31 + resolve(duration); 32 + }); 33 + audio.addEventListener("error", () => { 34 + URL.revokeObjectURL(audio.src); 35 + reject(new Error("Could not read audio file.")); 36 + }); 37 + audio.src = URL.createObjectURL(file); 38 + }); 39 + } 40 + 41 + export function UploadDialog() { 42 + const [open, setOpen] = useState(false); 43 + const router = useRouter(); 44 + 45 + const { 46 + register, 47 + handleSubmit, 48 + setValue, 49 + setError, 50 + reset, 51 + formState: { errors }, 52 + } = useForm<UploadSongFormData>({ 53 + resolver: zodResolver(uploadSongSchema), 54 + }); 55 + 56 + const [state, action, pending] = useActionState( 57 + async ( 58 + prevState: ActionResult<{ handle: string; rkey: string }> | null, 59 + formData: FormData, 60 + ) => { 61 + const result = await uploadSong(prevState, formData); 62 + if (result.success) { 63 + setOpen(false); 64 + reset(); 65 + router.push(`/${result.data.handle}/${result.data.rkey}`); 66 + } 67 + return result; 68 + }, 69 + null, 70 + ); 71 + 72 + async function onAudioChange(event: React.ChangeEvent<HTMLInputElement>) { 73 + const file = event.target.files?.[0]; 74 + if (!file) return; 75 + 76 + setValue("audio", file, { shouldValidate: true }); 77 + 78 + try { 79 + const duration = await getAudioDuration(file); 80 + setValue("duration", duration, { shouldValidate: true }); 81 + } catch { 82 + setError("audio", { message: "Could not read audio duration." }); 83 + } 84 + } 85 + 86 + async function onSubmit(data: UploadSongFormData) { 87 + const formData = new FormData(); 88 + formData.set("title", data.title); 89 + if (data.description) formData.set("description", data.description); 90 + if (data.genre) formData.set("genre", data.genre); 91 + formData.set("audio", data.audio); 92 + formData.set("coverArt", data.coverArt); 93 + formData.set("duration", String(data.duration)); 94 + startTransition(() => { 95 + action(formData); 96 + }); 97 + } 98 + 99 + return ( 100 + <Dialog 101 + open={open} 102 + onOpenChange={(value) => { 103 + setOpen(value); 104 + if (!value) { 105 + reset(); 106 + } 107 + }} 108 + > 109 + <DialogTrigger asChild> 110 + <Button>Upload a song</Button> 111 + </DialogTrigger> 112 + <DialogContent onInteractOutside={(event) => event.preventDefault()}> 113 + <DialogHeader> 114 + <DialogTitle>Upload a song</DialogTitle> 115 + </DialogHeader> 116 + {state && !state.success && ( 117 + <p className="text-sm text-destructive">{state.error}</p> 118 + )} 119 + <form 120 + onSubmit={(event) => void handleSubmit(onSubmit)(event)} 121 + className="space-y-4" 122 + > 123 + <Field data-invalid={!!errors.title}> 124 + <FieldLabel htmlFor="upload-title">Title</FieldLabel> 125 + <Input 126 + id="upload-title" 127 + placeholder="Enter the title of your song" 128 + {...register("title")} 129 + /> 130 + <FieldError>{errors.title?.message}</FieldError> 131 + </Field> 132 + 133 + <Field data-invalid={!!errors.genre}> 134 + <FieldLabel htmlFor="upload-genre">Genre</FieldLabel> 135 + <Input 136 + id="upload-genre" 137 + placeholder="Electronic, Hip-Hop, Jazz..." 138 + {...register("genre")} 139 + /> 140 + <FieldError>{errors.genre?.message}</FieldError> 141 + </Field> 142 + 143 + <Field data-invalid={!!errors.description}> 144 + <FieldLabel htmlFor="upload-description">Description</FieldLabel> 145 + <Textarea 146 + id="upload-description" 147 + placeholder="Tell listeners about this song..." 148 + {...register("description")} 149 + /> 150 + <FieldError>{errors.description?.message}</FieldError> 151 + </Field> 152 + 153 + <Field data-invalid={!!errors.audio}> 154 + <FieldLabel htmlFor="upload-audio">Audio file</FieldLabel> 155 + <FieldDescription> 156 + MP3, OGG, WAV, FLAC, AAC, or WebM. Max 50 MB. 157 + </FieldDescription> 158 + <Input 159 + id="upload-audio" 160 + type="file" 161 + accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/webm" 162 + onChange={(event) => void onAudioChange(event)} 163 + /> 164 + <FieldError>{errors.audio?.message}</FieldError> 165 + </Field> 166 + 167 + <Field data-invalid={!!errors.coverArt}> 168 + <FieldLabel htmlFor="upload-coverArt">Cover art</FieldLabel> 169 + <FieldDescription>PNG, JPEG, or WebP. Max 10 MB.</FieldDescription> 170 + <Input 171 + id="upload-coverArt" 172 + type="file" 173 + accept="image/png,image/jpeg,image/webp" 174 + onChange={(event) => { 175 + const file = event.target.files?.[0]; 176 + if (file) setValue("coverArt", file, { shouldValidate: true }); 177 + }} 178 + /> 179 + <FieldError>{errors.coverArt?.message}</FieldError> 180 + </Field> 181 + 182 + <DialogFooter> 183 + <DialogClose asChild> 184 + <Button variant="outline">Cancel</Button> 185 + </DialogClose> 186 + <Button type="submit" disabled={pending}> 187 + {pending ? ( 188 + <> 189 + <Loader2Icon className="animate-spin" /> 190 + Uploading... 191 + </> 192 + ) : ( 193 + "Upload" 194 + )} 195 + </Button> 196 + </DialogFooter> 197 + </form> 198 + </DialogContent> 199 + </Dialog> 200 + ); 201 + }
-2
src/lib/lexicons/types/app/musicsky/temp/song.ts
··· 31 31 coverArt: BlobRef 32 32 /** Duration of the track in seconds. */ 33 33 duration: number 34 - /** URL-friendly slug for the track. */ 35 - slug: string 36 34 labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 37 35 /** Client-declared timestamp of when the track was uploaded. */ 38 36 createdAt: string
-1
src/types/song.ts
··· 2 2 3 3 export interface TrackRecord { 4 4 title: string; 5 - slug: string; 6 5 coverArt: BlobRef; 7 6 audio: BlobRef; 8 7 genre?: string;