WIP. A little custom music server
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

run format

+150 -178
+11 -15
web/src/components/album-card.tsx
··· 1 - import { Link } from "waku" 1 + import { Link } from "waku"; 2 2 3 3 type Props = { 4 4 album: Readonly<{ 5 - id: string 6 - title: string 5 + id: string; 6 + title: string; 7 7 artists: ReadonlyArray<{ 8 - id: string 9 - name: string, 10 - }> 11 - }> 12 - } 8 + id: string; 9 + name: string; 10 + }>; 11 + }>; 12 + }; 13 13 14 - const mockCover = "https://writteninmusic.com/wp-content/uploads/2025/09/TOP-Breach.jpg" 14 + const mockCover = "https://writteninmusic.com/wp-content/uploads/2025/09/TOP-Breach.jpg"; 15 15 export function AlbumCard(props: Props) { 16 16 const { id, title, artists } = props.album; 17 17 18 18 return ( 19 - <Link 20 - to={`/album/${id}`} 21 - className="space-y-px p-3 transition duration-100 hover:bg-black/10 h-fit rounded-lg"> 19 + <Link to={`/album/${id}`} className="space-y-px p-3 transition duration-100 hover:bg-black/10 h-fit rounded-lg"> 22 20 <div className="aspect-square rounded-md overflow-hidden"> 23 21 <img width={900} height={900} src={mockCover} className="object-cover w-full h-full" /> 24 22 </div> 25 23 <p className="text-md font-medium">{title}</p> 26 24 <p className="text-sm opacity-70">{artists.at(0)?.name}</p> 27 25 </Link> 28 - ) 26 + ); 29 27 } 30 - 31 -
+38 -47
web/src/components/ui/button.tsx
··· 1 - import * as React from "react" 2 - import { Slot } from "@radix-ui/react-slot" 3 - import { cva, type VariantProps } from "class-variance-authority" 1 + import * as React from "react"; 2 + import { Slot } from "@radix-ui/react-slot"; 3 + import { cva, type VariantProps } from "class-variance-authority"; 4 4 5 - import { cn } from "@/lib/utils" 5 + import { cn } from "@/lib/utils"; 6 6 7 7 const buttonVariants = cva( 8 - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 - { 10 - variants: { 11 - variant: { 12 - default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 - destructive: 14 - "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 - outline: 16 - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 - secondary: 18 - "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 - ghost: "hover:bg-accent hover:text-accent-foreground", 20 - link: "text-primary underline-offset-4 hover:underline", 21 - }, 22 - size: { 23 - default: "h-10 px-4 py-2", 24 - sm: "h-9 rounded-md px-3", 25 - lg: "h-11 rounded-md px-8", 26 - icon: "h-10 w-10", 27 - }, 28 - }, 29 - defaultVariants: { 30 - variant: "default", 31 - size: "default", 32 - }, 33 - } 34 - ) 8 + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 + { 10 + variants: { 11 + variant: { 12 + default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 15 + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 16 + ghost: "hover:bg-accent hover:text-accent-foreground", 17 + link: "text-primary underline-offset-4 hover:underline", 18 + }, 19 + size: { 20 + default: "h-10 px-4 py-2", 21 + sm: "h-9 rounded-md px-3", 22 + lg: "h-11 rounded-md px-8", 23 + icon: "h-10 w-10", 24 + }, 25 + }, 26 + defaultVariants: { 27 + variant: "default", 28 + size: "default", 29 + }, 30 + }, 31 + ); 35 32 36 33 export interface ButtonProps 37 - extends React.ButtonHTMLAttributes<HTMLButtonElement>, 38 - VariantProps<typeof buttonVariants> { 39 - asChild?: boolean 34 + extends React.ButtonHTMLAttributes<HTMLButtonElement>, 35 + VariantProps<typeof buttonVariants> { 36 + asChild?: boolean; 40 37 } 41 38 42 39 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 43 - ({ className, variant, size, asChild = false, ...props }, ref) => { 44 - const Comp = asChild ? Slot : "button" 45 - return ( 46 - <Comp 47 - className={cn(buttonVariants({ variant, size, className }))} 48 - ref={ref} 49 - {...props} 50 - /> 51 - ) 52 - } 53 - ) 54 - Button.displayName = "Button" 40 + ({ className, variant, size, asChild = false, ...props }, ref) => { 41 + const Comp = asChild ? Slot : "button"; 42 + return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />; 43 + }, 44 + ); 45 + Button.displayName = "Button"; 55 46 56 - export { Button, buttonVariants } 47 + export { Button, buttonVariants };
+18 -18
web/src/components/ui/input.tsx
··· 1 - import * as React from "react" 1 + import * as React from "react"; 2 2 3 - import { cn } from "@/lib/utils" 3 + import { cn } from "@/lib/utils"; 4 4 5 5 const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( 6 - ({ className, type, ...props }, ref) => { 7 - return ( 8 - <input 9 - type={type} 10 - className={cn( 11 - "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 12 - className 13 - )} 14 - ref={ref} 15 - {...props} 16 - /> 17 - ) 18 - } 19 - ) 20 - Input.displayName = "Input" 6 + ({ className, type, ...props }, ref) => { 7 + return ( 8 + <input 9 + type={type} 10 + className={cn( 11 + "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 12 + className, 13 + )} 14 + ref={ref} 15 + {...props} 16 + /> 17 + ); 18 + }, 19 + ); 20 + Input.displayName = "Input"; 21 21 22 - export { Input } 22 + export { Input };
+19 -22
web/src/components/ui/progress.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import * as React from "react" 4 - import * as ProgressPrimitive from "@radix-ui/react-progress" 3 + import * as React from "react"; 4 + import * as ProgressPrimitive from "@radix-ui/react-progress"; 5 5 6 - import { cn } from "@/lib/utils" 6 + import { cn } from "@/lib/utils"; 7 7 8 8 const Progress = React.forwardRef< 9 - React.ElementRef<typeof ProgressPrimitive.Root>, 10 - React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> 9 + React.ElementRef<typeof ProgressPrimitive.Root>, 10 + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> 11 11 >(({ className, value, ...props }, ref) => ( 12 - <ProgressPrimitive.Root 13 - ref={ref} 14 - className={cn( 15 - "relative h-4 w-full overflow-hidden rounded-full bg-secondary", 16 - className 17 - )} 18 - {...props} 19 - > 20 - <ProgressPrimitive.Indicator 21 - className="h-full w-full flex-1 bg-primary transition-all" 22 - style={{ transform: `translateX(-${100 - (value || 0)}%)` }} 23 - /> 24 - </ProgressPrimitive.Root> 25 - )) 26 - Progress.displayName = ProgressPrimitive.Root.displayName 12 + <ProgressPrimitive.Root 13 + ref={ref} 14 + className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} 15 + {...props} 16 + > 17 + <ProgressPrimitive.Indicator 18 + className="h-full w-full flex-1 bg-primary transition-all" 19 + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} 20 + /> 21 + </ProgressPrimitive.Root> 22 + )); 23 + Progress.displayName = ProgressPrimitive.Root.displayName; 27 24 28 - export { Progress } 25 + export { Progress };
+19 -22
web/src/components/ui/slider.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import * as React from "react" 4 - import * as SliderPrimitive from "@radix-ui/react-slider" 3 + import * as React from "react"; 4 + import * as SliderPrimitive from "@radix-ui/react-slider"; 5 5 6 - import { cn } from "@/lib/utils" 6 + import { cn } from "@/lib/utils"; 7 7 8 8 const Slider = React.forwardRef< 9 - React.ElementRef<typeof SliderPrimitive.Root>, 10 - React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> 9 + React.ElementRef<typeof SliderPrimitive.Root>, 10 + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> 11 11 >(({ className, ...props }, ref) => ( 12 - <SliderPrimitive.Root 13 - ref={ref} 14 - className={cn( 15 - "relative flex w-full touch-none select-none items-center", 16 - className 17 - )} 18 - {...props} 19 - > 20 - <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> 21 - <SliderPrimitive.Range className="absolute h-full bg-primary" /> 22 - </SliderPrimitive.Track> 23 - <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> 24 - </SliderPrimitive.Root> 25 - )) 26 - Slider.displayName = SliderPrimitive.Root.displayName 12 + <SliderPrimitive.Root 13 + ref={ref} 14 + className={cn("relative flex w-full touch-none select-none items-center", className)} 15 + {...props} 16 + > 17 + <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> 18 + <SliderPrimitive.Range className="absolute h-full bg-primary" /> 19 + </SliderPrimitive.Track> 20 + <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> 21 + </SliderPrimitive.Root> 22 + )); 23 + Slider.displayName = SliderPrimitive.Root.displayName; 27 24 28 - export { Slider } 25 + export { Slider };
+5 -8
web/src/pages/album/[id].tsx
··· 63 63 }; 64 64 } 65 65 66 - 67 66 const ArtistSchema = Schema.Struct({ 68 67 id: Schema.NonEmptyString, 69 68 name: Schema.NonEmptyString, ··· 82 81 }); 83 82 84 83 function fetchAlbum(id: string) { 85 - return Effect.gen(function*() { 84 + return Effect.gen(function* () { 86 85 const request = yield* Effect.tryPromise({ 87 86 try: () => fetch(`http://localhost:3003/album/${id}`), 88 87 catch: (err) => ··· 106 105 }).pipe(Effect.tapError((err) => Console.error(err))); 107 106 } 108 107 109 - export default async function AlbumPage({ id }: PageProps<'/album/[id]'>) { 108 + export default async function AlbumPage({ id }: PageProps<"/album/[id]">) { 110 109 return await Effect.runPromise( 111 - Effect.gen(function*() { 110 + Effect.gen(function* () { 112 111 const data = yield* Effect.tryPromise(() => getMock()); 113 112 114 113 const album = yield* fetchAlbum(id); ··· 168 167 ); 169 168 } 170 169 171 - 172 170 export async function getConfig() { 173 171 return { 174 - render: "dynamic" // TODO: Change to static with staticPaths:[''], 175 - } as const 172 + render: "dynamic", // TODO: Change to static with staticPaths:[''], 173 + } as const; 176 174 } 177 -
+39 -45
web/src/pages/album/index.tsx
··· 3 3 import { Console, Effect, Schema } from "effect"; 4 4 import { divide } from "effect/Duration"; 5 5 6 - 7 6 const ArtistSchema = Schema.Struct({ 8 7 id: Schema.NonEmptyString, 9 8 name: Schema.NonEmptyString, ··· 12 11 const AlbumSchema = Schema.Struct({ 13 12 id: Schema.NonEmptyString, 14 13 title: Schema.NonEmptyString, 15 - cover: Schema.UndefinedOr(Schema.String), // TODO: enforce cover to be string, or atleast String | Null 16 - artists: Schema.Array(ArtistSchema) 14 + cover: Schema.UndefinedOr(Schema.String), // TODO: enforce cover to be string, or atleast String | Null 15 + artists: Schema.Array(ArtistSchema), 17 16 }); 18 17 19 - const getAlbums = () => Effect.gen(function*() { 18 + const getAlbums = () => 19 + Effect.gen(function* () { 20 + const request = yield* Effect.tryPromise({ 21 + try: () => fetch(`http://localhost:3003/albums`), 22 + catch: (err) => 23 + new FetchFailedError({ 24 + cause: err, 25 + message: "Failed to fetch album", 26 + }), 27 + }); 20 28 21 - const request = yield* Effect.tryPromise({ 22 - try: () => fetch(`http://localhost:3003/albums`), 23 - catch: (err) => 24 - new FetchFailedError({ 25 - cause: err, 26 - message: "Failed to fetch album", 27 - }), 28 - }); 29 + const json = yield* Effect.tryPromise({ 30 + try: () => request.json().then((x) => x as unknown), 31 + catch: (err) => 32 + new JsonParseError({ 33 + cause: err, 34 + message: "Failed to parse json", 35 + }), 36 + }); 37 + yield* Console.dir(json, { depth: 3 }); 29 38 30 - const json = yield* Effect.tryPromise({ 31 - try: () => request.json().then((x) => x as unknown), 32 - catch: (err) => 33 - new JsonParseError({ 34 - cause: err, 35 - message: "Failed to parse json", 36 - }), 39 + const parsed = yield* Schema.decodeUnknown(Schema.Array(AlbumSchema))(json); 40 + return parsed; 41 + //return Array.from({ length: 15 }).map(() => parsed).flat(); 37 42 }); 38 - yield* Console.dir(json, { depth: 3 }) 39 - 40 - const parsed = yield* Schema.decodeUnknown(Schema.Array(AlbumSchema))(json); 41 - return parsed 42 - //return Array.from({ length: 15 }).map(() => parsed).flat(); 43 - }) 44 - 45 - const AlbumPage = () => Effect.runPromise(Effect.gen(function*() { 46 - 47 - 48 - const albums = yield* getAlbums() 49 - 50 - return ( 51 - <div className="container"> 52 - <div className="grid lg:grid-cols-6 grid-cols-1"> 53 - 54 - 55 - {albums.map(album => <AlbumCard 56 - album={album} 57 - key={album.id} 58 43 59 - />)} 44 + const AlbumPage = () => 45 + Effect.runPromise( 46 + Effect.gen(function* () { 47 + const albums = yield* getAlbums(); 60 48 61 - </div> 62 - </div> 63 - ) 64 - })) 65 - 49 + return ( 50 + <div className="container"> 51 + <div className="grid lg:grid-cols-6 grid-cols-1"> 52 + {albums.map((album) => ( 53 + <AlbumCard album={album} key={album.id} /> 54 + ))} 55 + </div> 56 + </div> 57 + ); 58 + }), 59 + ); 66 60 67 61 export default AlbumPage;
+1 -1
web/tsconfig.json
··· 14 14 "noUncheckedIndexedAccess": true, 15 15 "exactOptionalPropertyTypes": true, 16 16 "jsx": "react-jsx", 17 - "baseUrl":".", 17 + "baseUrl": ".", 18 18 "paths": { 19 19 "@/*": ["./src/*"] 20 20 }