From 78a9378c7b9b2999bc84fe4e9d90ceffd20cae2d Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 21:45:16 +0100 Subject: [PATCH 01/29] Feat(main-layout): Hide Separator Before Playlists --- apps/web/app/(app)/main-layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(app)/main-layout.tsx b/apps/web/app/(app)/main-layout.tsx index 203ec9e3..a8ca01d8 100644 --- a/apps/web/app/(app)/main-layout.tsx +++ b/apps/web/app/(app)/main-layout.tsx @@ -39,7 +39,7 @@ export default function MainLayout({ children }: any) { - + {/* */} From 4f0774c78f9c64cc447f23b0289ada97dd5addea Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 21:58:34 +0100 Subject: [PATCH 02/29] Feat(ArtistComponent): Include Songs Count in AlbumCard --- apps/web/app/(app)/artist/ArtistComponent.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/(app)/artist/ArtistComponent.tsx b/apps/web/app/(app)/artist/ArtistComponent.tsx index 1ad8068d..49b13272 100644 --- a/apps/web/app/(app)/artist/ArtistComponent.tsx +++ b/apps/web/app/(app)/artist/ArtistComponent.tsx @@ -123,6 +123,7 @@ export default function ArtistComponent() { album_id={album.id} album_name={album.name} album_cover={album.cover_url} + album_songs_count={album.songs.length} first_release_date={album.first_release_date} /> From 0183c0f40892e4c4169775a5eb9d4677a9486ba4 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 21:59:24 +0100 Subject: [PATCH 03/29] Feat(explore/page): Include Album Songs Count for AlbumCard --- apps/web/app/(app)/explore/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/(app)/explore/page.tsx b/apps/web/app/(app)/explore/page.tsx index a60fa388..577e1249 100644 --- a/apps/web/app/(app)/explore/page.tsx +++ b/apps/web/app/(app)/explore/page.tsx @@ -105,6 +105,7 @@ export default function ExplorePage() { album_id={albumDetails[album.id]?.id ?? ""} album_name={albumDetails[album.id]?.name ?? ""} album_cover={albumDetails[album.id]?.cover_url ?? ""} + album_songs_count={Number(albumDetails[album.id]?.songs.length) ?? ""} first_release_date={albumDetails[album.id]?.first_release_date ?? ""} /> ) : null} From a6f2e50334d00dbab18326ff964aa1218d97d958 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 21:59:47 +0100 Subject: [PATCH 04/29] Feat(home/page): Custom Layout through LocalStorage --- apps/web/app/(app)/home/page.tsx | 165 +++++++++++++++++++------------ 1 file changed, 103 insertions(+), 62 deletions(-) diff --git a/apps/web/app/(app)/home/page.tsx b/apps/web/app/(app)/home/page.tsx index 2d67eb5f..37ce7a07 100644 --- a/apps/web/app/(app)/home/page.tsx +++ b/apps/web/app/(app)/home/page.tsx @@ -1,63 +1,104 @@ -"use client"; - -import FromYourLibrary from "@/components/Home/FromYourLibrary"; -import LandingCarousel from "@/components/Home/LandingCarousel"; -import ListenAgain from "@/components/Home/ListenAgain"; -import MusicVideos from "@/components/Home/MusicVideos"; -import RandomSongs from "@/components/Home/RandomSongs"; -import RecommendedAlbums from "@/components/Home/RecommendedAlbums"; -import SimilarTo from "@/components/Home/SimilarTo"; -import GenreButtons from "@/components/Layout/GenreButtons"; -import { useGradientHover } from "@/components/Providers/GradientHoverProvider"; -import { getConfig, hasConfig } from "@music/sdk"; -import { Button } from "@music/ui/components/button"; -import Link from "next/link"; -import { useEffect, useState } from "react"; - -export default function Home() { - let [configExists, setConfigExists] = useState(true); - - const { setGradient } = useGradientHover() - - useEffect(() => { - async function checkConfig() { - const config = await hasConfig(); - if (config) { - setConfigExists(true); - } else { - setConfigExists(false); - setGradient("#FFFFFF") - } - } - - checkConfig(); - }, [setGradient]); - - return configExists ? ( -
- -
- + "use client"; + + import FromYourLibrary from "@/components/Home/FromYourLibrary"; + import LandingCarousel from "@/components/Home/LandingCarousel"; + import ListenAgain from "@/components/Home/ListenAgain"; + import MusicVideos from "@/components/Home/MusicVideos"; + import RandomSongs from "@/components/Home/RandomSongs"; + import RecommendedAlbums from "@/components/Home/RecommendedAlbums"; + import SimilarTo from "@/components/Home/SimilarTo"; + import CustomiseFeed from "@/components/Layout/CustomiseFeed"; + import GenreButtons from "@/components/Layout/GenreButtons"; + import { useGradientHover } from "@/components/Providers/GradientHoverProvider"; + import { hasConfig } from "@music/sdk"; + import { Button } from "@music/ui/components/button"; + import Link from "next/link"; + import { useEffect, useState } from "react"; + import { useLayoutConfig } from "@/components/Providers/LayoutConfigContext"; + + export default function Home() { + const [configExists, setConfigExists] = useState(true); + const { components, setComponents } = useLayoutConfig(); + + const { setGradient } = useGradientHover(); + + useEffect(() => { + async function checkConfig() { + const config = await hasConfig(); + if (config) { + setConfigExists(true); + } else { + setConfigExists(false); + setGradient("#FFFFFF"); + } + } + + checkConfig(); + }, [setGradient]); + + useEffect(() => { + const savedConfig = localStorage.getItem("layoutConfig"); + if (savedConfig) { + setComponents(JSON.parse(savedConfig)); + } + }, [setComponents]); + + type ComponentConfig = { + id: string; + name: string; + visible: boolean; + pinned: boolean; + }; + + const renderComponent = (component: ComponentConfig): JSX.Element | null => { + if (!component.visible) return null; + switch (component.id) { + case "LandingCarousel": + return ; + case "ListenAgain": + return ; + case "SimilarTo": + return ; + case "RecommendedAlbums": + return ; + case "RandomSongs": + return ; + case "FromYourLibrary": + return ; + case "MusicVideos": + return ; + default: + return null; + } + }; + + return configExists ? ( +
+
+ +
+ + {components + .filter((component) => component.pinned) + .map(renderComponent)} + {components + .filter((component) => !component.pinned) + .map(renderComponent)} +
- - - - - - - -
- ) : ( - <> -
-

No Config Found!

-

- Head to the setup page to index your library -

- - - -
- - ); -} \ No newline at end of file + ) : ( +
+

No Config Found!

+

+ + Head to the setup page to index your library + +

+ + + +
+ ); + } \ No newline at end of file From f63fe49839944baa3d6561c87d69542322479cc2 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:02:08 +0100 Subject: [PATCH 05/29] Feat(UsernameComponent): Include Album Songs Count for Album Card --- apps/web/app/(app)/profile/UsernameComponent.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/(app)/profile/UsernameComponent.tsx b/apps/web/app/(app)/profile/UsernameComponent.tsx index 29d38cc5..11223342 100644 --- a/apps/web/app/(app)/profile/UsernameComponent.tsx +++ b/apps/web/app/(app)/profile/UsernameComponent.tsx @@ -155,6 +155,7 @@ export default function UsernameComponent() { album_id={album.id} album_name={album.name} album_cover={album.cover_url} + album_songs_count={album.songs.length} first_release_date={album.first_release_date} key={album.id} /> From ebc4cfab10168f92e6973db48ead7eefaced90cd Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:02:29 +0100 Subject: [PATCH 06/29] Feat(SongsInLibrary): Include ID for Custom Layout --- apps/web/components/Artist/SongsInLibrary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/Artist/SongsInLibrary.tsx b/apps/web/components/Artist/SongsInLibrary.tsx index ca2d1a2d..897f890f 100644 --- a/apps/web/components/Artist/SongsInLibrary.tsx +++ b/apps/web/components/Artist/SongsInLibrary.tsx @@ -48,7 +48,7 @@ export default function FromYourLibrary() { }, [id]); return librarySongs.length > 0 && ( - +
{librarySongs.map((song, index) => (
From 02da195d45ee3d060d3177a0a2b7fff6feb13d88 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:03:59 +0100 Subject: [PATCH 07/29] Feat(FromYourLibrary): Include ID for Custom Layout --- apps/web/components/Home/FromYourLibrary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/Home/FromYourLibrary.tsx b/apps/web/components/Home/FromYourLibrary.tsx index a1de5d92..2b81429f 100644 --- a/apps/web/components/Home/FromYourLibrary.tsx +++ b/apps/web/components/Home/FromYourLibrary.tsx @@ -71,7 +71,7 @@ export default function FromYourLibrary({ genre }: FromYourLibraryProps) { if (!librarySongs || librarySongs.length === 0) return null; return ( - +
{librarySongs.map((song, index) => (
From fba89ff1ef0df8ba081fc4d7a0691bab56267496 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:04:09 +0100 Subject: [PATCH 08/29] Feat(ListenAgain): Include ID for Custom Layout --- apps/web/components/Home/ListenAgain.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/components/Home/ListenAgain.tsx b/apps/web/components/Home/ListenAgain.tsx index 044373a3..4fbc3bc5 100644 --- a/apps/web/components/Home/ListenAgain.tsx +++ b/apps/web/components/Home/ListenAgain.tsx @@ -36,7 +36,7 @@ export default function ListenAgain({ genre }: ListenAgainProps) { return listenHistorySongs && ( <> - +
{listenHistorySongs.map((item, index) => { if (item.item_type === "album") { @@ -48,6 +48,7 @@ export default function ListenAgain({ genre }: ListenAgainProps) { album_id={item.album_id} album_name={item.album_name} album_cover={item.album_cover ?? ""} + album_songs_count={item.album_songs_count} first_release_date={item.release_date} />
From 0b571ef8df3600a5d4b79570f7a24c941abb7b94 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:04:19 +0100 Subject: [PATCH 09/29] Feat(MusicVideos): Include ID for Custom Layout --- apps/web/components/Home/MusicVideos.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/Home/MusicVideos.tsx b/apps/web/components/Home/MusicVideos.tsx index 72e9d411..bbe9f1ba 100644 --- a/apps/web/components/Home/MusicVideos.tsx +++ b/apps/web/components/Home/MusicVideos.tsx @@ -46,7 +46,7 @@ export default function MusicVideos() { if (loading) return null; return songs && ( - +
{songs.map((song) => ( From 9ac20d84fabd62a04db2ba0833328f0ebedb7851 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:04:39 +0100 Subject: [PATCH 10/29] Feat(RandomSongs): Include ID for Custom Layout --- apps/web/components/Home/RandomSongs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/Home/RandomSongs.tsx b/apps/web/components/Home/RandomSongs.tsx index 70f02c68..1f518ab4 100644 --- a/apps/web/components/Home/RandomSongs.tsx +++ b/apps/web/components/Home/RandomSongs.tsx @@ -48,7 +48,7 @@ export default function RandomSongs({ genre }: RandomSongsProps) { if (!randomSongs || randomSongs.length === 0) return null; return ( - +
{randomSongs.map((song, index) => (
From f10d98ffa369dc27ac51951d30f4febd9debbf4f Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:04:49 +0100 Subject: [PATCH 11/29] Feat(RecommendedAlbums): Include ID for Custom Layout --- apps/web/components/Home/RecommendedAlbums.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/components/Home/RecommendedAlbums.tsx b/apps/web/components/Home/RecommendedAlbums.tsx index e3cd50b3..396f8722 100644 --- a/apps/web/components/Home/RecommendedAlbums.tsx +++ b/apps/web/components/Home/RecommendedAlbums.tsx @@ -71,7 +71,7 @@ export default function RecommendedAlbums({ genre }: RecommendedAlbumsProps) { if (!librarySongs || librarySongs.length === 0) return null; return ( - +
{librarySongs.map((song, index) => (
@@ -79,6 +79,7 @@ export default function RecommendedAlbums({ genre }: RecommendedAlbumsProps) { album_cover={song.album_object.cover_url} album_id={song.album_object.id} album_name={song.album_object.name} + album_songs_count={song.album_object.songs.length} artist_id={song.artist_object.id} artist_name={song.artist} first_release_date={song.album_object.first_release_date} From 74c75cc7113669cc884be552d6b85058439c8232 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:05:11 +0100 Subject: [PATCH 12/29] Feat(ScrollButtons): Use ID to Optionally Render Options to Pin or Hide in Layout --- apps/web/components/Home/ScrollButtons.tsx | 78 ++++++++++++++++------ 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/apps/web/components/Home/ScrollButtons.tsx b/apps/web/components/Home/ScrollButtons.tsx index 4860b8f3..a64d5967 100644 --- a/apps/web/components/Home/ScrollButtons.tsx +++ b/apps/web/components/Home/ScrollButtons.tsx @@ -1,13 +1,16 @@ -"use client" +"use client"; import getSession from '@/lib/Authentication/JWT/getSession'; import { getProfilePicture } from '@music/sdk'; import { ScrollArea, ScrollBar } from '@music/ui/components/scroll-area'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Ellipsis, Pin, Eye, EyeOff } from 'lucide-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import Image from 'next/image'; +import { Popover, PopoverContent, PopoverTrigger } from '@music/ui/components/popover'; +import { useLayoutConfig } from '../Providers/LayoutConfigContext'; type ScrollButtonsProps = { + id?: string; children: React.ReactNode; heading: string; showUser?: boolean; @@ -15,13 +18,14 @@ type ScrollButtonsProps = { imageUrl?: string; }; -export default function ScrollButtons({ children, heading, showUser, topText, imageUrl }: ScrollButtonsProps) { +export default function ScrollButtons({ id, children, heading, showUser, topText, imageUrl }: ScrollButtonsProps) { const scrollRef = useRef(null); const [isAtStart, setIsAtStart] = useState(true); const [isAtEnd, setIsAtEnd] = useState(false); const [canScroll, setCanScroll] = useState(false); const [profilePicture, setProfilePicture] = useState(null); const [username, setUsername] = useState(null); + const { components, setComponents } = useLayoutConfig(); // Use the context const checkScrollPosition = useCallback(() => { if (scrollRef.current) { @@ -34,7 +38,7 @@ export default function ScrollButtons({ children, heading, showUser, topText, im useEffect(() => { const fetchUserData = async () => { - const session = await getSession(); + const session = getSession(); const profilePictureBlob = await getProfilePicture(Number(session?.sub)); const profilePictureUrl = URL.createObjectURL(profilePictureBlob); setProfilePicture(profilePictureUrl); @@ -75,11 +79,30 @@ export default function ScrollButtons({ children, heading, showUser, topText, im } }, []); + const handlePinToggle = () => { + const updatedComponents = components.map((component) => + component.id === id ? { ...component, pinned: !component.pinned } : component + ); + const pinnedComponents = updatedComponents.filter((component) => component.pinned); + const unpinnedComponents = updatedComponents.filter((component) => !component.pinned); + const newComponents = [...pinnedComponents, ...unpinnedComponents]; + setComponents(newComponents); + }; + + const handleVisibilityToggle = () => { + const updatedComponents = components.map((component) => + component.id === id ? { ...component, visible: !component.visible } : component + ); + setComponents(updatedComponents); + }; + + const currentComponent = components.find((component) => component.id === id); + return ( <>
- { (showUser || imageUrl) && + {(showUser || imageUrl) && Profile
- {false ? ( - - ) : ( -
+ {id && ( + + + + + +
+ + +
+
+
)} + {component.name} +
+
+ + + {component.visible ? ( + handleVisibilityToggle(component.id)} /> + ) : ( + handleVisibilityToggle(component.id)} /> + )} +
+
+ + ))} +
+ + + + ); +} \ No newline at end of file From f21559c5cd93ae30d1ef78ad0d5b66c3df1c0e5f Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:09:28 +0100 Subject: [PATCH 15/29] Feat(GenreButtons): Lower Width to fit Layout Button --- apps/web/components/Layout/GenreButtons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/Layout/GenreButtons.tsx b/apps/web/components/Layout/GenreButtons.tsx index 8393e5f9..89a86f71 100644 --- a/apps/web/components/Layout/GenreButtons.tsx +++ b/apps/web/components/Layout/GenreButtons.tsx @@ -36,7 +36,7 @@ export default function GenreButtons({ children }: GenreButtonsProps) { return ( <> - +
{genres.map((genre, index) => (
@@ -180,6 +181,7 @@ export default function LibraryButtons() { album_id={item.data.album_object.id} album_name={item.data.album_object.name} album_cover={item.data.album_object.cover_url} + album_songs_count={item.data.album_object.songs.length} first_release_date={item.data.album_object.first_release_date} /> ) : item.data && From 22b22080a25a6ed81a1cd4673c62cae33d68324a Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:12:26 +0100 Subject: [PATCH 17/29] PKG(package.json): Add Tone & Sortable --- apps/web/package.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 9484e33a..ce448b74 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,8 +6,8 @@ "author": "ParsonLabs", "scripts": { "dev": "next dev", - "electron": "cross-env NEXT_PUBLIC_BASE_URL=http://localhost:3001 concurrently -n \"NEXT,ELECTRON\" -c \"yellow,blue\" --kill-others \"next dev\" \"electron .\"", - "electron-build": "cross-env NEXT_PUBLIC_BASE_URL=http://localhost:3001 next build && electron-builder", + "electron": "concurrently -n \"NEXT,ELECTRON\" -c \"yellow,blue\" --kill-others \"next dev\" \"electron .\"", + "electron-build": "next build && electron-builder", "tauri": "tauri dev", "tauri-build": "tauri build", "type-check": "tsc --pretty --noEmit", @@ -23,9 +23,7 @@ "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-slider": "^1.2.0", - "@radix-ui/react-slot": "^1.1.0", - "@types/crypto-js": "^4.2.2", + "@dnd-kit/sortable": "8.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "crypto-js": "^4.2.0", @@ -42,6 +40,7 @@ "react-hook-form": "^7.52.1", "react-tweet": "3.2.1", "jwt-decode": "4.0.0", + "tone": "15.0.4", "electron-serve": "2.0.0", "sharp": "^0.32.6", "sonner": "^1.5.0", From 1c561cbe1fab91a977a876c6908e863f84060b77 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:12:43 +0100 Subject: [PATCH 18/29] Feat(Playlists): Change into Accordion --- apps/web/components/Layout/Playlists.tsx | 34 +++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/web/components/Layout/Playlists.tsx b/apps/web/components/Layout/Playlists.tsx index 4110dd87..9d22dacb 100644 --- a/apps/web/components/Layout/Playlists.tsx +++ b/apps/web/components/Layout/Playlists.tsx @@ -8,6 +8,7 @@ import { ListMusic, Plus } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import CreatePlaylistDialog from "../Music/Playlist/CreatePlaylistDialog"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@music/ui/components/accordion" interface Playlist extends OriginalPlaylist { users: string[]; @@ -46,17 +47,26 @@ export default function Playlists() { return (
-

Playlists

- {playlists?.map(playlist => ( -
- - -

{playlist.name}

-

Playlist • {playlist.users.join(", ")}

- -
- ))} - + + + + + Playlists + + + {playlists?.map(playlist => ( +
+ + +

{playlist.name}

+

Playlist • {playlist.users.join(", ")}

+ +
+ ))} +
+
+
+
- ) + ); } \ No newline at end of file From f3e00ccd53816584c4b390df95f2c264335e3fc1 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:13:09 +0100 Subject: [PATCH 19/29] Feat(Player): Add Features --- apps/web/components/Music/Player.tsx | 204 +++++++++++++++++++++++---- 1 file changed, 177 insertions(+), 27 deletions(-) diff --git a/apps/web/components/Music/Player.tsx b/apps/web/components/Music/Player.tsx index 5e7a8d95..6acd6a01 100644 --- a/apps/web/components/Music/Player.tsx +++ b/apps/web/components/Music/Player.tsx @@ -1,5 +1,7 @@ "use client"; +import React, { useState, useEffect, useRef, useContext } from "react"; +import * as Tone from "tone"; import getSession from "@/lib/Authentication/JWT/getSession"; import getBaseURL from "@/lib/Server/getBaseURL"; import { @@ -8,11 +10,11 @@ import { SliderThumb, SliderTrack, } from "@music/ui/components/slider"; +import { Popover, PopoverContent, PopoverTrigger } from "@music/ui/components/popover"; import { FastAverageColor } from "fast-average-color"; import { AudioLines, BookAudioIcon, MicVocal, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { useContext, useEffect, useRef, useState } from "react"; import { AIContext } from "../AI/AIOverlayContext"; import ArrowPath from "../Icons/ArrowPath"; import IconQueue from "../Icons/IconQueue"; @@ -24,10 +26,10 @@ import { useReverb } from "../Providers/SlowedReverbProvider"; import { usePlayer } from "./Player/usePlayer"; import VideoPlayerDialog from "./Player/VideoPlayerDialog"; import { PanelContext } from "./Queue/QueuePanelContext"; +import { Input } from "@music/ui/components/input"; +import { Label } from "@radix-ui/react-label"; export default function Player() { - const audioRef = useRef(null); - const [liked, setLiked] = useState(false); const { reverb, setReverb } = useReverb(); @@ -65,16 +67,18 @@ export default function Player() { const [isReverbClicked, setIsReverbClicked] = useState(false); const [isSpeakerHovered, setIsSpeakerHovered] = useState(false); - const formatTime = (time: number) => { - const minutes = Math.floor(time / 60); - const seconds = Math.floor(time % 60); - return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; - }; + const [slowed, setSlowed] = useState(1); + const [reverbEffectValue, setReverbEffectValue] = useState(0); + const [pitch, setPitch] = useState(0); + const player = useRef(null); + const reverbEffect = useRef(null); + const pitchShift = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { - if (isPlaying) document.title = `${song.name} | ParsonLabs Music`; - if (!isPlaying) document.title = `ParsonLabs Music`; - }, [song, artist, isPlaying]); + reverbEffect.current = new Tone.Reverb().toDestination(); + pitchShift.current = new Tone.PitchShift().toDestination(); + }, []); useEffect(() => { const session = getSession(); @@ -82,23 +86,110 @@ export default function Player() { if (reverb) { songURL += "&slowed_reverb=true"; } - setAudioSource(songURL); + + setAudioSource(songURL) + // player.current = new Tone.Player({ + // url: songURL, + // onload: () => { + // player.current?.connect(pitchShift.current!); + // pitchShift.current?.connect(reverbEffect.current!); + // setIsLoaded(true); + // console.log('Player loaded'); + // }, + // onerror: (error) => { + // console.error('Error loading player:', error); + // } + // }).toDestination(); }, [reverb, song, setAudioSource]); + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const url = URL.createObjectURL(file); + player.current = new Tone.Player({ + url, + onload: () => { + setIsLoaded(true); + console.log('File loaded'); + }, + onerror: (error) => { + console.error('Error loading file:', error); + } + }).connect(pitchShift.current!); + pitchShift.current?.connect(reverbEffect.current!); + } + }; + + const handleSlowedChange = (event: React.ChangeEvent) => { + const value = parseFloat(event.target.value); + setSlowed(value); + if (player.current) { + player.current.playbackRate = value; + } + }; + + const handleReverbChange = (event: React.ChangeEvent) => { + const value = parseFloat(event.target.value); + setReverbEffectValue(value); + if (reverbEffect.current) { + reverbEffect.current.decay = value; + } + }; + + const handlePitchChange = (event: React.ChangeEvent) => { + const value = parseFloat(event.target.value); + setPitch(value); + if (pitchShift.current) { + pitchShift.current.pitch = value; + } + }; + + const handleClick = () => { + console.log('handleClick called'); + if (Tone.context.state !== 'running') { + Tone.context.resume(); + } + if (player.current) { + console.log('Player is initialized'); + if (isLoaded) { + console.log('Player is loaded'); + if (player.current.state !== 'started') { + console.log('Starting player'); + player.current.start(); + } else { + console.log('Stopping player'); + player.current.stop(); + } + } else { + console.log('Player is not loaded yet'); + } + } else { + console.log('Player is not initialized'); + } + }; + const debounce = (func: Function, wait: number) => { let timeout: NodeJS.Timeout; return (...args: any[]) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; - }; + } const debouncedTogglePlayPause = debounce(togglePlayPause, 300); + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; + }; + return song.id && (
-
+
{song.name
-
+

30 && "whitespace-nowrap"} ${song.name.length > 30 ? "md:animate-marquee" : ""}`} title={song.name.length > 30 ? song.name : ""} > - {song.name} + {song.name}

- {artist.name} + {artist.name} + {song.contributing_artists.map((artist, index) => ( + + , {artist} + + ))}

@@ -228,7 +324,7 @@ export default function Player() { )} - - + {/* */} + {/* */} + + {/* */} + {/* */} + {/*
+
+

Audio Effects

+

+ Adjust the audio effects for the current track. +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
*/}
-
); } \ No newline at end of file From ee50c3b17a51c38aa33271a62956d8153303f705 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:13:43 +0100 Subject: [PATCH 20/29] Feat(AlbumCard): Take Song Count Prop & Show Dominant Colour on Top --- .../components/Music/Card/Album/AlbumCard.tsx | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/web/components/Music/Card/Album/AlbumCard.tsx b/apps/web/components/Music/Card/Album/AlbumCard.tsx index 12454735..cfdd6013 100644 --- a/apps/web/components/Music/Card/Album/AlbumCard.tsx +++ b/apps/web/components/Music/Card/Album/AlbumCard.tsx @@ -5,6 +5,7 @@ import getBaseURL from "@/lib/Server/getBaseURL"; import { FastAverageColor } from "fast-average-color"; import Image from "next/image"; import Link from "next/link"; +import { useState, useEffect } from "react"; type AlbumCardProps = { artist_id: string; @@ -12,6 +13,7 @@ type AlbumCardProps = { album_id: string; album_name: string; album_cover: string; + album_songs_count: number; first_release_date: string; }; @@ -21,29 +23,46 @@ export default function AlbumCard({ album_id, album_name, album_cover, + album_songs_count, first_release_date, }: AlbumCardProps) { const albumCoverURL = (!album_cover || album_cover.length === 0) ? "/snf.png" : `${getBaseURL()}/image/${encodeURIComponent(album_cover)}`; let releaseDate = new Date(first_release_date).toLocaleString('default', { year: 'numeric' }); const { setGradient } = useGradientHover(); + const [dominantColor, setDominantColor] = useState(null); - function setDominantGradient() { + useEffect(() => { const fac = new FastAverageColor(); const getColor = async () => { const color = await fac.getColorAsync(albumCoverURL); - setGradient(color.hex); + setDominantColor(color.hex); }; getColor(); + }, [albumCoverURL]); + + function handleMouseEnter() { + if (dominantColor) { + setGradient(dominantColor); + } } return ( -
+
+ {dominantColor && ( +
+ )} - {`${album_name} -

{album_name}

+ {`${album_name} +
+

{album_name}

+

{album_songs_count}

+
- +

{artist_name} • {releaseDate} From 1bec2de81062e787e7171f395ad7f366a3ee4e6b Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:13:58 +0100 Subject: [PATCH 21/29] Feat(TopResultsCard): Take Songs Count for Album Card --- apps/web/components/Music/Card/Search/TopResultsCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/components/Music/Card/Search/TopResultsCard.tsx b/apps/web/components/Music/Card/Search/TopResultsCard.tsx index 52256154..83cc7c37 100644 --- a/apps/web/components/Music/Card/Search/TopResultsCard.tsx +++ b/apps/web/components/Music/Card/Search/TopResultsCard.tsx @@ -137,6 +137,7 @@ const coverUrl = result.item_type === "song" album_id={album.id} album_name={album.name} album_cover={album.cover_url} + album_songs_count={album.songs.length} first_release_date={album.first_release_date} />

From ab5360010fdc162997f32da9e4bf388f66f970c8 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:14:21 +0100 Subject: [PATCH 22/29] Feat(LayoutConfigContext): Globally Share Layout Config & Changes --- .../Providers/LayoutConfigContext.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 apps/web/components/Providers/LayoutConfigContext.tsx diff --git a/apps/web/components/Providers/LayoutConfigContext.tsx b/apps/web/components/Providers/LayoutConfigContext.tsx new file mode 100644 index 00000000..ac8962a5 --- /dev/null +++ b/apps/web/components/Providers/LayoutConfigContext.tsx @@ -0,0 +1,64 @@ +"use client" + +import React, { createContext, useContext, useState, useEffect } from 'react'; + +type ComponentConfig = { + id: string; + name: string; + visible: boolean; + pinned: boolean; +}; + +type LayoutConfigContextType = { + components: ComponentConfig[]; + setComponents: React.Dispatch>; +}; + +const LayoutConfigContext = createContext(undefined); + +export const LayoutConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [components, setComponents] = useState([ + { id: "LandingCarousel", name: "Landing Carousel", visible: true, pinned: false }, + { id: "ListenAgain", name: "Listen Again", visible: true, pinned: false }, + { id: "SimilarTo", name: "Similar To", visible: true, pinned: false }, + { id: "RecommendedAlbums", name: "Recommended Albums", visible: true, pinned: false }, + { id: "RandomSongs", name: "Random Songs", visible: true, pinned: false }, + { id: "FromYourLibrary", name: "From Your Library", visible: true, pinned: false }, + { id: "MusicVideos", name: "Music Videos", visible: true, pinned: false }, + ]); + + useEffect(() => { + const savedConfig = localStorage.getItem("layoutConfig"); + if (savedConfig) { + setComponents(JSON.parse(savedConfig)); + } else { + setComponents([ + { id: "LandingCarousel", name: "Landing Carousel", visible: true, pinned: false }, + { id: "ListenAgain", name: "Listen Again", visible: true, pinned: false }, + { id: "SimilarTo", name: "Similar To", visible: true, pinned: false }, + { id: "RecommendedAlbums", name: "Recommended Albums", visible: true, pinned: false }, + { id: "RandomSongs", name: "Random Songs", visible: true, pinned: false }, + { id: "FromYourLibrary", name: "From Your Library", visible: true, pinned: false }, + { id: "MusicVideos", name: "Music Videos", visible: true, pinned: false }, + ]); + } + }, []); + + useEffect(() => { + localStorage.setItem("layoutConfig", JSON.stringify(components)); + }, [components]); + + return ( + + {children} + + ); +}; + +export const useLayoutConfig = () => { + const context = useContext(LayoutConfigContext); + if (!context) { + throw new Error('useLayoutConfig must be used within a LayoutConfigProvider'); + } + return context; +}; \ No newline at end of file From 2544dacce4ace66fd73ad0a162e6d03ed4fa29eb Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:14:35 +0100 Subject: [PATCH 23/29] Feat(Providers): Include Layout Config Provider --- apps/web/components/Providers/Providers.tsx | 37 +++++++++++---------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/web/components/Providers/Providers.tsx b/apps/web/components/Providers/Providers.tsx index 5331e165..3a3841a8 100644 --- a/apps/web/components/Providers/Providers.tsx +++ b/apps/web/components/Providers/Providers.tsx @@ -8,6 +8,7 @@ import { ScrollProvider } from './ScrollProvider'; import SidebarProvider from './SideBarProvider'; import { ThemeProvider } from './ThemeProvider'; import { SlowedReverbProvider } from './SlowedReverbProvider'; +import { LayoutConfigProvider } from './LayoutConfigContext'; interface ProvidersProps { children: ReactNode; @@ -16,23 +17,25 @@ interface ProvidersProps { export default function Providers({ children }: ProvidersProps) { return ( - - - - - - - - - {children} - - - - - - - - + + + + + + + + + + {children} + + + + + + + + + ); } \ No newline at end of file From c65f36cba722b5e5398ed526464bac3064a532db Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:15:00 +0100 Subject: [PATCH 24/29] Feat(web.rs): Include Album Songs Count --- crates/backend/src/routes/web.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/backend/src/routes/web.rs b/crates/backend/src/routes/web.rs index 615320fc..94307f2e 100644 --- a/crates/backend/src/routes/web.rs +++ b/crates/backend/src/routes/web.rs @@ -23,6 +23,7 @@ struct SongInfo { album_id: String, album_name: String, album_cover: String, + album_songs_count: usize, release_date: String, item_type: String, } @@ -34,6 +35,7 @@ struct AlbumCardProps { album_id: String, album_name: String, album_cover: String, + album_songs_count: usize, first_release_date: String, } @@ -54,6 +56,7 @@ async fn find_song_info_min(song_id: String) -> Result { album_name: album.name.clone(), album_cover: album.cover_url.clone(), release_date: album.first_release_date.clone(), + album_songs_count: album.songs.len().clone(), item_type: "song".to_string(), }); } @@ -112,6 +115,7 @@ async fn fetch_listen_history_songs(user_id: u32) -> Result, ()> { album_id: album_id.clone(), album_name: songs[0].album_name.clone(), album_cover: songs[0].album_cover.clone(), + album_songs_count: songs[0].album_songs_count.clone(), release_date: songs[0].release_date.clone(), item_type: "album".to_string(), }); @@ -165,6 +169,7 @@ async fn fetch_similar_albums(user_id: u32) -> Result<(Vec, Stri album_id: album.id, album_name: album.name, album_cover: album.cover_url, + album_songs_count: album.songs.len(), first_release_date: album.first_release_date, } }).collect(); From d3a9c91c42de2bf2ee2f850f0b0914832d65b025 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:15:25 +0100 Subject: [PATCH 25/29] Feat(web.ts): Include Album Songs Count in Type Definitions --- packages/music-sdk/src/lib/web.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/music-sdk/src/lib/web.ts b/packages/music-sdk/src/lib/web.ts index ab8d2f53..3b45224e 100644 --- a/packages/music-sdk/src/lib/web.ts +++ b/packages/music-sdk/src/lib/web.ts @@ -9,6 +9,7 @@ export interface SongInfo { album_id: string; album_name: string; album_cover: string; + album_songs_count: number; release_date: string; item_type: 'song' | 'album'; } @@ -19,6 +20,7 @@ export interface AlbumCardProps { album_id: string; album_name: string; album_cover: string; + album_songs_count: number, first_release_date: string; } From 092eb19b0a847cbe9161bf898683237863ae18f9 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:15:39 +0100 Subject: [PATCH 26/29] UI(accordion): Include Accordion --- packages/ui/src/components/accordion.tsx | 60 ++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/ui/src/components/accordion.tsx diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx new file mode 100644 index 00000000..417c96c5 --- /dev/null +++ b/packages/ui/src/components/accordion.tsx @@ -0,0 +1,60 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "../lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + +
+ {children} +
+ +
+
+)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } \ No newline at end of file From 9c30a5d4907c9acfaceaf521ec2b506c443f73eb Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:15:53 +0100 Subject: [PATCH 27/29] UI(popover): Include Popover --- packages/ui/src/components/popover.tsx | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/ui/src/components/popover.tsx diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx new file mode 100644 index 00000000..6580309e --- /dev/null +++ b/packages/ui/src/components/popover.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "../lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } From 58a8d6029ecc80be1ffdd9eb82d8eed764e71d4e Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Fri, 27 Sep 2024 22:16:10 +0100 Subject: [PATCH 28/29] UI(sortable): Include Sortable --- packages/ui/src/components/sortable.tsx | 371 ++++++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 packages/ui/src/components/sortable.tsx diff --git a/packages/ui/src/components/sortable.tsx b/packages/ui/src/components/sortable.tsx new file mode 100644 index 00000000..611d950f --- /dev/null +++ b/packages/ui/src/components/sortable.tsx @@ -0,0 +1,371 @@ +"use client" + +import * as React from "react" +import type { + DndContextProps, + DraggableSyntheticListeners, + DropAnimation, + UniqueIdentifier, +} from "@dnd-kit/core" +import { + closestCenter, + defaultDropAnimationSideEffects, + DndContext, + DragOverlay, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + restrictToHorizontalAxis, + restrictToParentElement, + restrictToVerticalAxis, +} from "@dnd-kit/modifiers" +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + useSortable, + verticalListSortingStrategy, + type SortableContextProps, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { Slot, type SlotProps } from "@radix-ui/react-slot" + +import { cn } from "../lib/utils" +import { Button, type ButtonProps } from "./button" + +// @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx + +type PossibleRef = React.Ref | undefined + +/** + * Set a given ref to a given value + * This utility takes care of different types of refs: callback refs and RefObject(s) + */ +function setRef(ref: PossibleRef, value: T) { + if (typeof ref === "function") { + ref(value) + } else if (ref !== null && ref !== undefined) { + ;(ref as React.MutableRefObject).current = value + } +} + +/** + * A utility to compose multiple refs together + * Accepts callback refs and RefObject(s) + */ +function composeRefs(...refs: PossibleRef[]) { + return (node: T) => refs.forEach((ref) => setRef(ref, node)) +} + +/** + * A custom hook that composes multiple refs + * Accepts callback refs and RefObject(s) + */ +function useComposedRefs(...refs: PossibleRef[]) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return React.useCallback(composeRefs(...refs), refs) +} + +export { composeRefs, useComposedRefs } + + +const orientationConfig = { + vertical: { + modifiers: [restrictToVerticalAxis, restrictToParentElement], + strategy: verticalListSortingStrategy, + }, + horizontal: { + modifiers: [restrictToHorizontalAxis, restrictToParentElement], + strategy: horizontalListSortingStrategy, + }, + mixed: { + modifiers: [restrictToParentElement], + strategy: undefined, + }, +} + +interface SortableProps + extends DndContextProps { + /** + * An array of data items that the sortable component will render. + * @example + * value={[ + * { id: 1, name: 'Item 1' }, + * { id: 2, name: 'Item 2' }, + * ]} + */ + value: TData[] + + /** + * An optional callback function that is called when the order of the data items changes. + * It receives the new array of items as its argument. + * @example + * onValueChange={(items) => console.log(items)} + */ + onValueChange?: (items: TData[]) => void + + /** + * An optional callback function that is called when an item is moved. + * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item. + * This will override the default behavior of updating the order of the data items. + * @type (event: { activeIndex: number; overIndex: number }) => void + * @example + * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)} + */ + onMove?: (event: { activeIndex: number; overIndex: number }) => void + + /** + * A collision detection strategy that will be used to determine the closest sortable item. + * @default closestCenter + * @type DndContextProps["collisionDetection"] + */ + collisionDetection?: DndContextProps["collisionDetection"] + + /** + * An array of modifiers that will be used to modify the behavior of the sortable component. + * @default + * [restrictToVerticalAxis, restrictToParentElement] + * @type Modifier[] + */ + modifiers?: DndContextProps["modifiers"] + + /** + * A sorting strategy that will be used to determine the new order of the data items. + * @default verticalListSortingStrategy + * @type SortableContextProps["strategy"] + */ + strategy?: SortableContextProps["strategy"] + + /** + * Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both". + * @default "vertical" + * @type "vertical" | "horizontal" | "mixed" + */ + orientation?: "vertical" | "horizontal" | "mixed" + + /** + * An optional React node that is rendered on top of the sortable component. + * It can be used to display additional information or controls. + * @default null + * @type React.ReactNode | null + * @example + * overlay={} + */ + overlay?: React.ReactNode | null +} + +function Sortable({ + value, + onValueChange, + collisionDetection = closestCenter, + modifiers, + strategy, + onMove, + orientation = "vertical", + overlay, + children, + ...props +}: SortableProps) { + const [activeId, setActiveId] = React.useState(null) + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor) + ) + + const config = orientationConfig[orientation] + + return ( + setActiveId(active.id)} + onDragEnd={({ active, over }) => { + if (over && active.id !== over?.id) { + const activeIndex = value.findIndex((item) => item.id === active.id) + const overIndex = value.findIndex((item) => item.id === over.id) + + if (onMove) { + onMove({ activeIndex, overIndex }) + } else { + onValueChange?.(arrayMove(value, activeIndex, overIndex)) + } + } + setActiveId(null) + }} + onDragCancel={() => setActiveId(null)} + collisionDetection={collisionDetection} + {...props} + > + + {children} + + {overlay ? ( + {overlay} + ) : null} + + ) +} + +const dropAnimationOpts: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + +interface SortableOverlayProps + extends React.ComponentPropsWithRef { + activeId?: UniqueIdentifier | null +} + +const SortableOverlay = React.forwardRef( + ( + { activeId, dropAnimation = dropAnimationOpts, children, ...props }, + ref + ) => { + return ( + + {activeId ? ( + + {children} + + ) : null} + + ) + } +) +SortableOverlay.displayName = "SortableOverlay" + +interface SortableItemContextProps { + attributes: React.HTMLAttributes + listeners: DraggableSyntheticListeners | undefined + isDragging?: boolean +} + +const SortableItemContext = React.createContext({ + attributes: {}, + listeners: undefined, + isDragging: false, +}) + +function useSortableItem() { + const context = React.useContext(SortableItemContext) + + if (!context) { + throw new Error("useSortableItem must be used within a SortableItem") + } + + return context +} + +interface SortableItemProps extends SlotProps { + /** + * The unique identifier of the item. + * @example "item-1" + * @type UniqueIdentifier + */ + value: UniqueIdentifier + + /** + * Specifies whether the item should act as a trigger for the drag-and-drop action. + * @default false + * @type boolean | undefined + */ + asTrigger?: boolean + + /** + * Merges the item's props into its immediate child. + * @default false + * @type boolean | undefined + */ + asChild?: boolean +} + +const SortableItem = React.forwardRef( + ({ value, asTrigger, asChild, className, ...props }, ref) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: value }) + + const context = React.useMemo( + () => ({ + attributes, + listeners, + isDragging, + }), + [attributes, listeners, isDragging] + ) + const style: React.CSSProperties = { + opacity: isDragging ? 0.5 : 1, + transform: CSS.Translate.toString(transform), + transition, + } + + const Comp = asChild ? Slot : "div" + + return ( + + )} + style={style} + {...(asTrigger ? attributes : {})} + {...(asTrigger ? listeners : {})} + {...props} + /> + + ) + } +) +SortableItem.displayName = "SortableItem" + +interface SortableDragHandleProps extends ButtonProps { + withHandle?: boolean +} + +const SortableDragHandle = React.forwardRef< + HTMLButtonElement, + SortableDragHandleProps +>(({ className, ...props }, ref) => { + const { attributes, listeners, isDragging } = useSortableItem() + + return ( +