music and animation system

This commit is contained in:
2026-03-13 15:49:53 +01:00
parent 4916a2fc7b
commit 166ae50c49
24 changed files with 785 additions and 15 deletions

View File

@@ -0,0 +1,30 @@
import { useGSAP } from "@gsap/react";
import { useRef, type ReactNode } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import { SplitText } from "gsap/SplitText";
import gsap from 'gsap'
const AnimateTextIn = ({children,animation="type"}:{children:ReactNode,animation?:"type"|"slide",index?:gsap.Position}) => {
const el = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext();
useGSAP(() => {
const tl = gsap.timeline();
const chars = new SplitText(el.current,{type:'chars'})
tl.to(el.current,{opacity:100, duration:0})
switch(animation) {
case "slide":
tl.from(chars.chars,{opacity:0, x:-10, duration: 0.2, stagger: {each: 0.08}, ease:'bounce.inOut', scrollTrigger: el.current })
break
case "type":
tl.from(chars.chars,{opacity:0, duration: 0.01, stagger: {each: 0.04}, ease: 'bounce.inOut', scrollTrigger: el.current })
break
}
gsapContext?.addAnimation(tl)
},{scope:el})
return (
<div ref={el} className="opacity-0">
{children}
</div>
)
}
export default AnimateTextIn;

View File

