forked from Qortal/ear-bump
update #1
@@ -1,6 +1,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { useIframe } from './hooks/useIframe'
|
||||||
import { StoreList } from "./pages/StoreList/StoreList";
|
import { StoreList } from "./pages/StoreList/StoreList";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import { CssBaseline } from "@mui/material";
|
import { CssBaseline } from "@mui/material";
|
||||||
@@ -26,6 +27,7 @@ function App() {
|
|||||||
// const themeColor = window._qdnTheme
|
// const themeColor = window._qdnTheme
|
||||||
|
|
||||||
const [theme, setTheme] = useState("dark");
|
const [theme, setTheme] = useState("dark");
|
||||||
|
useIframe()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
|
||||||
import { TbPlaylist } from "react-icons/tb";
|
import { TbPlaylist } from "react-icons/tb";
|
||||||
import { AiOutlinePlus } from "react-icons/ai";
|
import { AiOutlinePlus } from "react-icons/ai";
|
||||||
|
import { BsFolderPlus } from "react-icons/bs";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import MediaItem from "./MediaItem";
|
import MediaItem from "./MediaItem";
|
||||||
import { Song } from "../types";
|
import { Song } from "../types";
|
||||||
import useUploadModal from "../hooks/useUploadModal";
|
import useUploadModal from "../hooks/useUploadModal";
|
||||||
import useUploadPlaylistModal from "../hooks/useUploadPlaylistModal";
|
import useUploadPlaylistModal from "../hooks/useUploadPlaylistModal";
|
||||||
|
import useUploadFolderModal from "../hooks/useUploadFolderModal";
|
||||||
import useOnPlay from "../hooks/useOnPlay";
|
import useOnPlay from "../hooks/useOnPlay";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
@@ -30,6 +32,7 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
|||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const uploadModal = useUploadModal();
|
const uploadModal = useUploadModal();
|
||||||
const uploadPlaylistModal = useUploadPlaylistModal()
|
const uploadPlaylistModal = useUploadPlaylistModal()
|
||||||
|
const uploadFolderModal = useUploadFolderModal()
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (!username) {
|
if (!username) {
|
||||||
@@ -48,26 +51,28 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
|||||||
songs: [],
|
songs: [],
|
||||||
image: null
|
image: null
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClickFolder = () => {
|
||||||
|
if (!username) {
|
||||||
|
toast.error('Please authenticate')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploadFolderModal.onOpen();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center justify-between px-5 pt-4">
|
<div onClick={onClick} className="flex items-center justify-between px-5 pt-4">
|
||||||
<div className="inline-flex items-center gap-x-2">
|
<div className="inline-flex items-center gap-x-2">
|
||||||
<BsMusicNote className="text-neutral-400" size={26} />
|
<BsMusicNote className="text-neutral-400 cursor-pointer hover:text-white transition" size={26} />
|
||||||
<p className="text-neutral-400 font-medium text-md">
|
<p className="text-neutral-400 cursor-pointer hover:text-white transition font-medium text-md">
|
||||||
Add Song
|
Add Song
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AiOutlinePlus
|
<AiOutlinePlus
|
||||||
onClick={onClick}
|
|
||||||
size={20}
|
size={20}
|
||||||
className="
|
className="
|
||||||
text-neutral-400
|
text-neutral-400
|
||||||
@@ -77,15 +82,14 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-5 pt-4">
|
<div onClick={onClickPlaylist} className="flex items-center justify-between px-5 pt-4">
|
||||||
<div className="inline-flex items-center gap-x-2">
|
<div className="inline-flex items-center gap-x-2">
|
||||||
<BsMusicNoteList className="text-neutral-400" size={26} />
|
<BsMusicNoteList className="text-neutral-400 cursor-pointer hover:text-white transition" size={26} />
|
||||||
<p className="text-neutral-400 font-medium text-md">
|
<p className="text-neutral-400 cursor-pointer hover:text-white transition font-medium text-md">
|
||||||
Add Playlist
|
Add Playlist
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AiOutlinePlus
|
<AiOutlinePlus
|
||||||
onClick={onClickPlaylist}
|
|
||||||
size={20}
|
size={20}
|
||||||
className="
|
className="
|
||||||
text-neutral-400
|
text-neutral-400
|
||||||
@@ -96,6 +100,15 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div onClick={onClickFolder} className="flex items-center justify-between px-5 pt-4">
|
||||||
|
<div className="inline-flex items-center gap-x-2">
|
||||||
|
<BsFolderPlus className="text-neutral-400 cursor-pointer hover:text-white transition" size={26} />
|
||||||
|
<p className="text-neutral-400 cursor-pointer hover:text-white transition font-medium text-md">
|
||||||
|
Add Folder
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AiOutlinePlus className="text-neutral-400 cursor-pointer hover:text-white transition" size={20}/>
|
||||||
|
</div>
|
||||||
{newPlaylist && (
|
{newPlaylist && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="bg-red-500 fixed top-10 right-5 p-3 flex flex-col space-y-2">
|
<div className="bg-red-500 fixed top-10 right-5 p-3 flex flex-col space-y-2">
|
||||||
@@ -107,7 +120,7 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
|||||||
className="bg-blue-500 text-white px-4 py-2 rounded"
|
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||||
onClick={() => { uploadPlaylistModal.onOpen() }}
|
onClick={() => { uploadPlaylistModal.onOpen() }}
|
||||||
>
|
>
|
||||||
Save Playlist
|
Edit Playlist
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="bg-gray-300 text-black px-2 py-1 rounded text-sm"
|
className="bg-gray-300 text-black px-2 py-1 rounded text-sm"
|
||||||
|
|||||||
+55
-19
@@ -8,8 +8,9 @@ import { BiSearch } from "react-icons/bi";
|
|||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import { RootState } from "../state/store";
|
import { RootState } from "../state/store";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { addUser } from "../state/features/authSlice";
|
import { addUser, setSelectedName } from "../state/features/authSlice";
|
||||||
|
import { getAccountNames, getPrimaryAccountName } from "../utils/qortalRequestFunctions";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -22,28 +23,29 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const username = useSelector((state: RootState) => state?.auth?.user?.name);
|
const selectedName = useSelector((state: RootState) => state?.auth?.user?.selectedName);
|
||||||
|
const names = useSelector((state: RootState) => state?.auth?.user?.names || []);
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
const [openNames, setOpenNames] = useState(false);
|
||||||
async function getNameInfo(address: string) {
|
|
||||||
const response = await fetch("/names/address/" + address);
|
|
||||||
const nameData = await response.json();
|
|
||||||
|
|
||||||
if (nameData?.length > 0) {
|
|
||||||
return nameData[0].name;
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const askForAccountInformation = useCallback(async () => {
|
const askForAccountInformation = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
let account = await qortalRequest({
|
let account = await qortalRequest({
|
||||||
action: "GET_USER_ACCOUNT"
|
action: "GET_USER_ACCOUNT"
|
||||||
});
|
});
|
||||||
|
const [namesList, primaryName] = await Promise.all([
|
||||||
const name = await getNameInfo(account.address);
|
getAccountNames(account.address),
|
||||||
dispatch(addUser({ ...account, name }));
|
getPrimaryAccountName(account.address)
|
||||||
|
]);
|
||||||
|
const chosen = primaryName || (namesList && namesList[0]) || "";
|
||||||
|
dispatch(addUser({
|
||||||
|
...account,
|
||||||
|
names: namesList,
|
||||||
|
primaryName,
|
||||||
|
selectedName: chosen,
|
||||||
|
name: chosen,
|
||||||
|
publicKey: account.publicKey
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@@ -127,9 +129,8 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center gap-x-4">
|
<div className="flex justify-between items-center gap-x-4">
|
||||||
{!username && (
|
{!selectedName && (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={()=> {
|
onClick={()=> {
|
||||||
@@ -142,6 +143,41 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{selectedName && (
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
onClick={() => setOpenNames((o) => !o)}
|
||||||
|
className="bg-white px-6 py-2 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[160px]">{selectedName}</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>
|
||||||
|
</Button>
|
||||||
|
{openNames && (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5
|
||||||
|
max-h-64 overflow-auto z-20 text-black
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{names.map((n: string) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(setSelectedName(n));
|
||||||
|
setOpenNames(false);
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
w-full text-left px-4 py-2 text-black hover:bg-gray-100
|
||||||
|
${n === selectedName ? "font-semibold" : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface ModalProps {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<ModalProps> = ({
|
const Modal: React.FC<ModalProps> = ({
|
||||||
@@ -14,8 +15,18 @@ const Modal: React.FC<ModalProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children
|
children,
|
||||||
|
size = 'sm'
|
||||||
}) => {
|
}) => {
|
||||||
|
const sizeClass =
|
||||||
|
size === 'xl'
|
||||||
|
? 'md:w-[98vw] md:max-w-[1000px]'
|
||||||
|
: size === 'lg'
|
||||||
|
? 'md:w-[95vw] md:max-w-[800px]'
|
||||||
|
: size === 'md'
|
||||||
|
? 'md:w-[92vw] md:max-w-[600px]'
|
||||||
|
: 'md:w-[90vw] md:max-w-[450px]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={isOpen} defaultOpen={isOpen} onOpenChange={onChange}>
|
<Dialog.Root open={isOpen} defaultOpen={isOpen} onOpenChange={onChange}>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
@@ -28,7 +39,7 @@ const Modal: React.FC<ModalProps> = ({
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<Dialog.Content
|
<Dialog.Content
|
||||||
className="
|
className={`
|
||||||
fixed
|
fixed
|
||||||
drop-shadow-md
|
drop-shadow-md
|
||||||
border
|
border
|
||||||
@@ -40,8 +51,7 @@ const Modal: React.FC<ModalProps> = ({
|
|||||||
md:h-auto
|
md:h-auto
|
||||||
md:max-h-[85vh]
|
md:max-h-[85vh]
|
||||||
w-full
|
w-full
|
||||||
md:w-[90vw]
|
${sizeClass}
|
||||||
md:max-w-[450px]
|
|
||||||
translate-x-[-50%]
|
translate-x-[-50%]
|
||||||
translate-y-[-50%]
|
translate-y-[-50%]
|
||||||
rounded-md
|
rounded-md
|
||||||
@@ -49,7 +59,7 @@ const Modal: React.FC<ModalProps> = ({
|
|||||||
p-[25px]
|
p-[25px]
|
||||||
focus:outline-none
|
focus:outline-none
|
||||||
overflow-y-auto
|
overflow-y-auto
|
||||||
">
|
`}>
|
||||||
<Dialog.Title
|
<Dialog.Title
|
||||||
className="
|
className="
|
||||||
text-xl
|
text-xl
|
||||||
|
|||||||
@@ -3,54 +3,53 @@ import { Song } from "../types";
|
|||||||
import { AddToPlaylistButton } from "./AddToPlayistButton";
|
import { AddToPlaylistButton } from "./AddToPlayistButton";
|
||||||
import LikeButton from "./LikeButton";
|
import LikeButton from "./LikeButton";
|
||||||
import MediaItem from "./MediaItem";
|
import MediaItem from "./MediaItem";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { RootState } from "../state/store";
|
||||||
|
import useUploadModal from "../hooks/useUploadModal";
|
||||||
|
import { setEditingSong } from "../state/features/globalSlice";
|
||||||
|
|
||||||
interface SearchContentProps {
|
interface SearchContentProps {
|
||||||
songs: Song[];
|
songs: Song[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchContent: React.FC<SearchContentProps> = ({
|
const SearchContent: React.FC<SearchContentProps> = ({ songs }) => {
|
||||||
songs
|
|
||||||
}) => {
|
|
||||||
const onPlay = useOnPlay(songs);
|
const onPlay = useOnPlay(songs);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const uploadModal = useUploadModal();
|
||||||
|
const username = useSelector((state: RootState) => state?.auth?.user?.selectedName);
|
||||||
|
|
||||||
if (songs.length === 0) {
|
if (songs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-col gap-y-2 w-full px-6 text-neutral-400">
|
||||||
className="
|
|
||||||
flex
|
|
||||||
flex-col
|
|
||||||
gap-y-2
|
|
||||||
w-full
|
|
||||||
px-6
|
|
||||||
text-neutral-400
|
|
||||||
"
|
|
||||||
>
|
|
||||||
No songs found.
|
No songs found.
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-2 w-full px-6">
|
<div className="flex flex-col gap-y-2 w-full px-6">
|
||||||
{songs.map((song: Song) => (
|
{songs.map((song: Song) => (
|
||||||
<div
|
<div key={song.id} className="flex items-center gap-x-4 w-full">
|
||||||
key={song.id}
|
|
||||||
className="flex items-center gap-x-4 w-full"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<MediaItem
|
<MediaItem onClick={(id: string) => onPlay(id)} data={song} />
|
||||||
onClick={(id: string) => onPlay(id)}
|
|
||||||
data={song}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<AddToPlaylistButton song={song} />
|
<AddToPlaylistButton song={song} />
|
||||||
<LikeButton songId={song.id} name={song.name} service={song.service} songData={song} />
|
<LikeButton songId={song.id} name={song.name} service={song.service} songData={song} />
|
||||||
|
{username && song.name === username && (
|
||||||
|
<button
|
||||||
|
className="text-sm px-3 py-1 rounded border border-neutral-600 text-neutral-300 hover:text-white hover:border-neutral-400"
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(setEditingSong(song));
|
||||||
|
uploadModal.onOpen();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default SearchContent;
|
export default SearchContent;
|
||||||
@@ -1,30 +1,57 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
|
|
||||||
import Input from "./Input";
|
import Input from "./Input";
|
||||||
import { resetQueriedList, setQueriedValue } from "../state/features/globalSlice";
|
import { resetQueriedList, setQueriedValue } from "../state/features/globalSlice";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { useFetchSongs } from "../hooks/fetchSongs";
|
import { useFetchSongs } from "../hooks/fetchSongs";
|
||||||
|
|
||||||
const SearchInput = () => {
|
const SearchInput = () => {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch();
|
||||||
const {getQueriedSongs} = useFetchSongs()
|
const { getQueriedSongs } = useFetchSongs();
|
||||||
|
const queriedValue = useSelector((state: any) => state.global.queriedValue);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleInputKeyDown = (event: any) => {
|
const handleInputKeyDown = (event: any) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === "Enter") {
|
||||||
dispatch(resetQueriedList())
|
dispatch(resetQueriedList());
|
||||||
getQueriedSongs()
|
getQueriedSongs();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<div style={{ position: "relative", width: "100%" }}>
|
||||||
placeholder="What do you want to listen to?"
|
<Input
|
||||||
onChange={(e) => dispatch(setQueriedValue(e.target.value))}
|
ref={inputRef}
|
||||||
onKeyDown={handleInputKeyDown}
|
placeholder="What do you want to listen to?"
|
||||||
/>
|
value={queriedValue}
|
||||||
|
onChange={(e) => dispatch(setQueriedValue(e.target.value))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
/>
|
||||||
|
{queriedValue && (
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(setQueriedValue(""))}
|
||||||
|
aria-label="Clear search input"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 10,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default SearchInput;
|
export default SearchInput;
|
||||||
@@ -1,51 +1,61 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
|
|
||||||
import Input from "./Input";
|
import Input from "./Input";
|
||||||
import { resetQueriedList, resetQueriedListPlaylist, setIsQueryingPlaylist, setQueriedValue, setQueriedValuePlaylist } from "../state/features/globalSlice";
|
import {
|
||||||
|
resetQueriedListPlaylist,
|
||||||
|
setIsQueryingPlaylist,
|
||||||
|
setQueriedValuePlaylist,
|
||||||
|
} from "../state/features/globalSlice";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { useFetchSongs } from "../hooks/fetchSongs";
|
import { useFetchSongs } from "../hooks/fetchSongs";
|
||||||
import { RootState } from "../state/store";
|
import { RootState } from "../state/store";
|
||||||
import { FaUndoAlt } from "react-icons/fa";
|
|
||||||
|
|
||||||
export const SearchInputPlaylist = () => {
|
export const SearchInputPlaylist = () => {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch();
|
||||||
const {getPlaylistsQueried} = useFetchSongs()
|
const { getPlaylistsQueried } = useFetchSongs();
|
||||||
const queriedValuePlaylist = useSelector((state: RootState) => state.global.queriedValuePlaylist);
|
const queriedValuePlaylist = useSelector((state: RootState) => state.global.queriedValuePlaylist);
|
||||||
const isQueryingPlaylist = useSelector((state: RootState) => state.global.isQueryingPlaylist);
|
const isQueryingPlaylist = useSelector((state: RootState) => state.global.isQueryingPlaylist);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleInputKeyDown = (event: any) => {
|
const handleInputKeyDown = (event: any) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === "Enter") {
|
||||||
dispatch(resetQueriedListPlaylist())
|
dispatch(resetQueriedListPlaylist());
|
||||||
if(!queriedValuePlaylist){
|
if (!queriedValuePlaylist) {
|
||||||
dispatch(setIsQueryingPlaylist(false))
|
dispatch(setIsQueryingPlaylist(false));
|
||||||
} else {
|
} else {
|
||||||
dispatch(setIsQueryingPlaylist(true))
|
dispatch(setIsQueryingPlaylist(true));
|
||||||
getPlaylistsQueried()
|
getPlaylistsQueried();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center w-full">
|
||||||
<Input
|
<div className="relative flex-1">
|
||||||
placeholder="What do you want to listen to?"
|
<Input
|
||||||
onChange={(e) => {
|
ref={inputRef}
|
||||||
dispatch(setQueriedValuePlaylist(e.target.value))
|
placeholder="What do you want to listen to?"
|
||||||
}}
|
value={queriedValuePlaylist}
|
||||||
value={queriedValuePlaylist}
|
onChange={(e) => dispatch(setQueriedValuePlaylist(e.target.value))}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
/>
|
/>
|
||||||
{isQueryingPlaylist && (
|
{queriedValuePlaylist && (
|
||||||
<FaUndoAlt className=" ml-2 cursor-pointer" onClick={()=> {
|
<button
|
||||||
dispatch(resetQueriedListPlaylist())
|
onClick={() => {
|
||||||
dispatch(setIsQueryingPlaylist(false))
|
dispatch(resetQueriedListPlaylist());
|
||||||
dispatch(setQueriedValuePlaylist(''))
|
dispatch(setIsQueryingPlaylist(false));
|
||||||
}} />
|
dispatch(setQueriedValuePlaylist(""));
|
||||||
)}
|
}}
|
||||||
|
aria-label="Clear search input"
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 bg-transparent border-0 cursor-pointer text-xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import ShortUniqueId from 'short-unique-id';
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import Compressor from 'compressorjs';
|
||||||
|
|
||||||
|
import Modal from './Modal';
|
||||||
|
import Input from './Input';
|
||||||
|
import Button from './Button';
|
||||||
|
import Textarea from './TextArea';
|
||||||
|
|
||||||
|
import useUploadFolderModal from "../hooks/useUploadFolderModal";
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { RootState } from '../state/store';
|
||||||
|
import { toBase64, objectToBase64 } from '../utils/toBase64';
|
||||||
|
import { addNewSong, setImageCoverHash, upsertPlaylists } from '../state/features/globalSlice';
|
||||||
|
import { removeTrailingUnderscore } from '../utils/extra';
|
||||||
|
|
||||||
|
const uid = new ShortUniqueId();
|
||||||
|
|
||||||
|
type TrackItem = {
|
||||||
|
file: File;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
coverFile: File | null; // per-file cover when sameCover=false
|
||||||
|
relPath: string; // webkitRelativePath (for diagnostics / future)
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIO_MIME_PREFIX = 'audio/';
|
||||||
|
const IMAGE_ACCEPT = 'image/*';
|
||||||
|
|
||||||
|
const isAudio = (f: File) => f.type?.startsWith(AUDIO_MIME_PREFIX) || /\.(mp3|wav|m4a|aac|flac|ogg|opus)$/i.test(f.name);
|
||||||
|
|
||||||
|
const filenameNoExt = (name: string) => name.replace(/\.[^.]+$/, '');
|
||||||
|
const safeTitleSlice = (s: string) => removeTrailingUnderscore(s.replace(/ /g, '_').toLowerCase().slice(0, 20));
|
||||||
|
|
||||||
|
const compressToWebpBase64 = async (img: File) => {
|
||||||
|
let compressedFile: File | undefined;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
new Compressor(img, {
|
||||||
|
quality: 0.6,
|
||||||
|
maxWidth: 300,
|
||||||
|
mimeType: 'image/webp',
|
||||||
|
success(result) {
|
||||||
|
compressedFile = new File([result], 'cover.webp', { type: 'image/webp' });
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
error() { resolve(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!compressedFile) return undefined;
|
||||||
|
const dataURI = await toBase64(compressedFile);
|
||||||
|
if (!dataURI || typeof dataURI !== 'string') return undefined;
|
||||||
|
return dataURI.split(',')[1]; // bare base64
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseAuthorFromPath = (relativePath: string) => {
|
||||||
|
// "Bob/First/song.mp3" => ["Bob","First","song.mp3"] => "Bob - First"
|
||||||
|
const parts = relativePath.split('/').filter(Boolean);
|
||||||
|
if (parts.length <= 1) return ''; // no folder level around the file
|
||||||
|
const noFileParts = parts.slice(0, -1);
|
||||||
|
return noFileParts.join(' - ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const topLevelFolderFromPaths = (relPaths: string[]) => {
|
||||||
|
// Take first component if it’s common, else take the first file’s first folder
|
||||||
|
const firstParts = relPaths.map(p => p.split('/').filter(Boolean)[0]).filter(Boolean);
|
||||||
|
if (!firstParts.length) return '';
|
||||||
|
const first = firstParts[0];
|
||||||
|
const allSame = firstParts.every(x => x === first);
|
||||||
|
return allSame ? first : first;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UploadFolderModal: React.FC = () => {
|
||||||
|
const username = useSelector((state: RootState) => state?.auth?.user?.selectedName || state?.auth?.user?.name);
|
||||||
|
const modal = useUploadFolderModal();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [tracks, setTracks] = useState<TrackItem[]>([]);
|
||||||
|
const [sameAuthor, setSameAuthor] = useState(true);
|
||||||
|
const [globalAuthor, setGlobalAuthor] = useState('');
|
||||||
|
const [sameCover, setSameCover] = useState(true);
|
||||||
|
const [globalCoverFile, setGlobalCoverFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const [createPlaylist, setCreatePlaylist] = useState(false);
|
||||||
|
const [playlistTitle, setPlaylistTitle] = useState('');
|
||||||
|
const [playlistDescription, setPlaylistDescription] = useState('');
|
||||||
|
const [playlistCoverFile, setPlaylistCoverFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setTracks([]);
|
||||||
|
setSameAuthor(true);
|
||||||
|
setGlobalAuthor('');
|
||||||
|
setSameCover(true);
|
||||||
|
setGlobalCoverFile(null);
|
||||||
|
setCreatePlaylist(false);
|
||||||
|
setPlaylistTitle('');
|
||||||
|
setPlaylistDescription('');
|
||||||
|
setPlaylistCoverFile(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
resetState();
|
||||||
|
modal.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderSelect: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
const audioFiles = files.filter(isAudio);
|
||||||
|
|
||||||
|
if (!audioFiles.length) {
|
||||||
|
toast.error('No audio files found in the selected folder.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: TrackItem[] = audioFiles.map((file) => {
|
||||||
|
const relPath = (file as any).webkitRelativePath || file.name;
|
||||||
|
const title = filenameNoExt(file.name);
|
||||||
|
const authorSuggestion = parseAuthorFromPath(relPath);
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
title,
|
||||||
|
author: authorSuggestion || '',
|
||||||
|
coverFile: null,
|
||||||
|
relPath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setTracks(items);
|
||||||
|
|
||||||
|
// Decide author mode based on diversity of author suggestions
|
||||||
|
const distinctAuthors = Array.from(new Set(items.map(i => i.author || '')));
|
||||||
|
if (distinctAuthors.length > 1) {
|
||||||
|
setSameAuthor(false);
|
||||||
|
// when different, keep each prefilled author as-is
|
||||||
|
setGlobalAuthor('');
|
||||||
|
} else {
|
||||||
|
setSameAuthor(true);
|
||||||
|
setGlobalAuthor(distinctAuthors[0] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefill playlist title from top-level folder
|
||||||
|
const top = topLevelFolderFromPaths(items.map(i => i.relPath));
|
||||||
|
setPlaylistTitle(top || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTrackTitle = (idx: number, title: string) => {
|
||||||
|
setTracks(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], title };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTrackAuthor = (idx: number, author: string) => {
|
||||||
|
setTracks(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], author };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTrackCover = (idx: number, file: File | null) => {
|
||||||
|
setTracks(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], coverFile: file };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateBeforeSubmit = () => {
|
||||||
|
if (!username) {
|
||||||
|
toast.error('Please authenticate');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!tracks.length) {
|
||||||
|
toast.error('Please select a folder with audio files');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// titles
|
||||||
|
if (tracks.some(t => !t.title?.trim())) {
|
||||||
|
toast.error('Every file needs a Title');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// authors
|
||||||
|
if (sameAuthor) {
|
||||||
|
if (!globalAuthor.trim()) {
|
||||||
|
toast.error('Please enter the Author');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tracks.some(t => !t.author?.trim())) {
|
||||||
|
toast.error('Every file needs an Author');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// covers
|
||||||
|
if (sameCover) {
|
||||||
|
if (!globalCoverFile) {
|
||||||
|
toast.error('Please select a cover image');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tracks.some(t => !t.coverFile)) {
|
||||||
|
toast.error('Every file needs a cover image (or enable "Same cover for all")');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// playlist fields if creating one
|
||||||
|
if (createPlaylist) {
|
||||||
|
if (!playlistTitle.trim()) {
|
||||||
|
toast.error('Playlist title is required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!playlistDescription.trim()) {
|
||||||
|
toast.error('Playlist description is required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// playlist cover can fall back to global cover if sameCover==true
|
||||||
|
if (!playlistCoverFile && !sameCover) {
|
||||||
|
// if user set per-file covers only, we still require an explicit playlist cover
|
||||||
|
toast.error('Please select a playlist cover image (or enable "Same cover for all")');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!validateBeforeSubmit()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resources: any[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Prepare images (compress once if shared)
|
||||||
|
let sharedCoverB64: string | undefined;
|
||||||
|
if (sameCover && globalCoverFile) {
|
||||||
|
sharedCoverB64 = await compressToWebpBase64(globalCoverFile);
|
||||||
|
if (!sharedCoverB64) {
|
||||||
|
toast.error('Image compression error (shared cover)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-track publish and local store updates
|
||||||
|
const publishedSongRefs: { title: string; identifier: string }[] = [];
|
||||||
|
for (const t of tracks) {
|
||||||
|
const title = t.title.trim();
|
||||||
|
const author = (sameAuthor ? globalAuthor : t.author).trim();
|
||||||
|
|
||||||
|
const idRand = uid(8) as string;
|
||||||
|
const cleanTitle = safeTitleSlice(title);
|
||||||
|
const identifier = `earbump_song_${cleanTitle}_${idRand}`;
|
||||||
|
|
||||||
|
const desc = `title=${title};author=${author}`;
|
||||||
|
|
||||||
|
// AUDIO
|
||||||
|
const audioExt = t.file.name.split('.').pop() || 'mp3';
|
||||||
|
const audioFilename = `${title.replace(/ /g, '_').slice(0, 20)}.${audioExt}`;
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
name: username,
|
||||||
|
service: 'AUDIO',
|
||||||
|
file: t.file,
|
||||||
|
title,
|
||||||
|
description: desc,
|
||||||
|
identifier,
|
||||||
|
filename: audioFilename
|
||||||
|
});
|
||||||
|
|
||||||
|
// THUMBNAIL
|
||||||
|
let coverB64 = sharedCoverB64;
|
||||||
|
if (!sameCover) {
|
||||||
|
const chosen = t.coverFile!;
|
||||||
|
coverB64 = await compressToWebpBase64(chosen);
|
||||||
|
if (!coverB64) {
|
||||||
|
toast.error(`Image compression error for "${title}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const imageFilename = `${title.replace(/ /g, '_').slice(0, 20)}.webp`;
|
||||||
|
resources.push({
|
||||||
|
name: username,
|
||||||
|
service: 'THUMBNAIL',
|
||||||
|
data64: coverB64,
|
||||||
|
identifier,
|
||||||
|
filename: imageFilename
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep for playlist + store update after publish
|
||||||
|
publishedSongRefs.push({ title, identifier });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional PLAYLIST
|
||||||
|
let playlistIdentifier: string | null = null;
|
||||||
|
if (createPlaylist) {
|
||||||
|
const title = playlistTitle.trim();
|
||||||
|
const description = playlistDescription.trim();
|
||||||
|
const playlistCover =
|
||||||
|
playlistCoverFile
|
||||||
|
? await compressToWebpBase64(playlistCoverFile)
|
||||||
|
: (sameCover ? sharedCoverB64 : undefined);
|
||||||
|
|
||||||
|
if (!playlistCover) {
|
||||||
|
toast.error('Playlist cover image is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idRand = uid(8) as string;
|
||||||
|
const cleanTitle = removeTrailingUnderscore(title.replace(/ /g, '_').toLowerCase().slice(0, 25));
|
||||||
|
playlistIdentifier = `earbump_playlist_${cleanTitle}_${idRand}`;
|
||||||
|
|
||||||
|
const filename = `${title.replace(/ /g, '_').slice(0, 20)}.json`;
|
||||||
|
const playlistObj = {
|
||||||
|
songs: publishedSongRefs,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image: 'data:image/webp;base64,' + playlistCover
|
||||||
|
};
|
||||||
|
const playlistData64 = await objectToBase64(playlistObj);
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
name: username,
|
||||||
|
service: 'PLAYLIST',
|
||||||
|
data64: playlistData64,
|
||||||
|
title: title.slice(0, 55),
|
||||||
|
description: description.slice(0, 140),
|
||||||
|
identifier: playlistIdentifier,
|
||||||
|
filename
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish all at once
|
||||||
|
const multiplePublish = {
|
||||||
|
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
|
||||||
|
resources
|
||||||
|
};
|
||||||
|
await qortalRequest(multiplePublish);
|
||||||
|
|
||||||
|
// Update local store for each song
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
const t = tracks[i];
|
||||||
|
const title = t.title.trim();
|
||||||
|
const author = (sameAuthor ? globalAuthor : t.author).trim();
|
||||||
|
const identifier = publishedSongRefs[i].identifier;
|
||||||
|
|
||||||
|
dispatch(addNewSong({
|
||||||
|
title,
|
||||||
|
description: `title=${title};author=${author}`,
|
||||||
|
created: now,
|
||||||
|
updated: now,
|
||||||
|
name: username,
|
||||||
|
id: identifier,
|
||||||
|
author
|
||||||
|
}));
|
||||||
|
|
||||||
|
// set local cover hash if shared or per-file
|
||||||
|
if (sameCover && globalCoverFile) {
|
||||||
|
if (!sharedCoverB64) continue;
|
||||||
|
dispatch(setImageCoverHash({ url: 'data:image/webp;base64,' + sharedCoverB64, id: identifier }));
|
||||||
|
} else if (tracks[i].coverFile) {
|
||||||
|
const b64 = await compressToWebpBase64(tracks[i].coverFile as File);
|
||||||
|
if (b64) {
|
||||||
|
dispatch(setImageCoverHash({ url: 'data:image/webp;base64,' + b64, id: identifier }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local store for playlist (if created)
|
||||||
|
if (createPlaylist && playlistIdentifier) {
|
||||||
|
const cover =
|
||||||
|
playlistCoverFile
|
||||||
|
? await compressToWebpBase64(playlistCoverFile)
|
||||||
|
: (sameCover ? sharedCoverB64 : undefined);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
upsertPlaylists({
|
||||||
|
user: username,
|
||||||
|
service: 'PLAYLIST',
|
||||||
|
id: playlistIdentifier,
|
||||||
|
filename: `${playlistTitle.replace(/ /g, '_').slice(0, 20)}.json`,
|
||||||
|
songs: publishedSongRefs,
|
||||||
|
title: playlistTitle.trim(),
|
||||||
|
description: playlistDescription.trim(),
|
||||||
|
image: cover ? 'data:image/webp;base64,' + cover : undefined
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(createPlaylist ? 'Songs & playlist published!' : 'Songs published!');
|
||||||
|
onChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('Something went wrong while publishing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPerFileAuthor = !sameAuthor;
|
||||||
|
const showPerFileCover = !sameCover;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Add Folder"
|
||||||
|
description="Select a folder to publish multiple audio files at once"
|
||||||
|
isOpen={modal.isOpen}
|
||||||
|
onChange={onChange}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
{/* Native input to ensure webkitdirectory is passed through */}
|
||||||
|
<div>
|
||||||
|
<div className="pb-1">Select a folder</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
// @ts-ignore - non-standard but widely supported
|
||||||
|
webkitdirectory="true"
|
||||||
|
// @ts-ignore
|
||||||
|
directory="true"
|
||||||
|
className="block w-full text-sm file:mr-4 file:rounded file:border-0 file:bg-neutral-700 file:px-3 file:py-2 file:text-white"
|
||||||
|
onChange={handleFolderSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Global author toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input id="sameAuthor" type="checkbox" checked={sameAuthor} onChange={(e) => setSameAuthor(e.target.checked)} />
|
||||||
|
<label htmlFor="sameAuthor">Same author for all files</label>
|
||||||
|
</div>
|
||||||
|
{sameAuthor ? (
|
||||||
|
<Input id="globalAuthor" placeholder="Author" value={globalAuthor} onChange={(e: any) => setGlobalAuthor(e.target.value)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Global cover toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input id="sameCover" type="checkbox" checked={sameCover} onChange={(e) => setSameCover(e.target.checked)} />
|
||||||
|
<label htmlFor="sameCover">Same cover image for all files</label>
|
||||||
|
</div>
|
||||||
|
{sameCover ? (
|
||||||
|
<div>
|
||||||
|
<div className="pb-1">Select a cover image</div>
|
||||||
|
<Input type="file" accept={IMAGE_ACCEPT} onChange={(e: any) => setGlobalCoverFile(e.target.files?.[0] || null)} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files table/list */}
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<div className="rounded-md border border-neutral-700">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[820px]">
|
||||||
|
{/* Scroll region with sticky header */}
|
||||||
|
<div className="max-h-[55vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
sticky top-0 z-10
|
||||||
|
grid gap-2
|
||||||
|
grid-cols-[2fr,1.2fr,1fr]
|
||||||
|
bg-neutral-800
|
||||||
|
border-b border-neutral-700
|
||||||
|
text-xs uppercase tracking-wide text-neutral-400
|
||||||
|
py-2 px-2
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>Title</div>
|
||||||
|
<div>{showPerFileAuthor ? 'Author' : 'Author (global)'}</div>
|
||||||
|
<div>{showPerFileCover ? 'Cover' : 'File'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="divide-y divide-neutral-800">
|
||||||
|
{tracks.map((t, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="
|
||||||
|
grid gap-2 items-center
|
||||||
|
grid-cols-[2fr,1.2fr,1fr]
|
||||||
|
py-2 px-2
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Title"
|
||||||
|
value={t.title}
|
||||||
|
onChange={(e: any) => updateTrackTitle(idx, e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showPerFileAuthor ? (
|
||||||
|
<Input
|
||||||
|
placeholder="Author"
|
||||||
|
value={t.author}
|
||||||
|
onChange={(e: any) => updateTrackAuthor(idx, e.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-neutral-400 truncate">
|
||||||
|
{t.author || globalAuthor || 'Author will be the global value'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPerFileCover ? (
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept={IMAGE_ACCEPT}
|
||||||
|
onChange={(e: any) => updateTrackCover(idx, e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-neutral-500 truncate">
|
||||||
|
{filenameNoExt(t.file.name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional playlist */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="createPlaylist"
|
||||||
|
type="checkbox"
|
||||||
|
checked={createPlaylist}
|
||||||
|
onChange={(e) => setCreatePlaylist(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="createPlaylist">Create a playlist from these files</label>
|
||||||
|
</div>
|
||||||
|
{createPlaylist && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Input
|
||||||
|
id="playlistTitle"
|
||||||
|
placeholder="Playlist title"
|
||||||
|
value={playlistTitle}
|
||||||
|
onChange={(e: any) => setPlaylistTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
id="playlistDescription"
|
||||||
|
placeholder="Playlist description"
|
||||||
|
value={playlistDescription}
|
||||||
|
onChange={(e: any) => setPlaylistDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="pb-1">Playlist cover image {sameCover && !playlistCoverFile ? '(will reuse shared cover if not set)' : ''}</div>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept={IMAGE_ACCEPT}
|
||||||
|
onChange={(e: any) => setPlaylistCoverFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={onSubmit}>
|
||||||
|
{createPlaylist ? 'Publish Songs & Playlist' : 'Publish Songs'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadFolderModal;
|
||||||
+217
-129
@@ -3,36 +3,32 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { FieldValues, SubmitHandler, useForm } from 'react-hook-form';
|
import { FieldValues, SubmitHandler, useForm } from 'react-hook-form';
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import Compressor from 'compressorjs'
|
import Compressor from 'compressorjs'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import Input from './Input';
|
import Input from './Input';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import useUploadModal from "../hooks/useUploadModal";
|
import useUploadModal from "../hooks/useUploadModal";
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { setNotification } from '../state/features/notificationsSlice';
|
|
||||||
import { RootState } from '../state/store';
|
import { RootState } from '../state/store';
|
||||||
import ImageUploader from './common/ImageUploader';
|
|
||||||
import { toBase64 } from '../utils/toBase64';
|
import { toBase64 } from '../utils/toBase64';
|
||||||
import { addNewSong, setImageCoverHash } from '../state/features/globalSlice';
|
import { addNewSong, setImageCoverHash, setEditingSong } from '../state/features/globalSlice';
|
||||||
import { removeTrailingUnderscore } from '../utils/extra';
|
import { removeTrailingUnderscore } from '../utils/extra';
|
||||||
|
|
||||||
const uid = new ShortUniqueId()
|
const uid = new ShortUniqueId()
|
||||||
|
|
||||||
const UploadModal = () => {
|
const UploadModal = () => {
|
||||||
const username = useSelector((state: RootState) => state?.auth?.user?.name)
|
const username = useSelector((state: RootState) => state?.auth?.user?.selectedName)
|
||||||
const [songImg, setSongImg] = useState("")
|
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const editingSong = useSelector((state: RootState) => state.global.editingSong);
|
||||||
|
const [prefilledAudio, setPrefilledAudio] = useState<File | null>(null);
|
||||||
|
const [prefilledImageBase64, setPrefilledImageBase64] = useState<string | null>(null);
|
||||||
const uploadModal = useUploadModal();
|
const uploadModal = useUploadModal();
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
} = useForm<FieldValues>({
|
} = useForm<FieldValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
author: '',
|
author: '',
|
||||||
@@ -45,6 +41,9 @@ const UploadModal = () => {
|
|||||||
const onChange = (open: boolean) => {
|
const onChange = (open: boolean) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
reset();
|
reset();
|
||||||
|
setPrefilledAudio(null);
|
||||||
|
setPrefilledImageBase64(null);
|
||||||
|
dispatch(setEditingSong(null));
|
||||||
uploadModal.onClose();
|
uploadModal.onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +70,7 @@ const UploadModal = () => {
|
|||||||
})
|
})
|
||||||
if (!compressedFile) return
|
if (!compressedFile) return
|
||||||
const dataURI = await toBase64(compressedFile)
|
const dataURI = await toBase64(compressedFile)
|
||||||
if(!dataURI || typeof dataURI !== 'string') throw new Error('invalid image')
|
if(!dataURI || typeof dataURI !== 'string') throw new Error('Invalid image')
|
||||||
const base64Data = dataURI?.split(',')[1];
|
const base64Data = dataURI?.split(',')[1];
|
||||||
return base64Data
|
return base64Data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -79,6 +78,40 @@ const UploadModal = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prefill = async () => {
|
||||||
|
if (!editingSong) return;
|
||||||
|
|
||||||
|
setValue('title', editingSong.title || '');
|
||||||
|
setValue('author', editingSong.author || '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioRes = await fetch(`/arbitrary/AUDIO/${editingSong.name}/${editingSong.id}`);
|
||||||
|
const audioBlob = await audioRes.blob();
|
||||||
|
const ext = audioBlob.type?.split('/')?.[1] || 'mp3';
|
||||||
|
const fileName = `${(editingSong.title || 'audio').replace(/[^a-z0-9_\-]/gi,'_')}.${ext}`;
|
||||||
|
setPrefilledAudio(new File([audioBlob], fileName, { type: audioBlob.type || 'audio/mpeg' }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch existing audio', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const thumbRes = await fetch(`/arbitrary/THUMBNAIL/${editingSong.name}/${editingSong.id}`);
|
||||||
|
const thumbBlob = await thumbRes.blob();
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
const base64 = dataUrl?.split(',')[1];
|
||||||
|
if (base64) setPrefilledImageBase64(base64);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(thumbBlob);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch existing thumbnail', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (uploadModal.isOpen && editingSong) prefill();
|
||||||
|
}, [uploadModal.isOpen, editingSong, setValue]);
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<FieldValues> = async (values) => {
|
const onSubmit: SubmitHandler<FieldValues> = async (values) => {
|
||||||
try {
|
try {
|
||||||
@@ -86,122 +119,166 @@ const UploadModal = () => {
|
|||||||
toast.error('Please authenticate')
|
toast.error('Please authenticate')
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(!values.image?.[0]){
|
|
||||||
toast.error('Please attach an image cover')
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
const isEditing = Boolean(editingSong);
|
||||||
|
const incomingImageFile: File | undefined = values.image?.[0];
|
||||||
|
const incomingSongFile: File | undefined = values.song?.[0];
|
||||||
|
const title = values.title;
|
||||||
|
const author = values.author;
|
||||||
|
|
||||||
const imageFile = values.image?.[0];
|
if (!title || !author) {
|
||||||
const songFile = values.song?.[0];
|
toast.error('Missing fields');
|
||||||
const title = values.title
|
setIsLoading(false);
|
||||||
const author = values.author
|
return;
|
||||||
if (!imageFile || !songFile || !username || !title || !author) {
|
}
|
||||||
toast.error('Missing fields')
|
if (!isEditing && (!incomingImageFile || !incomingSongFile)) {
|
||||||
|
toast.error('Please select both an audio file and a cover image');
|
||||||
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine what actually changed
|
||||||
|
const audioChanged = Boolean(incomingSongFile);
|
||||||
|
const imageChanged = Boolean(incomingImageFile);
|
||||||
|
const metadataChanged =
|
||||||
|
isEditing && (!!editingSong &&
|
||||||
|
(editingSong.title !== title || editingSong.author !== author));
|
||||||
|
|
||||||
|
// Only compress image if user selected a new one
|
||||||
const songError = null
|
let finalImageBase64: string | undefined;
|
||||||
const imageError = null
|
if (!isEditing) {
|
||||||
|
// New publish path: image required
|
||||||
try {
|
const compressed = await compressImg(incomingImageFile as File);
|
||||||
const compressedImg = await compressImg(imageFile)
|
if (!compressed) {
|
||||||
if(!compressedImg){
|
toast.error('Image compression Error');
|
||||||
toast.error('Image compression Error')
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = uid(8)
|
finalImageBase64 = compressed;
|
||||||
const titleToUnderscore = title?.replace(/ /g, '_')
|
} else if (imageChanged) {
|
||||||
const titleToLowercase = titleToUnderscore.toLowerCase()
|
const compressed = await compressImg(incomingImageFile as File);
|
||||||
const titleSlice = titleToLowercase.slice(0,20)
|
if (!compressed) {
|
||||||
const cleanTitle = removeTrailingUnderscore(titleSlice)
|
toast.error('Image compression Error');
|
||||||
let identifier = `earbump_song_${cleanTitle}_${id}`
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
const description = `title=${title};author=${author}`
|
|
||||||
|
|
||||||
const fileExtension = imageFile?.name?.split('.')?.pop()
|
|
||||||
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20)
|
|
||||||
const filename = `${fileTitle}.${fileExtension}`
|
|
||||||
const resources = [
|
|
||||||
{
|
|
||||||
name: username,
|
|
||||||
service: 'AUDIO',
|
|
||||||
file: songFile,
|
|
||||||
title: title,
|
|
||||||
description: description,
|
|
||||||
identifier: identifier,
|
|
||||||
filename
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: username,
|
|
||||||
service: 'THUMBNAIL',
|
|
||||||
data64: compressedImg,
|
|
||||||
identifier: identifier
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const multiplePublish = {
|
|
||||||
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
|
|
||||||
resources: resources
|
|
||||||
}
|
}
|
||||||
await qortalRequest(multiplePublish)
|
finalImageBase64 = compressed;
|
||||||
|
} // else: editing with no new image => don't republish THUMBNAIL
|
||||||
|
|
||||||
const songData = {
|
// Pick the audio to publish when needed
|
||||||
title: title,
|
const finalSongFile =
|
||||||
description: description,
|
audioChanged ? incomingSongFile : (isEditing ? prefilledAudio : undefined);
|
||||||
created: Date.now(),
|
if (!isEditing && !finalSongFile) {
|
||||||
updated: Date.now(),
|
toast.error('Please select an audio file');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let identifier = isEditing ? editingSong!.id : undefined;
|
||||||
|
if (!identifier) {
|
||||||
|
const id = uid(8);
|
||||||
|
const titleToUnderscore = title?.replace(/ /g, '_');
|
||||||
|
const titleToLowercase = titleToUnderscore.toLowerCase();
|
||||||
|
const titleSlice = titleToLowercase.slice(0,20);
|
||||||
|
const cleanTitle = removeTrailingUnderscore(titleSlice);
|
||||||
|
identifier = `earbump_song_${cleanTitle}_${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = `title=${title};author=${author}`;
|
||||||
|
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20);
|
||||||
|
const imageExt = (incomingImageFile?.name?.split('.').pop()) || 'webp';
|
||||||
|
const imageFilename = `${fileTitle}.${imageExt}`;
|
||||||
|
// Keep the right extension even if using the prefilled audio on metadata-only changes
|
||||||
|
const audioExt =
|
||||||
|
(incomingSongFile?.name?.split('.').pop()) ||
|
||||||
|
(prefilledAudio?.name?.split('.').pop()) ||
|
||||||
|
'mp3';
|
||||||
|
const audioFilename = `${fileTitle}.${audioExt}`;
|
||||||
|
|
||||||
|
// Build resources conditionally:
|
||||||
|
// - New publish: both AUDIO and THUMBNAIL
|
||||||
|
// - Editing: only changed pieces, but republish AUDIO when metadata changed
|
||||||
|
const resources: any[] = [];
|
||||||
|
if (!isEditing) {
|
||||||
|
resources.push({
|
||||||
name: username,
|
name: username,
|
||||||
id: identifier,
|
service: 'AUDIO',
|
||||||
author: author
|
file: finalSongFile,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
identifier,
|
||||||
|
filename: audioFilename
|
||||||
|
});
|
||||||
|
resources.push({
|
||||||
|
name: username,
|
||||||
|
service: 'THUMBNAIL',
|
||||||
|
data64: finalImageBase64,
|
||||||
|
identifier,
|
||||||
|
filename: imageFilename
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (audioChanged || metadataChanged) {
|
||||||
|
if (!finalSongFile) {
|
||||||
|
toast.error('Unable to republish audio — current file unavailable. Please re-select the audio.');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resources.push({
|
||||||
|
name: username,
|
||||||
|
service: 'AUDIO',
|
||||||
|
file: finalSongFile,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
identifier,
|
||||||
|
filename: audioFilename
|
||||||
|
});
|
||||||
}
|
}
|
||||||
dispatch(addNewSong(songData))
|
if (imageChanged && finalImageBase64) {
|
||||||
dispatch(setImageCoverHash({ url: 'data:image/webp;base64,' + compressedImg , id: identifier }));
|
resources.push({
|
||||||
} catch (error: any) {
|
name: username,
|
||||||
let notificationObj = null
|
service: 'THUMBNAIL',
|
||||||
if (typeof error === 'string') {
|
data64: finalImageBase64,
|
||||||
notificationObj = {
|
identifier,
|
||||||
msg: error || 'Failed to publish audio',
|
filename: imageFilename
|
||||||
alertType: 'error'
|
});
|
||||||
}
|
}
|
||||||
} else if (typeof error?.error === 'string') {
|
if (resources.length === 0) {
|
||||||
notificationObj = {
|
toast('No changes to publish');
|
||||||
msg: error?.error || 'Failed to publish audio',
|
setIsLoading(false);
|
||||||
alertType: 'error'
|
return;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
notificationObj = {
|
|
||||||
msg: error?.message || error?.message || 'Failed to publish audio',
|
|
||||||
alertType: 'error'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!notificationObj) return
|
|
||||||
dispatch(setNotification(notificationObj))
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const multiplePublish = {
|
||||||
|
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
|
||||||
|
resources
|
||||||
|
};
|
||||||
|
|
||||||
if (songError) {
|
await qortalRequest(multiplePublish);
|
||||||
setIsLoading(false);
|
|
||||||
return toast.error('Failed song upload');
|
const songData = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
created: isEditing ? editingSong!.created : Date.now(),
|
||||||
|
updated: Date.now(),
|
||||||
|
name: username,
|
||||||
|
id: identifier,
|
||||||
|
author
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(addNewSong(songData));
|
||||||
|
// Only update the local cover hash if we actually changed the image
|
||||||
|
if (imageChanged && finalImageBase64) {
|
||||||
|
dispatch(setImageCoverHash({ url: 'data:image/webp;base64,' + finalImageBase64 , id: identifier }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (imageError) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return toast.error('Failed image upload');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
toast.success('Song created!');
|
toast.success(isEditing ? 'Audio updated successfully!' : 'Audio published successfully!');
|
||||||
reset();
|
reset();
|
||||||
|
setPrefilledAudio(null);
|
||||||
|
setPrefilledImageBase64(null);
|
||||||
|
dispatch(setEditingSong(null));
|
||||||
uploadModal.onClose();
|
uploadModal.onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Something went wrong');
|
toast.error('Something went wrong');
|
||||||
@@ -210,59 +287,70 @@ const UploadModal = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Add a song"
|
title={editingSong ? "Edit Audio" : "Publish Audio"}
|
||||||
description="Upload an audio file- all fields are required"
|
description={editingSong ? "Update any fields below" : "All fields are required"}
|
||||||
isOpen={uploadModal.isOpen}
|
isOpen={uploadModal.isOpen}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
>
|
>
|
||||||
<form
|
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register('title', { required: true , maxLength: 35})}
|
maxLength={35}
|
||||||
placeholder="Song title"
|
{...register('title', { required: true })}
|
||||||
|
placeholder="Title"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="author"
|
id="author"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register('author', { required: true })}
|
{...register('author', { required: true })}
|
||||||
placeholder="Song author"
|
placeholder="Author"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* AUDIO */}
|
||||||
<div>
|
<div>
|
||||||
<div className="pb-1">
|
<div className="pb-1">Select an audio file {editingSong && "(leave empty to keep current)"}</div>
|
||||||
Select a song file
|
|
||||||
</div>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="test"
|
placeholder="audio"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/*"
|
accept="audio/*"
|
||||||
id="song"
|
id="song"
|
||||||
{...register('song', { required: true })}
|
{...register('song', { required: !editingSong })}
|
||||||
/>
|
/>
|
||||||
|
{editingSong && prefilledAudio && (
|
||||||
|
<div className="text-xs text-neutral-400 pt-1">
|
||||||
|
Current audio: {prefilledAudio.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* IMAGE */}
|
||||||
<div>
|
<div>
|
||||||
<div className="pb-1">
|
<div className="pb-1">Select a cover image {editingSong && "(leave empty to keep current)"}</div>
|
||||||
Select an image
|
|
||||||
</div>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="test"
|
placeholder="image"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
id="image"
|
id="image"
|
||||||
{...register('image', { required: true })}
|
{...register('image', { required: !editingSong })}
|
||||||
/>
|
/>
|
||||||
|
{editingSong && prefilledImageBase64 && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<img
|
||||||
|
className="h-16 w-16 rounded object-cover border border-neutral-700"
|
||||||
|
alt="Current cover"
|
||||||
|
src={`data:image/webp;base64,${prefilledImageBase64}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button disabled={isLoading} type="submit">
|
<Button disabled={isLoading} type="submit">
|
||||||
Create
|
{editingSong ? "Save changes" : "Publish"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -13,24 +13,21 @@ import useUploadModal from "../hooks/useUploadPlaylistModal";
|
|||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { setNotification } from '../state/features/notificationsSlice';
|
import { setNotification } from '../state/features/notificationsSlice';
|
||||||
import { RootState } from '../state/store';
|
import { RootState } from '../state/store';
|
||||||
import ImageUploader from './common/ImageUploader';
|
|
||||||
import { objectToBase64, toBase64 } from '../utils/toBase64';
|
import { objectToBase64, toBase64 } from '../utils/toBase64';
|
||||||
import { SongReference, addNewSong, addToPlaylistHashMap, setImageCoverHash, setNewPlayList, upsertPlaylists } from '../state/features/globalSlice';
|
import { SongReference, addToPlaylistHashMap, setNewPlayList, upsertPlaylists } from '../state/features/globalSlice';
|
||||||
import Textarea from './TextArea';
|
import Textarea from './TextArea';
|
||||||
import {AiOutlineClose} from "react-icons/ai";
|
import {AiOutlineClose} from "react-icons/ai";
|
||||||
import { Song } from '../types';
|
|
||||||
import { removeTrailingUnderscore } from '../utils/extra';
|
import { removeTrailingUnderscore } from '../utils/extra';
|
||||||
const uid = new ShortUniqueId()
|
const uid = new ShortUniqueId()
|
||||||
|
|
||||||
const UploadPlaylistModal = () => {
|
const UploadPlaylistModal = () => {
|
||||||
const username = useSelector((state: RootState) => state?.auth?.user?.name)
|
const username = useSelector((state: RootState) => state?.auth?.user?.name)
|
||||||
const [playlistImg, setPlaylistImg] = useState("")
|
const userPubkey = useSelector((state: RootState) => state?.auth?.user?.publicKey)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const newPlaylist = useSelector((state: RootState) => state?.global.newPlayList);
|
const newPlaylist = useSelector((state: RootState) => state?.global.newPlayList);
|
||||||
const [prevSavedImg, setPrevSavedImg] = useState<null | string>(null)
|
const [prevSavedImg, setPrevSavedImg] = useState<null | string>(null)
|
||||||
const uploadModal = useUploadModal();
|
const uploadModal = useUploadModal();
|
||||||
console.log({newPlaylist})
|
|
||||||
const currentPlaylist = useRef<any>(null)
|
const currentPlaylist = useRef<any>(null)
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -43,22 +40,27 @@ const UploadPlaylistModal = () => {
|
|||||||
description: newPlaylist?.description,
|
description: newPlaylist?.description,
|
||||||
title: newPlaylist?.title || '',
|
title: newPlaylist?.title || '',
|
||||||
image: null,
|
image: null,
|
||||||
|
isPrivate: false,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
useEffect(()=> {
|
useEffect(()=> {
|
||||||
if(currentPlaylist?.current?.id === newPlaylist?.id) return
|
if(currentPlaylist?.current?.id === newPlaylist?.id) return;
|
||||||
if(newPlaylist) reset({
|
if(newPlaylist) {
|
||||||
description: newPlaylist?.description,
|
const computedIsPrivate = !!newPlaylist?.service && newPlaylist.service.startsWith('DOCUMENT_PRIVATE');
|
||||||
title: newPlaylist?.title || '',
|
reset({
|
||||||
image: null,
|
description: newPlaylist?.description,
|
||||||
})
|
title: newPlaylist?.title || '',
|
||||||
if(newPlaylist && newPlaylist?.image) setPrevSavedImg(newPlaylist.image)
|
image: null,
|
||||||
if(newPlaylist?.id){
|
isPrivate: computedIsPrivate,
|
||||||
currentPlaylist.current = newPlaylist
|
});
|
||||||
}
|
}
|
||||||
}, [reset, newPlaylist])
|
if(newPlaylist && newPlaylist?.image) setPrevSavedImg(newPlaylist.image);
|
||||||
|
if(newPlaylist?.id){
|
||||||
|
currentPlaylist.current = newPlaylist;
|
||||||
|
}
|
||||||
|
}, [reset, newPlaylist]);
|
||||||
|
|
||||||
const onChange = async (open: boolean) => {
|
const onChange = async (open: boolean) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -66,18 +68,16 @@ const UploadPlaylistModal = () => {
|
|||||||
const title = watch("title");
|
const title = watch("title");
|
||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
const image = watch("image");
|
const image = watch("image");
|
||||||
console.log({image})
|
|
||||||
let playlistImage = null
|
let playlistImage = null
|
||||||
if(image && image[0]){
|
if(image && image[0]){
|
||||||
try {
|
try {
|
||||||
const compressedImg = await compressImg(image[0])
|
const compressedImg = await compressImg(image[0])
|
||||||
playlistImage = 'data:image/webp;base64,' + compressedImg
|
playlistImage = 'data:image/webp;base64,' + compressedImg
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log({error})
|
console.error(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
console.log({title})
|
|
||||||
dispatch(setNewPlayList({
|
dispatch(setNewPlayList({
|
||||||
...newPlaylist,
|
...newPlaylist,
|
||||||
title,
|
title,
|
||||||
@@ -88,7 +88,6 @@ const UploadPlaylistModal = () => {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset();
|
|
||||||
uploadModal.onClose();
|
uploadModal.onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +114,7 @@ const UploadPlaylistModal = () => {
|
|||||||
})
|
})
|
||||||
if (!compressedFile) return
|
if (!compressedFile) return
|
||||||
const dataURI = await toBase64(compressedFile)
|
const dataURI = await toBase64(compressedFile)
|
||||||
if(!dataURI || typeof dataURI !== 'string') throw new Error('invalid image')
|
if(!dataURI || typeof dataURI !== 'string') throw new Error('Invalid image')
|
||||||
const base64Data = dataURI?.split(',')[1];
|
const base64Data = dataURI?.split(',')[1];
|
||||||
return base64Data
|
return base64Data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -131,7 +130,7 @@ const UploadPlaylistModal = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(!values.image?.[0] && !prevSavedImg){
|
if(!values.image?.[0] && !prevSavedImg){
|
||||||
toast.error('Please attach an image cover')
|
toast.error('Please select a cover image')
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +140,7 @@ const UploadPlaylistModal = () => {
|
|||||||
const imageFile = values.image?.[0];
|
const imageFile = values.image?.[0];
|
||||||
const title = values.title
|
const title = values.title
|
||||||
const description = values.description
|
const description = values.description
|
||||||
|
const isPrivate = newPlaylist?.service ? newPlaylist.service.startsWith('DOCUMENT_PRIVATE') : !!values.isPrivate;
|
||||||
if ((!imageFile && !prevSavedImg) || !username || !title || !description) {
|
if ((!imageFile && !prevSavedImg) || !username || !title || !description) {
|
||||||
toast.error('Missing fields')
|
toast.error('Missing fields')
|
||||||
return;
|
return;
|
||||||
@@ -165,7 +165,6 @@ const UploadPlaylistModal = () => {
|
|||||||
let identifier = newPlaylist?.id ? newPlaylist.id : `earbump_playlist_${cleanTitle}_${id}`
|
let identifier = newPlaylist?.id ? newPlaylist.id : `earbump_playlist_${cleanTitle}_${id}`
|
||||||
const descriptionSnipped = description.slice(0, 140)
|
const descriptionSnipped = description.slice(0, 140)
|
||||||
|
|
||||||
// const fileExtension = imageFile?.name?.split('.')?.pop()
|
|
||||||
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20)
|
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20)
|
||||||
const filename = `${fileTitle}.json`
|
const filename = `${fileTitle}.json`
|
||||||
const playlistObject = {
|
const playlistObject = {
|
||||||
@@ -174,60 +173,70 @@ const UploadPlaylistModal = () => {
|
|||||||
description,
|
description,
|
||||||
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg
|
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg
|
||||||
}
|
}
|
||||||
console.log({playlistObject})
|
|
||||||
|
|
||||||
|
|
||||||
const playlistToBase64 = await objectToBase64(playlistObject);
|
const playlistToBase64 = await objectToBase64(playlistObject);
|
||||||
const resources = [
|
if (isPrivate) {
|
||||||
{
|
if (!userPubkey) {
|
||||||
name: newPlaylist?.user ? newPlaylist?.user : username,
|
toast.error('Missing user public key for encryption');
|
||||||
service: 'PLAYLIST',
|
setIsLoading(false);
|
||||||
data64: playlistToBase64,
|
return;
|
||||||
title: title.slice(0, 55),
|
|
||||||
description: descriptionSnipped,
|
|
||||||
identifier: identifier,
|
|
||||||
filename
|
|
||||||
}
|
}
|
||||||
// {
|
const pubkeyArray = [userPubkey]
|
||||||
// name: username,
|
const publishPrivate = {
|
||||||
// service: 'THUMBNAIL',
|
action: 'PUBLISH_QDN_RESOURCE',
|
||||||
// data64: compressedImg,
|
name: newPlaylist?.user ? newPlaylist?.user : username,
|
||||||
// identifier: identifier
|
service: 'DOCUMENT_PRIVATE',
|
||||||
// }
|
data64: playlistToBase64,
|
||||||
]
|
title: title.slice(0, 55),
|
||||||
|
description: descriptionSnipped,
|
||||||
const multiplePublish = {
|
identifier: identifier,
|
||||||
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
|
filename,
|
||||||
resources: resources
|
encrypt: true,
|
||||||
|
publicKeys: pubkeyArray
|
||||||
|
} as const
|
||||||
|
await qortalRequest(publishPrivate)
|
||||||
|
} else {
|
||||||
|
const publishPublic = {
|
||||||
|
action: 'PUBLISH_QDN_RESOURCE',
|
||||||
|
name: newPlaylist?.user ? newPlaylist?.user : username,
|
||||||
|
service: 'PLAYLIST',
|
||||||
|
data64: playlistToBase64,
|
||||||
|
title: title.slice(0, 55),
|
||||||
|
description: descriptionSnipped,
|
||||||
|
identifier: identifier,
|
||||||
|
filename
|
||||||
|
} as const
|
||||||
|
await qortalRequest(publishPublic)
|
||||||
}
|
}
|
||||||
await qortalRequest(multiplePublish)
|
toast.success(isPrivate ? 'Private playlist published successfully!' : 'Playlist published successfully!');
|
||||||
toast.success('Song created!');
|
if (!isPrivate) {
|
||||||
if(newPlaylist?.id){
|
if(newPlaylist?.id){
|
||||||
//update playlist in store
|
dispatch(addToPlaylistHashMap(
|
||||||
dispatch(addToPlaylistHashMap(
|
{
|
||||||
|
user: newPlaylist?.user ? newPlaylist?.user : username,
|
||||||
|
service: 'PLAYLIST',
|
||||||
|
id: identifier,
|
||||||
|
filename,
|
||||||
|
songs: newPlaylist.songs,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg
|
||||||
|
}
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
dispatch(upsertPlaylists(
|
||||||
{
|
{
|
||||||
user: newPlaylist?.user ? newPlaylist?.user : username,
|
user: newPlaylist?.user ? newPlaylist?.user : username,
|
||||||
service: 'PLAYLIST',
|
service: 'PLAYLIST',
|
||||||
id: identifier,
|
id: identifier,
|
||||||
filename,
|
filename,
|
||||||
songs: newPlaylist.songs,
|
songs: newPlaylist.songs,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg}
|
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg
|
||||||
|
}
|
||||||
))
|
))
|
||||||
} else {
|
}
|
||||||
//add playlist to store
|
|
||||||
dispatch(upsertPlaylists(
|
|
||||||
{
|
|
||||||
user: newPlaylist?.user ? newPlaylist?.user : username,
|
|
||||||
service: 'PLAYLIST',
|
|
||||||
id: identifier,
|
|
||||||
filename,
|
|
||||||
songs: newPlaylist.songs,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg}
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
reset();
|
reset();
|
||||||
dispatch(setNewPlayList(null))
|
dispatch(setNewPlayList(null))
|
||||||
@@ -259,14 +268,14 @@ const UploadPlaylistModal = () => {
|
|||||||
|
|
||||||
if (songError) {
|
if (songError) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return toast.error('Failed song upload');
|
return toast.error('Failed to publish audio');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (imageError) {
|
if (imageError) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return toast.error('Failed image upload');
|
return toast.error('Failed to publish image');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -274,9 +283,9 @@ const UploadPlaylistModal = () => {
|
|||||||
|
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Something went wrong');
|
toast.error('Something went wrong');
|
||||||
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -295,8 +304,8 @@ const UploadPlaylistModal = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Save Playlist"
|
title="Publish Playlist"
|
||||||
description="Upload playlist- all fields are required"
|
description="All fields are required"
|
||||||
isOpen={uploadModal.isOpen}
|
isOpen={uploadModal.isOpen}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
>
|
>
|
||||||
@@ -307,18 +316,19 @@ const UploadPlaylistModal = () => {
|
|||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register('title', { required: true , maxLength: 35})}
|
maxLength={35}
|
||||||
placeholder="Playlist title"
|
{...register('title', { required: true })}
|
||||||
|
placeholder="Title"
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register('description', { required: true })}
|
{...register('description', { required: true })}
|
||||||
placeholder="Describe your playlist"
|
placeholder="Description"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="pb-1">
|
<div className="pb-1">
|
||||||
Select an image for the playlist
|
Select a cover image
|
||||||
</div>
|
</div>
|
||||||
{prevSavedImg ? <div className='flex items-center gap-1'>
|
{prevSavedImg ? <div className='flex items-center gap-1'>
|
||||||
<img src={prevSavedImg}/>
|
<img src={prevSavedImg}/>
|
||||||
@@ -341,7 +351,7 @@ const UploadPlaylistModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="pb-1">
|
<div className="pb-1">
|
||||||
Songs
|
Tracks
|
||||||
</div>
|
</div>
|
||||||
{newPlaylist?.songs?.map((song: SongReference)=> {
|
{newPlaylist?.songs?.map((song: SongReference)=> {
|
||||||
return (
|
return (
|
||||||
@@ -357,6 +367,15 @@ const UploadPlaylistModal = () => {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="isPrivate"
|
||||||
|
type="checkbox"
|
||||||
|
disabled={isLoading || !!newPlaylist?.id}
|
||||||
|
{...register('isPrivate')}
|
||||||
|
/>
|
||||||
|
<span>{newPlaylist?.id ? "Privacy can't be changed after creation" : "Private (encrypt to your public key)"}</span>
|
||||||
|
</label>
|
||||||
<Button disabled={isLoading} type="submit">
|
<Button disabled={isLoading} type="submit">
|
||||||
Publish
|
Publish
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+93
-109
@@ -49,6 +49,62 @@ export const useFetchSongs = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsePossibleJson = (val: any) => {
|
||||||
|
if (val == null) return null;
|
||||||
|
if (typeof val === 'object') return val;
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
try { return JSON.parse(val); } catch { }
|
||||||
|
try {
|
||||||
|
const jsonStr = atob(val);
|
||||||
|
return JSON.parse(jsonStr);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bytesToBase64 = (bytes: Uint8Array) => {
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptByService = async (service: string | undefined, raw: any) => {
|
||||||
|
const isPrivate = typeof service === 'string' && service.endsWith('_PRIVATE');
|
||||||
|
if (!isPrivate) {
|
||||||
|
return parsePossibleJson(raw) ?? raw;
|
||||||
|
}
|
||||||
|
let encryptedBase64: string | null = null;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
encryptedBase64 = raw;
|
||||||
|
} else if (raw?.data) {
|
||||||
|
try {
|
||||||
|
encryptedBase64 = bytesToBase64(new Uint8Array(raw.data));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
const decrypted = await qortalRequest({
|
||||||
|
action: 'DECRYPT_DATA',
|
||||||
|
encryptedData: encryptedBase64 ?? raw
|
||||||
|
});
|
||||||
|
if (decrypted?.data) {
|
||||||
|
try {
|
||||||
|
const jsonStr = new TextDecoder().decode(new Uint8Array(decrypted.data));
|
||||||
|
return parsePossibleJson(jsonStr) ?? jsonStr;
|
||||||
|
} catch { return decrypted; }
|
||||||
|
}
|
||||||
|
if (typeof decrypted === 'string') {
|
||||||
|
const parsed = parsePossibleJson(decrypted);
|
||||||
|
if (parsed) return parsed;
|
||||||
|
try {
|
||||||
|
const bin = atob(decrypted);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
const jsonStr = new TextDecoder().decode(bytes);
|
||||||
|
return parsePossibleJson(jsonStr) ?? jsonStr;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
};
|
||||||
|
|
||||||
const getYourLibrary = useCallback(async (name: string) => {
|
const getYourLibrary = useCallback(async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const offset = songListLibrary.length
|
const offset = songListLibrary.length
|
||||||
@@ -62,28 +118,18 @@ export const useFetchSongs = () => {
|
|||||||
const responseData = await response.json()
|
const responseData = await response.json()
|
||||||
const structureData = responseData.map((song: any): SongMeta => {
|
const structureData = responseData.map((song: any): SongMeta => {
|
||||||
const description = song?.metadata?.description || ""
|
const description = song?.metadata?.description || ""
|
||||||
let pairs: string[] = description?.split(';'); // Splits the string into an array based on the semicolon.
|
let pairs: string[] = description?.split(';');
|
||||||
|
|
||||||
// Define an empty object to hold your title and author
|
|
||||||
let obj: { [key: string]: string } = {};
|
let obj: { [key: string]: string } = {};
|
||||||
|
|
||||||
// Loop through the pairs and further split them on the equals sign.
|
|
||||||
for (let i = 0; i < pairs.length; i++) {
|
for (let i = 0; i < pairs.length; i++) {
|
||||||
let pair: string[] = pairs[i].split('=');
|
let pair: string[] = pairs[i].split('=');
|
||||||
|
|
||||||
// Ensure the pair is a key-value pair before assignment
|
|
||||||
if (pair.length !== 2) {
|
if (pair.length !== 2) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let key: string = pair[0].trim();
|
||||||
let key: string = pair[0].trim(); // remove whitespace
|
let value: string = pair[1].trim();
|
||||||
let value: string = pair[1].trim(); // remove whitespace
|
|
||||||
|
|
||||||
// Ensure the key is either 'title' or 'author' before assignment
|
|
||||||
if (key !== 'title' && key !== 'author') {
|
if (key !== 'title' && key !== 'author') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -99,13 +145,10 @@ export const useFetchSongs = () => {
|
|||||||
})
|
})
|
||||||
dispatch(upsertMyLibrary(structureData))
|
dispatch(upsertMyLibrary(structureData))
|
||||||
|
|
||||||
|
|
||||||
for (const content of structureData) {
|
for (const content of structureData) {
|
||||||
if (content.name && content.id) {
|
if (content.name && content.id) {
|
||||||
|
|
||||||
if (!imageCoverHash[content.id]) {
|
if (!imageCoverHash[content.id]) {
|
||||||
queueFetchAvatars.push(() => getImgCover(content.id, content.name))
|
queueFetchAvatars.push(() => getImgCover(content.id, content.name))
|
||||||
// getImgCover(content.id, content.name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,28 +176,18 @@ export const useFetchSongs = () => {
|
|||||||
const responseData = await response.json()
|
const responseData = await response.json()
|
||||||
const structureData = responseData.map((song: any): SongMeta => {
|
const structureData = responseData.map((song: any): SongMeta => {
|
||||||
const description = song?.metadata?.description || ""
|
const description = song?.metadata?.description || ""
|
||||||
let pairs: string[] = description?.split(';'); // Splits the string into an array based on the semicolon.
|
let pairs: string[] = description?.split(';');
|
||||||
|
|
||||||
// Define an empty object to hold your title and author
|
|
||||||
let obj: { [key: string]: string } = {};
|
let obj: { [key: string]: string } = {};
|
||||||
|
|
||||||
// Loop through the pairs and further split them on the equals sign.
|
|
||||||
for (let i = 0; i < pairs.length; i++) {
|
for (let i = 0; i < pairs.length; i++) {
|
||||||
let pair: string[] = pairs[i].split('=');
|
let pair: string[] = pairs[i].split('=');
|
||||||
|
|
||||||
// Ensure the pair is a key-value pair before assignment
|
|
||||||
if (pair.length !== 2) {
|
if (pair.length !== 2) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key: string = pair[0].trim(); // remove whitespace
|
let key: string = pair[0].trim(); // remove whitespace
|
||||||
let value: string = pair[1].trim(); // remove whitespace
|
let value: string = pair[1].trim(); // remove whitespace
|
||||||
|
|
||||||
// Ensure the key is either 'title' or 'author' before assignment
|
|
||||||
if (key !== 'title' && key !== 'author') {
|
if (key !== 'title' && key !== 'author') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -170,13 +203,10 @@ export const useFetchSongs = () => {
|
|||||||
})
|
})
|
||||||
dispatch(upsertQueried(structureData))
|
dispatch(upsertQueried(structureData))
|
||||||
|
|
||||||
|
|
||||||
for (const content of structureData) {
|
for (const content of structureData) {
|
||||||
if (content.name && content.id) {
|
if (content.name && content.id) {
|
||||||
|
|
||||||
if (!imageCoverHash[content.id]) {
|
if (!imageCoverHash[content.id]) {
|
||||||
queueFetchAvatars.push(() => getImgCover(content.id, content.name))
|
queueFetchAvatars.push(() => getImgCover(content.id, content.name))
|
||||||
// getImgCover(content.id, content.name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,28 +234,18 @@ export const useFetchSongs = () => {
|
|||||||
const data = responseData[0]
|
const data = responseData[0]
|
||||||
|
|
||||||
const description = data?.metadata?.description || ""
|
const description = data?.metadata?.description || ""
|
||||||
let pairs: string[] = description?.split(';'); // Splits the string into an array based on the semicolon.
|
let pairs: string[] = description?.split(';');
|
||||||
|
|
||||||
// Define an empty object to hold your title and author
|
|
||||||
let obj: { [key: string]: string } = {};
|
let obj: { [key: string]: string } = {};
|
||||||
|
|
||||||
// Loop through the pairs and further split them on the equals sign.
|
|
||||||
for (let i = 0; i < pairs.length; i++) {
|
for (let i = 0; i < pairs.length; i++) {
|
||||||
let pair: string[] = pairs[i].split('=');
|
let pair: string[] = pairs[i].split('=');
|
||||||
|
|
||||||
// Ensure the pair is a key-value pair before assignment
|
|
||||||
if (pair.length !== 2) {
|
if (pair.length !== 2) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let key: string = pair[0].trim();
|
||||||
let key: string = pair[0].trim(); // remove whitespace
|
let value: string = pair[1].trim();
|
||||||
let value: string = pair[1].trim(); // remove whitespace
|
|
||||||
|
|
||||||
// Ensure the key is either 'title' or 'author' before assignment
|
|
||||||
if (key !== 'title' && key !== 'author') {
|
if (key !== 'title' && key !== 'author') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
}
|
}
|
||||||
const object = {
|
const object = {
|
||||||
@@ -240,22 +260,15 @@ export const useFetchSongs = () => {
|
|||||||
}
|
}
|
||||||
songsToSet.push(object)
|
songsToSet.push(object)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!imageCoverHash[object.id]) {
|
if (!imageCoverHash[object.id]) {
|
||||||
queueFetchAvatars.push(() => getImgCover(object.id, object.name))
|
queueFetchAvatars.push(() => getImgCover(object.id, object.name))
|
||||||
// getImgCover(object.id, object.name)
|
|
||||||
}
|
}
|
||||||
|
} catch (error) { } finally { }
|
||||||
} catch (error) {
|
|
||||||
} finally {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(upsertFavorite(songsToSet))
|
dispatch(upsertFavorite(songsToSet))
|
||||||
|
|
||||||
}, [imageCoverHash, songList, favoriteList]);
|
}, [imageCoverHash, songList, favoriteList]);
|
||||||
|
|
||||||
|
|
||||||
const getRecentSongs = useCallback(async (offsetParam?: number, limitParam?: number) => {
|
const getRecentSongs = useCallback(async (offsetParam?: number, limitParam?: number) => {
|
||||||
try {
|
try {
|
||||||
const offset = offsetParam ?? songListRecent.length
|
const offset = offsetParam ?? songListRecent.length
|
||||||
@@ -270,32 +283,21 @@ export const useFetchSongs = () => {
|
|||||||
const responseData = await response.json()
|
const responseData = await response.json()
|
||||||
const structureData = responseData.map((song: any): SongMeta => {
|
const structureData = responseData.map((song: any): SongMeta => {
|
||||||
const description = song?.metadata?.description || ""
|
const description = song?.metadata?.description || ""
|
||||||
let pairs: string[] = description?.split(';'); // Splits the string into an array based on the semicolon.
|
let pairs: string[] = description?.split(';');
|
||||||
|
|
||||||
// Define an empty object to hold your title and author
|
|
||||||
let obj: { [key: string]: string } = {};
|
let obj: { [key: string]: string } = {};
|
||||||
|
|
||||||
// Loop through the pairs and further split them on the equals sign.
|
|
||||||
for (let i = 0; i < pairs.length; i++) {
|
for (let i = 0; i < pairs.length; i++) {
|
||||||
let pair: string[] = pairs[i].split('=');
|
let pair: string[] = pairs[i].split('=');
|
||||||
|
|
||||||
// Ensure the pair is a key-value pair before assignment
|
|
||||||
if (pair.length !== 2) {
|
if (pair.length !== 2) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let key: string = pair[0].trim();
|
||||||
let key: string = pair[0].trim(); // remove whitespace
|
let value: string = pair[1].trim();
|
||||||
let value: string = pair[1].trim(); // remove whitespace
|
|
||||||
|
|
||||||
// Ensure the key is either 'title' or 'author' before assignment
|
|
||||||
if (key !== 'title' && key !== 'author') {
|
if (key !== 'title' && key !== 'author') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: song?.metadata?.title,
|
title: song?.metadata?.title,
|
||||||
description: song?.metadata?.description,
|
description: song?.metadata?.description,
|
||||||
@@ -310,10 +312,8 @@ export const useFetchSongs = () => {
|
|||||||
dispatch(upsertRecent(structureData))
|
dispatch(upsertRecent(structureData))
|
||||||
for (const content of structureData) {
|
for (const content of structureData) {
|
||||||
if (content.name && content.id) {
|
if (content.name && content.id) {
|
||||||
|
|
||||||
if (!imageCoverHash[content.id]) {
|
if (!imageCoverHash[content.id]) {
|
||||||
queueFetchAvatars.push(() => getImgCover(content.id, content.name))
|
queueFetchAvatars.push(() => getImgCover(content.id, content.name))
|
||||||
// getImgCover(content.id, content.name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,15 +322,12 @@ export const useFetchSongs = () => {
|
|||||||
}
|
}
|
||||||
}, [songListRecent, imageCoverHash]);
|
}, [songListRecent, imageCoverHash]);
|
||||||
|
|
||||||
|
|
||||||
const checkStructure = (content: any) => {
|
const checkStructure = (content: any) => {
|
||||||
let isValid = true
|
let isValid = true
|
||||||
|
|
||||||
return isValid
|
return isValid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const fetchAndEvaluatePlaylists = async (data: any) => {
|
const fetchAndEvaluatePlaylists = async (data: any) => {
|
||||||
const getPlaylist = async () => {
|
const getPlaylist = async () => {
|
||||||
const { user, playlistId, content } = data
|
const { user, playlistId, content } = data
|
||||||
@@ -338,47 +335,45 @@ export const useFetchSongs = () => {
|
|||||||
...content,
|
...content,
|
||||||
isValid: false
|
isValid: false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !playlistId) return obj
|
if (!user || !playlistId) return obj
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const isPrivate = typeof content?.service === 'string' && content.service.endsWith('_PRIVATE');
|
||||||
const responseData = await qortalRequest({
|
const serviceToFetch = isPrivate ? content.service : 'PLAYLIST';
|
||||||
|
const fetched = await qortalRequest({
|
||||||
action: 'FETCH_QDN_RESOURCE',
|
action: 'FETCH_QDN_RESOURCE',
|
||||||
name: user,
|
name: user,
|
||||||
service: 'PLAYLIST',
|
service: serviceToFetch,
|
||||||
identifier: playlistId
|
identifier: playlistId,
|
||||||
})
|
...(isPrivate ? { encoding: 'base64' } : {})
|
||||||
|
});
|
||||||
|
const responseData = await decryptByService(serviceToFetch, fetched);
|
||||||
if (checkStructure(responseData)) {
|
if (checkStructure(responseData)) {
|
||||||
obj = {
|
obj = {
|
||||||
...content,
|
...content,
|
||||||
...responseData,
|
...responseData,
|
||||||
isValid: true
|
isValid: true
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
return obj
|
return obj;
|
||||||
} catch (error) { }
|
} catch (error) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getPlaylist()
|
const res = await getPlaylist()
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const getPlaylist = async (user: string, playlistId: string, content: any) => {
|
const getPlaylist = async (user: string, playlistId: string, content: any) => {
|
||||||
const res = await fetchAndEvaluatePlaylists({
|
const res = await fetchAndEvaluatePlaylists({
|
||||||
user,
|
user,
|
||||||
playlistId,
|
playlistId,
|
||||||
content
|
content
|
||||||
})
|
})
|
||||||
|
|
||||||
dispatch(addToPlaylistHashMap(res))
|
dispatch(addToPlaylistHashMap(res))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const checkAndUpdatePlaylist = React.useCallback(
|
const checkAndUpdatePlaylist = React.useCallback(
|
||||||
(playlist: PlayList) => {
|
(playlist: PlayList) => {
|
||||||
|
|
||||||
const existingPlaylist = playlistHash[playlist.id]
|
const existingPlaylist = playlistHash[playlist.id]
|
||||||
if (!existingPlaylist) {
|
if (!existingPlaylist) {
|
||||||
return true
|
return true
|
||||||
@@ -418,7 +413,8 @@ export const useFetchSongs = () => {
|
|||||||
user: playlist.name,
|
user: playlist.name,
|
||||||
image: '',
|
image: '',
|
||||||
songs: [],
|
songs: [],
|
||||||
id: playlist.identifier
|
id: playlist.identifier,
|
||||||
|
service: playlist?.service
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
dispatch(upsertPlaylists(structureData))
|
dispatch(upsertPlaylists(structureData))
|
||||||
@@ -441,14 +437,12 @@ export const useFetchSongs = () => {
|
|||||||
const getRandomPlaylist = useCallback(async () => {
|
const getRandomPlaylist = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const url = `/arbitrary/resources/search?mode=ALL&service=PLAYLIST&query=earbump_playlist_&limit=50&includemetadata=false&offset=${0}&reverse=true&excludeblocked=false&includestatus=false`
|
const url = `/arbitrary/resources/search?mode=ALL&service=PLAYLIST&query=earbump_playlist_&limit=50&includemetadata=false&offset=${0}&reverse=true&excludeblocked=false&includestatus=false`
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const responseData = await response.json();
|
const responseData = await response.json();
|
||||||
const length = responseData.length;
|
const length = responseData.length;
|
||||||
const randomIndex = Math.floor(Math.random() * length);
|
const randomIndex = Math.floor(Math.random() * length);
|
||||||
@@ -465,28 +459,23 @@ export const useFetchSongs = () => {
|
|||||||
user: randomItem.name,
|
user: randomItem.name,
|
||||||
image: '',
|
image: '',
|
||||||
songs: [],
|
songs: [],
|
||||||
id: randomItem.identifier
|
id: randomItem.identifier,
|
||||||
|
service: randomItem?.service
|
||||||
}
|
}
|
||||||
dispatch(setRandomPlaylist(structurePlaylist))
|
dispatch(setRandomPlaylist(structurePlaylist))
|
||||||
|
|
||||||
|
|
||||||
const res = checkAndUpdatePlaylist(structurePlaylist)
|
const res = checkAndUpdatePlaylist(structurePlaylist)
|
||||||
if (res) {
|
if (res) {
|
||||||
getPlaylist(structurePlaylist.user, structurePlaylist.id, structurePlaylist)
|
getPlaylist(structurePlaylist.user, structurePlaylist.id, structurePlaylist)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
} catch (error) { }
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
const getMyPlaylists = useCallback(async () => {
|
const getMyPlaylists = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!username) return
|
if (!username) return
|
||||||
const offset = myPlaylists.length
|
const offset = myPlaylists.length
|
||||||
const url = `/arbitrary/resources/search?mode=ALL&service=PLAYLIST&query=earbump_playlist_&limit=20&includemetadata=false&offset=${offset}&reverse=true&excludeblocked=true&includestatus=false&name=${username}&exactmatchnames=true`
|
const url = `/arbitrary/resources/search?mode=ALL&query=earbump_playlist_&limit=20&includemetadata=false&offset=${offset}&reverse=true&excludeblocked=true&includestatus=false&name=${username}&exactmatchnames=true`
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -506,7 +495,8 @@ export const useFetchSongs = () => {
|
|||||||
user: playlist.name,
|
user: playlist.name,
|
||||||
image: '',
|
image: '',
|
||||||
songs: [],
|
songs: [],
|
||||||
id: playlist.identifier
|
id: playlist.identifier,
|
||||||
|
service: playlist?.service
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
dispatch(upsertMyPlaylists(structureData))
|
dispatch(upsertMyPlaylists(structureData))
|
||||||
@@ -516,14 +506,10 @@ export const useFetchSongs = () => {
|
|||||||
const res = checkAndUpdatePlaylist(content)
|
const res = checkAndUpdatePlaylist(content)
|
||||||
if (res) {
|
if (res) {
|
||||||
getPlaylist(content.user, content.id, content)
|
getPlaylist(content.user, content.id, content)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) { } finally { }
|
||||||
} catch (error) {
|
|
||||||
} finally {
|
|
||||||
}
|
|
||||||
}, [myPlaylists, imageCoverHash, username]);
|
}, [myPlaylists, imageCoverHash, username]);
|
||||||
|
|
||||||
const getPlaylistsQueried = useCallback(async () => {
|
const getPlaylistsQueried = useCallback(async () => {
|
||||||
@@ -553,7 +539,8 @@ export const useFetchSongs = () => {
|
|||||||
user: playlist.name,
|
user: playlist.name,
|
||||||
image: '',
|
image: '',
|
||||||
songs: [],
|
songs: [],
|
||||||
id: playlist.identifier
|
id: playlist.identifier,
|
||||||
|
service: playlist?.service
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
dispatch(upsertQueriedPlaylist(structureData))
|
dispatch(upsertQueriedPlaylist(structureData))
|
||||||
@@ -568,12 +555,9 @@ export const useFetchSongs = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) { } finally { }
|
||||||
} finally {
|
|
||||||
}
|
|
||||||
}, [playlistQueried, imageCoverHash, queriedValuePlaylist]);
|
}, [playlistQueried, imageCoverHash, queriedValuePlaylist]);
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getRecentSongs,
|
getRecentSongs,
|
||||||
getYourLibrary,
|
getYourLibrary,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export const useIframe = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
useEffect(() => {
|
||||||
|
function handleNavigation(event: MessageEvent) {
|
||||||
|
if (event.data?.action === "NAVIGATE_TO_PATH" && event.data.path) {
|
||||||
|
console.log("Navigating to path within React app:", event.data.path);
|
||||||
|
navigate(event.data.path); // Navigate directly to the specified path
|
||||||
|
|
||||||
|
// Send a response back to the parent window after navigation is handled
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ action: "NAVIGATION_SUCCESS", path: event.data.path },
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", handleNavigation);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", handleNavigation);
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
return { navigate };
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface UploadFolderModalStore {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useUploadFolderModal = create<UploadFolderModalStore>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
onOpen: () => set({ isOpen: true }),
|
||||||
|
onClose: () => set({ isOpen: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useUploadFolderModal;
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useMemo, useState , useRef, useEffect, useCallback} from 'react'
|
import React, { useMemo, useState , useRef, useEffect, useCallback} from 'react'
|
||||||
|
import { useDispatch } from 'react-redux'
|
||||||
|
import { clearSongListLibrary } from '../../state/features/globalSlice'
|
||||||
import Header from '../../components/Header'
|
import Header from '../../components/Header'
|
||||||
import ListItem from '../../components/ListItem'
|
import ListItem from '../../components/ListItem'
|
||||||
import likeImg from '../../assets/img/liked.png'
|
import likeImg from '../../assets/img/liked.png'
|
||||||
@@ -17,6 +19,8 @@ import useOnPlay from '../../hooks/useOnPlay'
|
|||||||
import { MyPlaylists } from '../Playlists/MyPlaylists'
|
import { MyPlaylists } from '../Playlists/MyPlaylists'
|
||||||
export const Library = () => {
|
export const Library = () => {
|
||||||
const initialFetch = useRef(false)
|
const initialFetch = useRef(false)
|
||||||
|
const prevNameRef = useRef<string | undefined>(undefined)
|
||||||
|
const dispatch = useDispatch()
|
||||||
const username = useSelector((state: RootState) => state?.auth?.user?.name);
|
const username = useSelector((state: RootState) => state?.auth?.user?.name);
|
||||||
const songListLibrary = useSelector((state: RootState) => state?.global.songListLibrary);
|
const songListLibrary = useSelector((state: RootState) => state?.global.songListLibrary);
|
||||||
const [mode, setMode] = useState<string>('songs')
|
const [mode, setMode] = useState<string>('songs')
|
||||||
@@ -45,13 +49,18 @@ export const Library = () => {
|
|||||||
}, [username, getYourLibrary])
|
}, [username, getYourLibrary])
|
||||||
|
|
||||||
useEffect(()=> {
|
useEffect(()=> {
|
||||||
if(username && !initialFetch.current){
|
if (!username) return;
|
||||||
fetchMyLibrary()
|
if (prevNameRef.current && prevNameRef.current !== username) {
|
||||||
|
dispatch(clearSongListLibrary());
|
||||||
|
initialFetch.current = false;
|
||||||
|
fetchMyLibrary();
|
||||||
|
} else if (!initialFetch.current) {
|
||||||
|
fetchMyLibrary();
|
||||||
}
|
}
|
||||||
|
prevNameRef.current = username;
|
||||||
|
}, [username, fetchMyLibrary, dispatch])
|
||||||
|
|
||||||
}, [username])
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
bg-neutral-900
|
bg-neutral-900
|
||||||
@@ -113,8 +122,8 @@ export const Library = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SearchContent songs={songListLibrary} />
|
<SearchContent key={`songs-${username || 'anon'}`} songs={songListLibrary} />
|
||||||
<LazyLoad onLoadMore={fetchMyLibrary}></LazyLoad>
|
<LazyLoad key={`lazy-${username || 'anon'}`} onLoadMore={fetchMyLibrary}></LazyLoad>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
import Header from '../../components/Header'
|
import Header from '../../components/Header'
|
||||||
import ListItem from '../../components/ListItem'
|
import ListItem from '../../components/ListItem'
|
||||||
import likeImg from '../../assets/img/liked.png'
|
import likeImg from '../../assets/img/liked.png'
|
||||||
@@ -7,20 +7,31 @@ import SearchInput from '../../components/SearchInput'
|
|||||||
import SearchContent from '../../components/SearchContent'
|
import SearchContent from '../../components/SearchContent'
|
||||||
import LazyLoad from '../../components/common/LazyLoad'
|
import LazyLoad from '../../components/common/LazyLoad'
|
||||||
import { useFetchSongs } from '../../hooks/fetchSongs'
|
import { useFetchSongs } from '../../hooks/fetchSongs'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector, useDispatch } from 'react-redux'
|
||||||
import { RootState } from '../../state/store'
|
import { RootState } from '../../state/store'
|
||||||
|
import { clearMyPlaylists } from '../../state/features/globalSlice'
|
||||||
import { PlayListsContent } from '../../components/PlaylistsContent'
|
import { PlayListsContent } from '../../components/PlaylistsContent'
|
||||||
import { SearchInputPlaylist } from '../../components/SearchInputPlaylist'
|
import { SearchInputPlaylist } from '../../components/SearchInputPlaylist'
|
||||||
export const MyPlaylists = () => {
|
export const MyPlaylists = () => {
|
||||||
const { getMyPlaylists
|
const { getMyPlaylists } = useFetchSongs()
|
||||||
} = useFetchSongs()
|
|
||||||
const myPlaylists = useSelector((state: RootState) => state.global.myPlaylists);
|
const myPlaylists = useSelector((state: RootState) => state.global.myPlaylists);
|
||||||
|
const username = useSelector((state: RootState) => state?.auth?.user?.name);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const prevNameRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
console.log({myPlaylists})
|
console.log({myPlaylists})
|
||||||
|
|
||||||
let playlistsToRender = myPlaylists
|
let playlistsToRender = myPlaylists
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
const switched = prevNameRef.current && prevNameRef.current !== username;
|
||||||
|
if (!prevNameRef.current || switched) {
|
||||||
|
dispatch(clearMyPlaylists());
|
||||||
|
getMyPlaylists();
|
||||||
|
}
|
||||||
|
prevNameRef.current = username;
|
||||||
|
}, [username, dispatch, getMyPlaylists]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -29,8 +40,8 @@ export const MyPlaylists = () => {
|
|||||||
Playlists
|
Playlists
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<PlayListsContent playlists={playlistsToRender} />
|
<PlayListsContent key={`pl-${username || 'anon'}`} playlists={playlistsToRender} />
|
||||||
<LazyLoad onLoadMore={getMyPlaylists}></LazyLoad>
|
<LazyLoad key={`pl-lazy-${username || 'anon'}`} onLoadMore={getMyPlaylists}></LazyLoad>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ interface AuthState {
|
|||||||
address: string;
|
address: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
names?: string[];
|
||||||
|
primaryName?: string;
|
||||||
|
selectedName?: string;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
@@ -19,9 +22,15 @@ export const authSlice = createSlice({
|
|||||||
addUser: (state, action) => {
|
addUser: (state, action) => {
|
||||||
state.user = action.payload;
|
state.user = action.payload;
|
||||||
},
|
},
|
||||||
|
setSelectedName: (state, action) => {
|
||||||
|
if (state.user) {
|
||||||
|
state.user.selectedName = action.payload;
|
||||||
|
state.user.name = action.payload;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { addUser } = authSlice.actions;
|
export const { addUser, setSelectedName } = authSlice.actions;
|
||||||
|
|
||||||
export default authSlice.reducer;
|
export default authSlice.reducer;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit'
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
import { Song } from '../../types'
|
import { Song } from '../../types'
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ interface GlobalState {
|
|||||||
queriedValue: string;
|
queriedValue: string;
|
||||||
queriedValuePlaylist: string;
|
queriedValuePlaylist: string;
|
||||||
currentSong: string | null
|
currentSong: string | null
|
||||||
|
editingSong: Song | null;
|
||||||
favorites: null | Favorites
|
favorites: null | Favorites
|
||||||
favoritesPlaylist: null | PlayList[]
|
favoritesPlaylist: null | PlayList[]
|
||||||
favoriteList: Song[]
|
favoriteList: Song[]
|
||||||
@@ -41,6 +42,7 @@ interface GlobalState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayList extends ResourceBase {
|
export interface PlayList extends ResourceBase {
|
||||||
|
service: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
songs: SongReference[];
|
songs: SongReference[];
|
||||||
@@ -98,6 +100,7 @@ const initialState: GlobalState = {
|
|||||||
queriedValue: "",
|
queriedValue: "",
|
||||||
queriedValuePlaylist: "",
|
queriedValuePlaylist: "",
|
||||||
currentSong: null,
|
currentSong: null,
|
||||||
|
editingSong: null,
|
||||||
favorites: null,
|
favorites: null,
|
||||||
favoriteList: [],
|
favoriteList: [],
|
||||||
nowPlayingPlaylist: [],
|
nowPlayingPlaylist: [],
|
||||||
@@ -222,7 +225,9 @@ export const globalSlice = createSlice({
|
|||||||
setCurrentSong: (state, action) => {
|
setCurrentSong: (state, action) => {
|
||||||
state.currentSong = action.payload
|
state.currentSong = action.payload
|
||||||
},
|
},
|
||||||
|
setEditingSong: (state, action: PayloadAction<Song | null>) => {
|
||||||
|
state.editingSong = action.payload;
|
||||||
|
},
|
||||||
setQueriedValue: (state, action) => {
|
setQueriedValue: (state, action) => {
|
||||||
state.queriedValue = action.payload
|
state.queriedValue = action.payload
|
||||||
},
|
},
|
||||||
@@ -308,6 +313,12 @@ export const globalSlice = createSlice({
|
|||||||
setRandomPlaylist: (state, action) => {
|
setRandomPlaylist: (state, action) => {
|
||||||
state.randomPlaylist = action.payload
|
state.randomPlaylist = action.payload
|
||||||
},
|
},
|
||||||
|
clearSongListLibrary(state) {
|
||||||
|
state.songListLibrary = [];
|
||||||
|
},
|
||||||
|
clearMyPlaylists(state) {
|
||||||
|
state.myPlaylists = [];
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -320,6 +331,7 @@ export const {
|
|||||||
upsertMyLibrary,
|
upsertMyLibrary,
|
||||||
upsertRecent,
|
upsertRecent,
|
||||||
setCurrentSong,
|
setCurrentSong,
|
||||||
|
setEditingSong,
|
||||||
upsertQueried,
|
upsertQueried,
|
||||||
setQueriedValue,
|
setQueriedValue,
|
||||||
resetQueriedList,
|
resetQueriedList,
|
||||||
@@ -342,7 +354,9 @@ export const {
|
|||||||
setFavoritesFromStoragePlaylists,
|
setFavoritesFromStoragePlaylists,
|
||||||
setFavPlaylist,
|
setFavPlaylist,
|
||||||
removeFavPlaylist,
|
removeFavPlaylist,
|
||||||
setRandomPlaylist
|
setRandomPlaylist,
|
||||||
|
clearSongListLibrary,
|
||||||
|
clearMyPlaylists
|
||||||
} = globalSlice.actions
|
} = globalSlice.actions
|
||||||
|
|
||||||
export default globalSlice.reducer
|
export default globalSlice.reducer
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface Song {
|
|||||||
title: string;
|
title: string;
|
||||||
name: string;
|
name: string;
|
||||||
service: string;
|
service: string;
|
||||||
|
created?: number;
|
||||||
|
updated?: number;
|
||||||
status?: Status
|
status?: Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface NameRecord {
|
||||||
|
name: string
|
||||||
|
owner: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountNames(address: string): Promise<string[]> {
|
||||||
|
const res = await qortalRequest({ action: 'GET_ACCOUNT_NAMES', address });
|
||||||
|
return (res || []).map((r: any) => r.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPrimaryAccountName(address: string): Promise<string> {
|
||||||
|
const res = await qortalRequest({ action: 'GET_PRIMARY_NAME', address });
|
||||||
|
return typeof res === 'string' ? res : '';
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
|
|||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
import { addUser } from "../state/features/authSlice";
|
import { addUser } from "../state/features/authSlice";
|
||||||
|
import { getAccountNames, getPrimaryAccountName } from "../utils/qortalRequestFunctions";
|
||||||
import NavBar from "../components/layout/Navbar/Navbar";
|
import NavBar from "../components/layout/Navbar/Navbar";
|
||||||
import PageLoader from "../components/common/PageLoader";
|
import PageLoader from "../components/common/PageLoader";
|
||||||
import { RootState } from "../state/store";
|
import { RootState } from "../state/store";
|
||||||
@@ -32,21 +33,17 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
|||||||
const songHash = useSelector((state: RootState) => state.global.songHash);
|
const songHash = useSelector((state: RootState) => state.global.songHash);
|
||||||
const imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
|
const imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
|
||||||
const songListRecent = useSelector((state: RootState) => state.global.imageCoverHash);
|
const songListRecent = useSelector((state: RootState) => state.global.imageCoverHash);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.name) return;
|
if (!user?.selectedName && !user?.name) return;
|
||||||
|
|
||||||
getAvatar();
|
getAvatar();
|
||||||
}, [user?.name]);
|
}, [user?.selectedName, user?.name]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const getAvatar = async () => {
|
const getAvatar = async () => {
|
||||||
try {
|
try {
|
||||||
let url = await qortalRequest({
|
let url = await qortalRequest({
|
||||||
action: "GET_QDN_RESOURCE_URL",
|
action: "GET_QDN_RESOURCE_URL",
|
||||||
name: user?.name,
|
name: user?.selectedName || user?.name,
|
||||||
service: "THUMBNAIL",
|
service: "THUMBNAIL",
|
||||||
identifier: "qortal_avatar"
|
identifier: "qortal_avatar"
|
||||||
});
|
});
|
||||||
@@ -63,28 +60,25 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
|||||||
isLoadingGlobal,
|
isLoadingGlobal,
|
||||||
} = useSelector((state: RootState) => state.global);
|
} = useSelector((state: RootState) => state.global);
|
||||||
|
|
||||||
async function getNameInfo(address: string) {
|
|
||||||
const response = await fetch("/names/address/" + address);
|
|
||||||
const nameData = await response.json();
|
|
||||||
|
|
||||||
if (nameData?.length > 0) {
|
|
||||||
return nameData[0].name;
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const askForAccountInformation = React.useCallback(async () => {
|
const askForAccountInformation = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
let account = await qortalRequest({
|
let account = await qortalRequest({
|
||||||
action: "GET_USER_ACCOUNT"
|
action: "GET_USER_ACCOUNT"
|
||||||
});
|
});
|
||||||
|
|
||||||
const name = await getNameInfo(account.address);
|
const [names, primaryName] = await Promise.all([
|
||||||
dispatch(addUser({ ...account, name }));
|
getAccountNames(account.address),
|
||||||
|
getPrimaryAccountName(account.address)
|
||||||
|
]);
|
||||||
|
const selectedName = primaryName || (names && names[0]) || "";
|
||||||
|
dispatch(addUser({
|
||||||
|
...account,
|
||||||
|
names,
|
||||||
|
primaryName,
|
||||||
|
selectedName,
|
||||||
|
name: selectedName,
|
||||||
|
publicKey: account.publicKey
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import UploadModal from "../components/UploadModal";
|
import UploadModal from "../components/UploadModal";
|
||||||
import UploadPlaylistModal from "../components/UploadPlaylistModal";
|
import UploadPlaylistModal from "../components/UploadPlaylistModal";
|
||||||
|
import UploadFolderModal from "../components/UploadFolderModal";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { RootState } from "../state/store";
|
import { RootState } from "../state/store";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const ModalProvider: React.FC<ModalProviderProps> = () => {
|
|||||||
{newPlaylist && (
|
{newPlaylist && (
|
||||||
<UploadPlaylistModal />
|
<UploadPlaylistModal />
|
||||||
)}
|
)}
|
||||||
|
<UploadFolderModal />
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user