@@ -0,0 +1,300 @@
"use client";
import React, { useRef, useEffect, useCallback, useState } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { useTheme } from "next-themes";
/* ─────────────────────────────────────────────
* Config — grayscale palettes
* ───────────────────────────────────────────── */
const PALETTES = {
dark: {
base: "#0a0a0a",
particles: [
"rgba(255,255,255,0.70)",
"rgba(255,255,255,0.45)",
"rgba(180,180,180,0.50)",
"rgba(200,200,200,0.35)",
"rgba(255,255,255,0.22)",
],
},
light: {
base: "#f5f5f5",
particles: [
"rgba(0,0,0,0.55)",
"rgba(0,0,0,0.35)",
"rgba(60,60,60,0.40)",
"rgba(80,80,80,0.25)",
"rgba(0,0,0,0.18)",
],
},
} as const;
/* ─────────────────────────────────────────────
* Helpers
* ───────────────────────────────────────────── */
const isMobileDevice = (): boolean => {
if (typeof window === "undefined") return false;
return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768;
};
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
/* ─────────────────────────────────────────────
* Particle
* ───────────────────────────────────────────── */
interface Particle {
angle: number;
radius: number;
speed: number;
size: number;
colorIndex: number;
wobbleAmp: number;
wobbleSpeed: number;
wobblePhase: number;
}
const spawnParticle = (): Particle => ({
angle: rand(0, Math.PI * 2),
radius: rand(30, 240),
speed: rand(0.003, 0.002) * (Math.random() > 0.5 ? 1 : -1),
size: rand(1.2, 4),
colorIndex: Math.floor(rand(0, 5)),
wobbleAmp: rand(6, 30),
wobbleSpeed: rand(0.008, 0.035),
wobblePhase: rand(0, Math.PI * 2),
});
/* ─────────────────────────────────────────────
* Component
* ───────────────────────────────────────────── */
interface AnimatedBackgroundContainerProps {
children: React.ReactNode;
className?: string;
/** Number of orbiting particles. Default 60 */
particleCount?: number;
/** Max orbit radius in px — controls how far particles spread from the cursor. Default 240 */
orbitRadius?: number;
/** How quickly particles catch up to cursor (01). Default 0.06 */
followSpeed?: number;
/** Speed multiplier for mobile random anchor drift. Default 1 */
mobileSpeed?: number;
}
export default function AnimatedBackgroundContainer({
children,
className = "",
particleCount = 60,
orbitRadius = 240,
followSpeed = 0.06,
mobileSpeed = 1,
}: AnimatedBackgroundContainerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const mousePos = useRef({ x: 0, y: 0 });
const smoothMouse = useRef({ x: 0, y: 0 });
const mobileAnchor = useRef({ x: 0, y: 0 });
const mobileTarget = useRef({ x: 0, y: 0 });
const isMobile = useRef(false);
const particles = useRef<Particle[]>([]);
const frame = useRef(0);
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const palette = isDark ? PALETTES.dark : PALETTES.light;
/* Spawn particles */
useEffect(() => {
const minR = Math.max(10, orbitRadius * 0.12);
particles.current = Array.from({ length: particleCount }, () => ({
...spawnParticle(),
radius: rand(minR, orbitRadius),
wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12),
}));
}, [particleCount, orbitRadius]);
/* Detect mobile & seed positions */
useEffect(() => {
setMounted(true);
isMobile.current = isMobileDevice();
if (containerRef.current) {
const cx = containerRef.current.clientWidth / 2;
const cy = containerRef.current.clientHeight / 2;
mousePos.current = { x: cx, y: cy };
smoothMouse.current = { x: cx, y: cy };
mobileAnchor.current = { x: cx, y: cy };
mobileTarget.current = {
x: rand(cx * 0.4, cx * 1.6),
y: rand(cy * 0.4, cy * 1.6),
};
}
}, []);
/* Resize canvas to match container */
useEffect(() => {
const resize = () => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const dpr = window.devicePixelRatio || 1;
const w = container.clientWidth;
const h = container.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
};
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
/* Mouse tracking (desktop only) */
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!containerRef.current || isMobile.current) return;
const rect = containerRef.current.getBoundingClientRect();
mousePos.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener("mousemove", handleMouseMove, { passive: true });
return () => el.removeEventListener("mousemove", handleMouseMove);
}, [handleMouseMove]);
/* ── GSAP ticker — draw loop ── */
useGSAP(
() => {
if (!mounted) return;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const tick = () => {
const w = container.clientWidth;
const h = container.clientHeight;
frame.current++;
/* Anchor: smooth-follow cursor or drift on mobile */
if (isMobile.current) {
mobileAnchor.current.x +=
(mobileTarget.current.x - mobileAnchor.current.x) * 0.008 * mobileSpeed;
mobileAnchor.current.y +=
(mobileTarget.current.y - mobileAnchor.current.y) * 0.008 * mobileSpeed;
const dx = mobileTarget.current.x - mobileAnchor.current.x;
const dy = mobileTarget.current.y - mobileAnchor.current.y;
if (Math.sqrt(dx * dx + dy * dy) < 30) {
mobileTarget.current = {
x: rand(w * 0.15, w * 0.85),
y: rand(h * 0.15, h * 0.85),
};
}
smoothMouse.current.x = mobileAnchor.current.x;
smoothMouse.current.y = mobileAnchor.current.y;
} else {
smoothMouse.current.x +=
(mousePos.current.x - smoothMouse.current.x) * followSpeed;
smoothMouse.current.y +=
(mousePos.current.y - smoothMouse.current.y) * followSpeed;
}
const cx = smoothMouse.current.x;
const cy = smoothMouse.current.y;
/* Clear frame */
ctx.clearRect(0, 0, w, h);
/* Draw each particle */
particles.current.forEach((p) => {
p.angle += p.speed;
const wobble =
Math.sin(frame.current * p.wobbleSpeed + p.wobblePhase) * p.wobbleAmp;
const r = p.radius + wobble;
const x = cx + Math.cos(p.angle) * r;
const y = cy + Math.sin(p.angle) * r;
/* Soft fade near viewport edges */
const edgeFade = Math.max(
0,
Math.min(x / 80, (w - x) / 80, y / 80, (h - y) / 80, 1),
);
if (edgeFade <= 0) return;
ctx.globalAlpha = edgeFade;
ctx.fillStyle = palette.particles[p.colorIndex];
ctx.beginPath();
ctx.arc(x, y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
};
gsap.ticker.add(tick);
return () => {
gsap.ticker.remove(tick);
};
},
{
scope: containerRef,
dependencies: [mounted, isDark, followSpeed, mobileSpeed, orbitRadius, palette],
},
);
/* ── Render ── */
return (
<div
ref={containerRef}
className={className}
style={{
position: "relative",
minHeight: "100vh",
width: "100%",
overflow: "hidden",
backgroundColor: palette.base,
transition: "background-color 0.6s ease",
}}
>
<canvas
ref={canvasRef}
aria-hidden
style={{
position: "absolute",
inset: 0,
zIndex: 0,
pointerEvents: "none",
}}
/>
{/* Grain texture */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
zIndex: 1,
opacity: isDark ? 0.05 : 0.03,
pointerEvents: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
backgroundRepeat: "repeat",
backgroundSize: "180px 180px",
}}
/>
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { useGSAP } from "@gsap/react"; import { useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import { SplitText } from "gsap/SplitText";
import gsap from 'gsap'
const AnimatedPageTitle = (
{ text }: { text: string }
) => {
const el = useRef<HTMLHeadingElement>(null)
const gsapContext = useGsapContext();
useGSAP(() => {
const tl = gsap.timeline();
tl.addLabel("title")
const split = new SplitText(el.current, { type: "chars" })
tl.to(el.current, { opacity: 100 })
tl.from(split.chars, {
stagger: 0.05, rotate: -90, opacity: 0, x: -10
}, '>')
gsapContext?.addAnimation(tl)
}, { scope: el })
return (
<h1 className="text-4xl opacity-0 font-bold text-balance w-full" ref={el}> {text} </h1>
)
}
export default AnimatedPageTitle;

View File

@@ -19,6 +19,9 @@ export default function TopNav() {
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href={"/projects"}> Projects </Link>
</Button>
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
<Link href={"/music"}> Music </Link>
</Button>
</div>
<div className="flex flex-col-reverse flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row lg:ml-auto">
<AdminWrap>

View File

@@ -1,18 +1,40 @@
'use client'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
import { createContext, useContext, type ReactNode } from 'react'
import { SplitText } from 'gsap/SplitText'
import { ScrollTrigger } from 'gsap/all'
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
gsap.registerPlugin(useGSAP)
const GsapContext = createContext<typeof globalThis.gsap | null>(null)
gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(SplitText)
const GsapContext = createContext<{ addAnimation: (animation: gsap.core.TimelineChild) => void, resetTimeline: () => void} | null>(null)
export function useGsapContext() {
return useContext(GsapContext)
}
export default function GsapProvider({children}:{children:ReactNode}) {
export default function GsapProvider({ children }: { children: ReactNode }) {
const [tl, setTl] = useState<GSAPTimeline | undefined>();
const indexRef = useRef<number>(0)
const { contextSafe } = useGSAP(() => {
console.log("App effect (create timeline)");
const tl = gsap.timeline();
setTl(() => tl);
}, []);
const addAnimation = useCallback((animation: gsap.core.TimelineChild) => {
indexRef.current += 1;
console.log(indexRef.current)
tl && tl.add(animation, indexRef.current * 2);
}, [tl]);
const resetTimeline = useCallback(() => {
const tl = gsap.timeline();
setTl(() => tl)
indexRef.current = 0;
},[tl])
return (
<GsapContext.Provider value={gsap}>
<GsapContext.Provider value={{ addAnimation, resetTimeline }}>
{children}
</GsapContext.Provider>
)

View File

@@ -20,6 +20,9 @@ export default async function AdminSideBar() {
<Link href={"/admin/project/techStack/create"}> Create Stack </Link>
<Link href={"/admin/project/list"}> Project List </Link>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Music">
<Link href={"/admin/music"}> Manage Music </Link>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Blog">
<Link href={"/"}> Some Blog Action </Link>
</SimpleSidebarGroup>

View File

@@ -14,13 +14,13 @@ import { SelectItem } from '~/components/ui/select';
import {FormMutationContextProvider} from '~/app/_components/Form/Components/MutationProvider';
export default function CreateUpdateCvCategoryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['category']['select']> }) {
const schemas = entitySchemas('cvCategory')
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const [id, setId] = useState<string | undefined>(params.entity?.id)
const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert),
defaultValues: {
id: params.entity ? params.entity.id : crypto.randomUUID(),
name: params.entity ? params.entity.name : "",
layoutPosition: params.entity ? params.entity.layoutPosition : "col1"
id: params.entity?.id || crypto.randomUUID(),
name: params.entity?.name || "",
layoutPosition: params.entity?.layoutPosition || "col1"
}
})
let path = usePathname()

View File

@@ -0,0 +1,105 @@
'use client'
import { useState } from "react";
import { trpc } from "~/app/_trpc/Client";
import { UploadDropzone } from "~/lib/uploadthing";
import { Label } from "~/components/ui/label";
import { FormScaffold } from "~/app/_components/Form/Components";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import type { RouterOutputs } from "~/server/routers/_app";
import type { IterableElement } from "type-fest";
import { Toaster } from "~/components/ui/sonner";
import { toast } from "sonner";
import { FormMutationContextProvider } from "~/app/_components/Form/Components/MutationProvider";
import { TextInputFormField } from "~/app/_components/Form/Fields";
import { createMusicInputSchema } from "~/lib/trpc/music/schemas";
export default function CreateUpdateMusicForm(props: {
entity?: IterableElement<RouterOutputs['music']['list']>,
className?: string
}) {
const entity = props.entity;
const [id, setId] = useState<string | undefined>(entity?.id)
const utils = trpc.useUtils();
const form = useForm<z.infer<typeof createMusicInputSchema>>({
resolver: zodResolver(createMusicInputSchema),
defaultValues: {
id: entity?.id || crypto.randomUUID(),
title: entity?.title || "",
description: entity?.description || "",
fileUrl: entity?.fileUrl,
fileKey: entity?.fileKey,
fileName: entity?.fileName,
}
})
const createMutation = trpc.music.create.useMutation({
onSuccess: (values) => {
setId(values?.id);
utils.music.list.invalidate();
},
onError: (e) => {
toast(e.message)
}
})
const updateMutation = trpc.music.update.useMutation({
onSuccess: (_) => {
utils.music.list.invalidate();
},
onError: (e) => {
toast(e.message)
}
})
const deleteMutation = trpc.music.delete.useMutation({
onSuccess: (_) => {
utils.music.list.invalidate();
},
onError: (e) => {
toast(e.message)
}
})
function onSubmit(values: z.infer<typeof createMusicInputSchema>) {
id ?
updateMutation.mutate(values) :
createMutation.mutate(values)
}
return (
<>
<Toaster />
<FormMutationContextProvider value={{
createMutation: createMutation,
updateMutation: updateMutation,
deleteMutation: deleteMutation
}}>
<FormScaffold form={form} onSubmit={onSubmit} title='Music' id={id} className={props.className}>
<TextInputFormField control={form.control} name='title' label='Title'/>
<TextInputFormField control={form.control} name='description' label='Description'/>
<div className="flex flex-col gap-1.5">
<Label>Audio File</Label>
<UploadDropzone
endpoint="musicUploader"
config={{mode: 'auto'}}
onUploadError={(e) => {
toast(e.message)
}}
onClientUploadComplete={(res) => {
console.log(res)
if (res[0]) {
form.setValue('fileKey',res[0].serverData.fileKey);
form.setValue('fileName',res[0].serverData.fileName);
form.setValue('title',res[0].serverData.fileName);
form.setValue('description',res[0].serverData.fileName);
form.setValue('fileUrl',res[0].serverData.fileUrl);
}
console.log(form.getValues());
}}
/>
</div>
</FormScaffold>
</FormMutationContextProvider>
</>
);
}

View File

@@ -0,0 +1,26 @@
'use client'
import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import UploadMusicForm from "./_components/UploadMusicForm";
import { CollapsibleForm } from "~/app/_components/Form/Components";
import { useEffect } from "react";
export default function AdminMusicPage() {
const { data: tracks } = trpc.music.list.useQuery();
useEffect(() => {console.log(tracks)}, [tracks])
return (
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
{tracks && <>
{tracks.map((t) => (
<Card.Card key={t.id}>
<Card.CardContent>
<UploadMusicForm entity={t} className="w-full"/>
</Card.CardContent>
</Card.Card>
))}
</>}
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createRouteHandler } from "uploadthing/next";
import { fileRouter } from "~/server/uploadthing";
export const { GET, POST } = createRouteHandler({
router: fileRouter,
});

View File

@@ -6,12 +6,13 @@ import { useRef } from "react";
import { SidebarContent, SidebarProvider, Sidebar } from "~/components/ui/sidebar";
import SidebarTriggerDisappearsOnMobile from "./_components/SidebarTriggerDisappearsOnMobile";
import CvCategory from "./_components/CvCategory";
import gsap from 'gsap'
export default function CvPage() {
const sidebarCategories = trpc.categoryv2.listByLayoutPosition.useQuery("sidebar");
const col1Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col1");
const headerCategories = trpc.categoryv2.listByLayoutPosition.useQuery("header");
const col2Categories = trpc.categoryv2.listByLayoutPosition.useQuery("col2");
const gsap = useGsapContext()
const gsapContext = useGsapContext()
const container = useRef<HTMLDivElement>(null)
enum Direction {
Left = 1,
@@ -32,11 +33,11 @@ export default function CvPage() {
}
}
useGSAP(() => {
gsapContext?.resetTimeline()
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
const tl = gsap?.timeline();
let dir = Direction.Left;
items?.forEach(item => {
tl?.from(item, nextGsapConf(dir))
gsapContext?.addAnimation(gsap.from(item, nextGsapConf(dir)))
if (dir == Direction.Down) {
dir = Direction.Left
} else {

View File

@@ -12,6 +12,7 @@ import ThemeProvider from './_providers/ThemeProvider'
import GsapProvider from "./_providers/GsapProvicer";
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
import { cn } from "~/lib/utils";
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
@@ -28,7 +29,6 @@ const geist = Geist({
variable: "--font-geist-sans",
});
export default async function RootLayout({
children,
modal
@@ -44,11 +44,13 @@ export default async function RootLayout({
</head>
<body className="flex flex-col bg-background text-foreground">
<ThemeProvider>
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
<TopNav />
<main className="absolute lg:top-10 h-screen w-screen">
{children}
</main>
{modal}
</AnimatedBackGroundContainer>
</ThemeProvider>
</body>
</html>

70
src/app/music/page.tsx Normal file
View File

@@ -0,0 +1,70 @@
'use client'
import { useGSAP } from "@gsap/react";
import { useRef } from "react";
import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import { useGsapContext } from "../_providers/GsapProvicer";
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
import { Spinner } from "~/components/ui/spinner";
import AnimateTextIn from "../_components/Animated/AnimateIn";
import gsap from 'gsap'
export default function MusicPage() {
const { data: tracks, isLoading } = trpc.music.list.useQuery();
const container = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext();
useGSAP(() => {
gsapContext?.resetTimeline()
const items = gsap.utils.toArray<HTMLElement>('.gsapan');
const tl = gsap.timeline();
items.map(item => {
const player = item.querySelector('.player');
tl.from(
item, { x: -100, opacity: 0, duration: 0.5, ease: 'power2.inOut', scrollTrigger: item },'<'
).from(
player, { y: 10, opacity: 0, duration: 0.5, ease: 'power2.inOut' }
, '<0.3')
gsapContext?.addAnimation(tl);
})
}, { scope: container, dependencies: [isLoading] });
return (<>
<div ref={container} className="w-full h-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
<AnimatedPageTitle text="Just Some Music I Made"/>
<AnimateTextIn>
<div className="flex flex-row h-8 content-center items-center">
<p className="mr-[1em]">All works on this page are licensed under:</p>
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a>
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt=""/>
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt=""/>
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt=""/>
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt=""/>
</div>
</AnimateTextIn>
{tracks && tracks.map((track) => (
<Card.Card key={track.id} className='gsapan'>
<Card.CardHeader>
<AnimateTextIn animation="slide">
<Card.CardTitle>{track.title}</Card.CardTitle>
</AnimateTextIn>
</Card.CardHeader>
<Card.CardContent className="flex flex-col gap-3">
{track.description && (
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
)}
<audio controls className="w-full player" src={track.fileUrl}>
Your browser does not support the audio element.
</audio>
</Card.CardContent>
</Card.Card>
))}
{!isLoading && !tracks?.length &&
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
No music yet.
</div>
}
{isLoading && <div className="w-full h-full items-center flex flex-row content-center gap-4 justify-center">
<Spinner/> Loading Tracks
</div>}
</div>
</>);
}