forked from Qortal/ear-bump
update #1
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { useIframe } from './hooks/useIframe'
|
||||
import { StoreList } from "./pages/StoreList/StoreList";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { CssBaseline } from "@mui/material";
|
||||
@@ -26,6 +27,7 @@ function App() {
|
||||
// const themeColor = window._qdnTheme
|
||||
|
||||
const [theme, setTheme] = useState("dark");
|
||||
useIframe()
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
|
||||
import { TbPlaylist } from "react-icons/tb";
|
||||
import { AiOutlinePlus } from "react-icons/ai";
|
||||
import { BsFolderPlus } from "react-icons/bs";
|
||||
import { toast } from "react-hot-toast";
|
||||
import MediaItem from "./MediaItem";
|
||||
import { Song } from "../types";
|
||||
import useUploadModal from "../hooks/useUploadModal";
|
||||
import useUploadPlaylistModal from "../hooks/useUploadPlaylistModal";
|
||||
import useUploadFolderModal from "../hooks/useUploadFolderModal";
|
||||
import useOnPlay from "../hooks/useOnPlay";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
@@ -30,6 +32,7 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
||||
const dispatch = useDispatch()
|
||||
const uploadModal = useUploadModal();
|
||||
const uploadPlaylistModal = useUploadPlaylistModal()
|
||||
const uploadFolderModal = useUploadFolderModal()
|
||||
|
||||
const onClick = () => {
|
||||
if (!username) {
|
||||
@@ -48,26 +51,28 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
||||
songs: [],
|
||||
image: null
|
||||
}))
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
const onClickFolder = () => {
|
||||
if (!username) {
|
||||
toast.error('Please authenticate')
|
||||
return
|
||||
}
|
||||
uploadFolderModal.onOpen();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<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">
|
||||
<BsMusicNote className="text-neutral-400" size={26} />
|
||||
<p className="text-neutral-400 font-medium text-md">
|
||||
<BsMusicNote 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 Song
|
||||
</p>
|
||||
</div>
|
||||
<AiOutlinePlus
|
||||
onClick={onClick}
|
||||
size={20}
|
||||
className="
|
||||
text-neutral-400
|
||||
@@ -77,15 +82,14 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
||||
"
|
||||
/>
|
||||
</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">
|
||||
<BsMusicNoteList className="text-neutral-400" size={26} />
|
||||
<p className="text-neutral-400 font-medium text-md">
|
||||
<BsMusicNoteList 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 Playlist
|
||||
</p>
|
||||
</div>
|
||||
<AiOutlinePlus
|
||||
onClick={onClickPlaylist}
|
||||
size={20}
|
||||
className="
|
||||
text-neutral-400
|
||||
@@ -96,6 +100,15 @@ export const AddLibrary: React.FC<LibraryProps> = ({
|
||||
/>
|
||||
</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 && (
|
||||
<Portal>
|
||||
<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"
|
||||
onClick={() => { uploadPlaylistModal.onOpen() }}
|
||||
>
|
||||
Save Playlist
|
||||
Edit Playlist
|
||||
</button>
|
||||
<button
|
||||
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 { RootState } from "../state/store";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useCallback } from "react";
|
||||
import { addUser } from "../state/features/authSlice";
|
||||
import { useCallback, useState } from "react";
|
||||
import { addUser, setSelectedName } from "../state/features/authSlice";
|
||||
import { getAccountNames, getPrimaryAccountName } from "../utils/qortalRequestFunctions";
|
||||
|
||||
|
||||
|
||||
@@ -22,28 +23,29 @@ const Header: React.FC<HeaderProps> = ({
|
||||
children,
|
||||
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()
|
||||
|
||||
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 [openNames, setOpenNames] = useState(false);
|
||||
|
||||
const askForAccountInformation = useCallback(async () => {
|
||||
try {
|
||||
let account = await qortalRequest({
|
||||
action: "GET_USER_ACCOUNT"
|
||||
});
|
||||
|
||||
const name = await getNameInfo(account.address);
|
||||
dispatch(addUser({ ...account, name }));
|
||||
const [namesList, primaryName] = await Promise.all([
|
||||
getAccountNames(account.address),
|
||||
getPrimaryAccountName(account.address)
|
||||
]);
|
||||
const chosen = primaryName || (namesList && namesList[0]) || "";
|
||||
dispatch(addUser({
|
||||
...account,
|
||||
names: namesList,
|
||||
primaryName,
|
||||
selectedName: chosen,
|
||||
name: chosen,
|
||||
publicKey: account.publicKey
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -127,9 +129,8 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-x-4">
|
||||
{!username && (
|
||||
{!selectedName && (
|
||||
<>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={()=> {
|
||||
@@ -142,6 +143,41 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</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>
|
||||
{children}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface ModalProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
@@ -14,8 +15,18 @@ const Modal: React.FC<ModalProps> = ({
|
||||
onChange,
|
||||
title,
|
||||
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 (
|
||||
<Dialog.Root open={isOpen} defaultOpen={isOpen} onOpenChange={onChange}>
|
||||
<Dialog.Portal>
|
||||
@@ -28,7 +39,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
"
|
||||
/>
|
||||
<Dialog.Content
|
||||
className="
|
||||
className={`
|
||||
fixed
|
||||
drop-shadow-md
|
||||
border
|
||||
@@ -40,8 +51,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
md:h-auto
|
||||
md:max-h-[85vh]
|
||||
w-full
|
||||
md:w-[90vw]
|
||||
md:max-w-[450px]
|
||||
${sizeClass}
|
||||
translate-x-[-50%]
|
||||
translate-y-[-50%]
|
||||
rounded-md
|
||||
@@ -49,7 +59,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
p-[25px]
|
||||
focus:outline-none
|
||||
overflow-y-auto
|
||||
">
|
||||
`}>
|
||||
<Dialog.Title
|
||||
className="
|
||||
text-xl
|
||||
|
||||
@@ -3,54 +3,53 @@ import { Song } from "../types";
|
||||
import { AddToPlaylistButton } from "./AddToPlayistButton";
|
||||
import LikeButton from "./LikeButton";
|
||||
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 {
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
const SearchContent: React.FC<SearchContentProps> = ({
|
||||
songs
|
||||
}) => {
|
||||
const SearchContent: React.FC<SearchContentProps> = ({ songs }) => {
|
||||
const onPlay = useOnPlay(songs);
|
||||
const dispatch = useDispatch();
|
||||
const uploadModal = useUploadModal();
|
||||
const username = useSelector((state: RootState) => state?.auth?.user?.selectedName);
|
||||
|
||||
if (songs.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
flex-col
|
||||
gap-y-2
|
||||
w-full
|
||||
px-6
|
||||
text-neutral-400
|
||||
"
|
||||
>
|
||||
<div className="flex flex-col gap-y-2 w-full px-6 text-neutral-400">
|
||||
No songs found.
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2 w-full px-6">
|
||||
{songs.map((song: Song) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="flex items-center gap-x-4 w-full"
|
||||
>
|
||||
<div key={song.id} className="flex items-center gap-x-4 w-full">
|
||||
<div className="flex-1">
|
||||
<MediaItem
|
||||
onClick={(id: string) => onPlay(id)}
|
||||
data={song}
|
||||
/>
|
||||
<MediaItem onClick={(id: string) => onPlay(id)} data={song} />
|
||||
</div>
|
||||
<AddToPlaylistButton song={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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default SearchContent;
|
||||
@@ -1,30 +1,57 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Input from "./Input";
|
||||
import { resetQueriedList, setQueriedValue } from "../state/features/globalSlice";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useFetchSongs } from "../hooks/fetchSongs";
|
||||
|
||||
const SearchInput = () => {
|
||||
const dispatch = useDispatch()
|
||||
const {getQueriedSongs} = useFetchSongs()
|
||||
const dispatch = useDispatch();
|
||||
const { getQueriedSongs } = useFetchSongs();
|
||||
const queriedValue = useSelector((state: any) => state.global.queriedValue);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleInputKeyDown = (event: any) => {
|
||||
if (event.key === 'Enter') {
|
||||
dispatch(resetQueriedList())
|
||||
getQueriedSongs()
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
dispatch(resetQueriedList());
|
||||
getQueriedSongs();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%" }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
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;
|
||||
@@ -1,51 +1,61 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
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 { useFetchSongs } from "../hooks/fetchSongs";
|
||||
import { RootState } from "../state/store";
|
||||
import { FaUndoAlt } from "react-icons/fa";
|
||||
|
||||
export const SearchInputPlaylist = () => {
|
||||
const dispatch = useDispatch()
|
||||
const {getPlaylistsQueried} = useFetchSongs()
|
||||
const dispatch = useDispatch();
|
||||
const { getPlaylistsQueried } = useFetchSongs();
|
||||
const queriedValuePlaylist = useSelector((state: RootState) => state.global.queriedValuePlaylist);
|
||||
const isQueryingPlaylist = useSelector((state: RootState) => state.global.isQueryingPlaylist);
|
||||
|
||||
const handleInputKeyDown = (event: any) => {
|
||||
if (event.key === 'Enter') {
|
||||
dispatch(resetQueriedListPlaylist())
|
||||
if(!queriedValuePlaylist){
|
||||
dispatch(setIsQueryingPlaylist(false))
|
||||
} else {
|
||||
dispatch(setIsQueryingPlaylist(true))
|
||||
getPlaylistsQueried()
|
||||
}
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleInputKeyDown = (event: any) => {
|
||||
if (event.key === "Enter") {
|
||||
dispatch(resetQueriedListPlaylist());
|
||||
if (!queriedValuePlaylist) {
|
||||
dispatch(setIsQueryingPlaylist(false));
|
||||
} else {
|
||||
dispatch(setIsQueryingPlaylist(true));
|
||||
getPlaylistsQueried();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center w-full">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="What do you want to listen to?"
|
||||
onChange={(e) => {
|
||||
dispatch(setQueriedValuePlaylist(e.target.value))
|
||||
}}
|
||||
value={queriedValuePlaylist}
|
||||
onChange={(e) => dispatch(setQueriedValuePlaylist(e.target.value))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{isQueryingPlaylist && (
|
||||
<FaUndoAlt className=" ml-2 cursor-pointer" onClick={()=> {
|
||||
dispatch(resetQueriedListPlaylist())
|
||||
dispatch(setIsQueryingPlaylist(false))
|
||||
dispatch(setQueriedValuePlaylist(''))
|
||||
}} />
|
||||
{queriedValuePlaylist && (
|
||||
<button
|
||||
onClick={() => {
|
||||
dispatch(resetQueriedListPlaylist());
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
+210
-122
@@ -3,36 +3,32 @@ import React, { useEffect, useState } from 'react';
|
||||
import { FieldValues, SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { toast } from "react-hot-toast";
|
||||
import Compressor from 'compressorjs'
|
||||
|
||||
|
||||
|
||||
import Modal from './Modal';
|
||||
import Input from './Input';
|
||||
import Button from './Button';
|
||||
import useUploadModal from "../hooks/useUploadModal";
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { setNotification } from '../state/features/notificationsSlice';
|
||||
import { RootState } from '../state/store';
|
||||
import ImageUploader from './common/ImageUploader';
|
||||
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';
|
||||
|
||||
const uid = new ShortUniqueId()
|
||||
|
||||
const UploadModal = () => {
|
||||
const username = useSelector((state: RootState) => state?.auth?.user?.name)
|
||||
const [songImg, setSongImg] = useState("")
|
||||
const username = useSelector((state: RootState) => state?.auth?.user?.selectedName)
|
||||
const dispatch = useDispatch()
|
||||
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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
} = useForm<FieldValues>({
|
||||
defaultValues: {
|
||||
author: '',
|
||||
@@ -45,6 +41,9 @@ const UploadModal = () => {
|
||||
const onChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
setPrefilledAudio(null);
|
||||
setPrefilledImageBase64(null);
|
||||
dispatch(setEditingSong(null));
|
||||
uploadModal.onClose();
|
||||
}
|
||||
}
|
||||
@@ -71,7 +70,7 @@ const UploadModal = () => {
|
||||
})
|
||||
if (!compressedFile) return
|
||||
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];
|
||||
return base64Data
|
||||
} 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) => {
|
||||
try {
|
||||
@@ -86,122 +119,166 @@ const UploadModal = () => {
|
||||
toast.error('Please authenticate')
|
||||
return;
|
||||
}
|
||||
if(!values.image?.[0]){
|
||||
toast.error('Please attach an image cover')
|
||||
return;
|
||||
}
|
||||
|
||||
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];
|
||||
const songFile = values.song?.[0];
|
||||
const title = values.title
|
||||
const author = values.author
|
||||
if (!imageFile || !songFile || !username || !title || !author) {
|
||||
toast.error('Missing fields')
|
||||
if (!title || !author) {
|
||||
toast.error('Missing fields');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!isEditing && (!incomingImageFile || !incomingSongFile)) {
|
||||
toast.error('Please select both an audio file and a cover image');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine what actually changed
|
||||
const audioChanged = Boolean(incomingSongFile);
|
||||
const imageChanged = Boolean(incomingImageFile);
|
||||
const metadataChanged =
|
||||
isEditing && (!!editingSong &&
|
||||
(editingSong.title !== title || editingSong.author !== author));
|
||||
|
||||
|
||||
const songError = null
|
||||
const imageError = null
|
||||
|
||||
try {
|
||||
const compressedImg = await compressImg(imageFile)
|
||||
if(!compressedImg){
|
||||
toast.error('Image compression Error')
|
||||
// Only compress image if user selected a new one
|
||||
let finalImageBase64: string | undefined;
|
||||
if (!isEditing) {
|
||||
// New publish path: image required
|
||||
const compressed = await compressImg(incomingImageFile as File);
|
||||
if (!compressed) {
|
||||
toast.error('Image compression Error');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const id = uid(8)
|
||||
const titleToUnderscore = title?.replace(/ /g, '_')
|
||||
const titleToLowercase = titleToUnderscore.toLowerCase()
|
||||
const titleSlice = titleToLowercase.slice(0,20)
|
||||
const cleanTitle = removeTrailingUnderscore(titleSlice)
|
||||
let identifier = `earbump_song_${cleanTitle}_${id}`
|
||||
finalImageBase64 = compressed;
|
||||
} else if (imageChanged) {
|
||||
const compressed = await compressImg(incomingImageFile as File);
|
||||
if (!compressed) {
|
||||
toast.error('Image compression Error');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
finalImageBase64 = compressed;
|
||||
} // else: editing with no new image => don't republish THUMBNAIL
|
||||
|
||||
const description = `title=${title};author=${author}`
|
||||
// Pick the audio to publish when needed
|
||||
const finalSongFile =
|
||||
audioChanged ? incomingSongFile : (isEditing ? prefilledAudio : undefined);
|
||||
if (!isEditing && !finalSongFile) {
|
||||
toast.error('Please select an audio file');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExtension = imageFile?.name?.split('.')?.pop()
|
||||
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20)
|
||||
const filename = `${fileTitle}.${fileExtension}`
|
||||
const resources = [
|
||||
{
|
||||
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,
|
||||
service: 'AUDIO',
|
||||
file: songFile,
|
||||
title: title,
|
||||
description: description,
|
||||
identifier: identifier,
|
||||
filename
|
||||
},
|
||||
{
|
||||
file: finalSongFile,
|
||||
title,
|
||||
description,
|
||||
identifier,
|
||||
filename: audioFilename
|
||||
});
|
||||
resources.push({
|
||||
name: username,
|
||||
service: 'THUMBNAIL',
|
||||
data64: compressedImg,
|
||||
identifier: identifier
|
||||
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
|
||||
});
|
||||
}
|
||||
if (imageChanged && finalImageBase64) {
|
||||
resources.push({
|
||||
name: username,
|
||||
service: 'THUMBNAIL',
|
||||
data64: finalImageBase64,
|
||||
identifier,
|
||||
filename: imageFilename
|
||||
});
|
||||
}
|
||||
if (resources.length === 0) {
|
||||
toast('No changes to publish');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const multiplePublish = {
|
||||
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
|
||||
resources: resources
|
||||
}
|
||||
await qortalRequest(multiplePublish)
|
||||
resources
|
||||
};
|
||||
|
||||
await qortalRequest(multiplePublish);
|
||||
|
||||
const songData = {
|
||||
title: title,
|
||||
description: description,
|
||||
created: Date.now(),
|
||||
title,
|
||||
description,
|
||||
created: isEditing ? editingSong!.created : Date.now(),
|
||||
updated: Date.now(),
|
||||
name: username,
|
||||
id: identifier,
|
||||
author: author
|
||||
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 }));
|
||||
}
|
||||
dispatch(addNewSong(songData))
|
||||
dispatch(setImageCoverHash({ url: 'data:image/webp;base64,' + compressedImg , id: identifier }));
|
||||
} catch (error: any) {
|
||||
let notificationObj = null
|
||||
if (typeof error === 'string') {
|
||||
notificationObj = {
|
||||
msg: error || 'Failed to publish audio',
|
||||
alertType: 'error'
|
||||
}
|
||||
} else if (typeof error?.error === 'string') {
|
||||
notificationObj = {
|
||||
msg: error?.error || 'Failed to publish audio',
|
||||
alertType: 'error'
|
||||
}
|
||||
} else {
|
||||
notificationObj = {
|
||||
msg: error?.message || error?.message || 'Failed to publish audio',
|
||||
alertType: 'error'
|
||||
}
|
||||
}
|
||||
if (!notificationObj) return
|
||||
dispatch(setNotification(notificationObj))
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (songError) {
|
||||
setIsLoading(false);
|
||||
return toast.error('Failed song upload');
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (imageError) {
|
||||
setIsLoading(false);
|
||||
return toast.error('Failed image upload');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
setIsLoading(false);
|
||||
toast.success('Song created!');
|
||||
toast.success(isEditing ? 'Audio updated successfully!' : 'Audio published successfully!');
|
||||
reset();
|
||||
setPrefilledAudio(null);
|
||||
setPrefilledImageBase64(null);
|
||||
dispatch(setEditingSong(null));
|
||||
uploadModal.onClose();
|
||||
} catch (error) {
|
||||
toast.error('Something went wrong');
|
||||
@@ -210,59 +287,70 @@ const UploadModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add a song"
|
||||
description="Upload an audio file- all fields are required"
|
||||
title={editingSong ? "Edit Audio" : "Publish Audio"}
|
||||
description={editingSong ? "Update any fields below" : "All fields are required"}
|
||||
isOpen={uploadModal.isOpen}
|
||||
onChange={onChange}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<Input
|
||||
id="title"
|
||||
disabled={isLoading}
|
||||
{...register('title', { required: true , maxLength: 35})}
|
||||
placeholder="Song title"
|
||||
maxLength={35}
|
||||
{...register('title', { required: true })}
|
||||
placeholder="Title"
|
||||
/>
|
||||
<Input
|
||||
id="author"
|
||||
disabled={isLoading}
|
||||
{...register('author', { required: true })}
|
||||
placeholder="Song author"
|
||||
placeholder="Author"
|
||||
/>
|
||||
|
||||
{/* AUDIO */}
|
||||
<div>
|
||||
<div className="pb-1">
|
||||
Select a song file
|
||||
</div>
|
||||
<div className="pb-1">Select an audio file {editingSong && "(leave empty to keep current)"}</div>
|
||||
<Input
|
||||
placeholder="test"
|
||||
placeholder="audio"
|
||||
disabled={isLoading}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
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>
|
||||
|
||||
{/* IMAGE */}
|
||||
<div>
|
||||
<div className="pb-1">
|
||||
Select an image
|
||||
</div>
|
||||
<div className="pb-1">Select a cover image {editingSong && "(leave empty to keep current)"}</div>
|
||||
<Input
|
||||
placeholder="test"
|
||||
placeholder="image"
|
||||
disabled={isLoading}
|
||||
type="file"
|
||||
accept="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>
|
||||
|
||||
<Button disabled={isLoading} type="submit">
|
||||
Create
|
||||
{editingSong ? "Save changes" : "Publish"}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -13,24 +13,21 @@ import useUploadModal from "../hooks/useUploadPlaylistModal";
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { setNotification } from '../state/features/notificationsSlice';
|
||||
import { RootState } from '../state/store';
|
||||
import ImageUploader from './common/ImageUploader';
|
||||
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 {AiOutlineClose} from "react-icons/ai";
|
||||
import { Song } from '../types';
|
||||
import { removeTrailingUnderscore } from '../utils/extra';
|
||||
const uid = new ShortUniqueId()
|
||||
|
||||
const UploadPlaylistModal = () => {
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const newPlaylist = useSelector((state: RootState) => state?.global.newPlayList);
|
||||
const [prevSavedImg, setPrevSavedImg] = useState<null | string>(null)
|
||||
const uploadModal = useUploadModal();
|
||||
console.log({newPlaylist})
|
||||
const currentPlaylist = useRef<any>(null)
|
||||
const {
|
||||
register,
|
||||
@@ -43,22 +40,27 @@ const UploadPlaylistModal = () => {
|
||||
description: newPlaylist?.description,
|
||||
title: newPlaylist?.title || '',
|
||||
image: null,
|
||||
isPrivate: false,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
useEffect(()=> {
|
||||
if(currentPlaylist?.current?.id === newPlaylist?.id) return
|
||||
if(newPlaylist) reset({
|
||||
if(currentPlaylist?.current?.id === newPlaylist?.id) return;
|
||||
if(newPlaylist) {
|
||||
const computedIsPrivate = !!newPlaylist?.service && newPlaylist.service.startsWith('DOCUMENT_PRIVATE');
|
||||
reset({
|
||||
description: newPlaylist?.description,
|
||||
title: newPlaylist?.title || '',
|
||||
image: null,
|
||||
})
|
||||
if(newPlaylist && newPlaylist?.image) setPrevSavedImg(newPlaylist.image)
|
||||
if(newPlaylist?.id){
|
||||
currentPlaylist.current = newPlaylist
|
||||
isPrivate: computedIsPrivate,
|
||||
});
|
||||
}
|
||||
}, [reset, newPlaylist])
|
||||
if(newPlaylist && newPlaylist?.image) setPrevSavedImg(newPlaylist.image);
|
||||
if(newPlaylist?.id){
|
||||
currentPlaylist.current = newPlaylist;
|
||||
}
|
||||
}, [reset, newPlaylist]);
|
||||
|
||||
const onChange = async (open: boolean) => {
|
||||
if (!open) {
|
||||
@@ -66,18 +68,16 @@ const UploadPlaylistModal = () => {
|
||||
const title = watch("title");
|
||||
const description = watch("description");
|
||||
const image = watch("image");
|
||||
console.log({image})
|
||||
let playlistImage = null
|
||||
if(image && image[0]){
|
||||
try {
|
||||
const compressedImg = await compressImg(image[0])
|
||||
playlistImage = 'data:image/webp;base64,' + compressedImg
|
||||
} catch (error) {
|
||||
console.log({error})
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
}
|
||||
console.log({title})
|
||||
dispatch(setNewPlayList({
|
||||
...newPlaylist,
|
||||
title,
|
||||
@@ -88,7 +88,6 @@ const UploadPlaylistModal = () => {
|
||||
|
||||
}
|
||||
|
||||
// reset();
|
||||
uploadModal.onClose();
|
||||
}
|
||||
}
|
||||
@@ -115,7 +114,7 @@ const UploadPlaylistModal = () => {
|
||||
})
|
||||
if (!compressedFile) return
|
||||
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];
|
||||
return base64Data
|
||||
} catch (error) {
|
||||
@@ -131,7 +130,7 @@ const UploadPlaylistModal = () => {
|
||||
return;
|
||||
}
|
||||
if(!values.image?.[0] && !prevSavedImg){
|
||||
toast.error('Please attach an image cover')
|
||||
toast.error('Please select a cover image')
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,6 +140,7 @@ const UploadPlaylistModal = () => {
|
||||
const imageFile = values.image?.[0];
|
||||
const title = values.title
|
||||
const description = values.description
|
||||
const isPrivate = newPlaylist?.service ? newPlaylist.service.startsWith('DOCUMENT_PRIVATE') : !!values.isPrivate;
|
||||
if ((!imageFile && !prevSavedImg) || !username || !title || !description) {
|
||||
toast.error('Missing fields')
|
||||
return;
|
||||
@@ -165,7 +165,6 @@ const UploadPlaylistModal = () => {
|
||||
let identifier = newPlaylist?.id ? newPlaylist.id : `earbump_playlist_${cleanTitle}_${id}`
|
||||
const descriptionSnipped = description.slice(0, 140)
|
||||
|
||||
// const fileExtension = imageFile?.name?.split('.')?.pop()
|
||||
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20)
|
||||
const filename = `${fileTitle}.json`
|
||||
const playlistObject = {
|
||||
@@ -174,12 +173,31 @@ const UploadPlaylistModal = () => {
|
||||
description,
|
||||
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg
|
||||
}
|
||||
console.log({playlistObject})
|
||||
|
||||
|
||||
const playlistToBase64 = await objectToBase64(playlistObject);
|
||||
const resources = [
|
||||
{
|
||||
if (isPrivate) {
|
||||
if (!userPubkey) {
|
||||
toast.error('Missing user public key for encryption');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const pubkeyArray = [userPubkey]
|
||||
const publishPrivate = {
|
||||
action: 'PUBLISH_QDN_RESOURCE',
|
||||
name: newPlaylist?.user ? newPlaylist?.user : username,
|
||||
service: 'DOCUMENT_PRIVATE',
|
||||
data64: playlistToBase64,
|
||||
title: title.slice(0, 55),
|
||||
description: descriptionSnipped,
|
||||
identifier: identifier,
|
||||
filename,
|
||||
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,
|
||||
@@ -187,23 +205,12 @@ const UploadPlaylistModal = () => {
|
||||
description: descriptionSnipped,
|
||||
identifier: identifier,
|
||||
filename
|
||||
} as const
|
||||
await qortalRequest(publishPublic)
|
||||
}
|
||||
// {
|
||||
// name: username,
|
||||
// service: 'THUMBNAIL',
|
||||
// data64: compressedImg,
|
||||
// identifier: identifier
|
||||
// }
|
||||
]
|
||||
|
||||
const multiplePublish = {
|
||||
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
|
||||
resources: resources
|
||||
}
|
||||
await qortalRequest(multiplePublish)
|
||||
toast.success('Song created!');
|
||||
toast.success(isPrivate ? 'Private playlist published successfully!' : 'Playlist published successfully!');
|
||||
if (!isPrivate) {
|
||||
if(newPlaylist?.id){
|
||||
//update playlist in store
|
||||
dispatch(addToPlaylistHashMap(
|
||||
{
|
||||
user: newPlaylist?.user ? newPlaylist?.user : username,
|
||||
@@ -213,10 +220,10 @@ const UploadPlaylistModal = () => {
|
||||
songs: newPlaylist.songs,
|
||||
title,
|
||||
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,
|
||||
@@ -226,9 +233,11 @@ const UploadPlaylistModal = () => {
|
||||
songs: newPlaylist.songs,
|
||||
title,
|
||||
description,
|
||||
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg}
|
||||
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
reset();
|
||||
dispatch(setNewPlayList(null))
|
||||
uploadModal.onClose();
|
||||
@@ -259,14 +268,14 @@ const UploadPlaylistModal = () => {
|
||||
|
||||
if (songError) {
|
||||
setIsLoading(false);
|
||||
return toast.error('Failed song upload');
|
||||
return toast.error('Failed to publish audio');
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (imageError) {
|
||||
setIsLoading(false);
|
||||
return toast.error('Failed image upload');
|
||||
return toast.error('Failed to publish image');
|
||||
}
|
||||
|
||||
|
||||
@@ -274,9 +283,9 @@ const UploadPlaylistModal = () => {
|
||||
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
} catch (error) {
|
||||
toast.error('Something went wrong');
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -295,8 +304,8 @@ const UploadPlaylistModal = () => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Save Playlist"
|
||||
description="Upload playlist- all fields are required"
|
||||
title="Publish Playlist"
|
||||
description="All fields are required"
|
||||
isOpen={uploadModal.isOpen}
|
||||
onChange={onChange}
|
||||
>
|
||||
@@ -307,18 +316,19 @@ const UploadPlaylistModal = () => {
|
||||
<Input
|
||||
id="title"
|
||||
disabled={isLoading}
|
||||
{...register('title', { required: true , maxLength: 35})}
|
||||
placeholder="Playlist title"
|
||||
maxLength={35}
|
||||
{...register('title', { required: true })}
|
||||
placeholder="Title"
|
||||
/>
|
||||
<Textarea
|
||||
id="description"
|
||||
disabled={isLoading}
|
||||
{...register('description', { required: true })}
|
||||
placeholder="Describe your playlist"
|
||||
placeholder="Description"
|
||||
/>
|
||||
<div>
|
||||
<div className="pb-1">
|
||||
Select an image for the playlist
|
||||
Select a cover image
|
||||
</div>
|
||||
{prevSavedImg ? <div className='flex items-center gap-1'>
|
||||
<img src={prevSavedImg}/>
|
||||
@@ -341,7 +351,7 @@ const UploadPlaylistModal = () => {
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-1">
|
||||
Songs
|
||||
Tracks
|
||||
</div>
|
||||
{newPlaylist?.songs?.map((song: SongReference)=> {
|
||||
return (
|
||||
@@ -357,6 +367,15 @@ const UploadPlaylistModal = () => {
|
||||
)
|
||||
})}
|
||||
</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">
|
||||
Publish
|
||||
</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) => {
|
||||
try {
|
||||
const offset = songListLibrary.length
|
||||
@@ -62,28 +118,18 @@ export const useFetchSongs = () => {
|
||||
const responseData = await response.json()
|
||||
const structureData = responseData.map((song: any): SongMeta => {
|
||||
const description = song?.metadata?.description || ""
|
||||
let pairs: string[] = description?.split(';'); // Splits the string into an array based on the semicolon.
|
||||
|
||||
// Define an empty object to hold your title and author
|
||||
let pairs: string[] = description?.split(';');
|
||||
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++) {
|
||||
let pair: string[] = pairs[i].split('=');
|
||||
|
||||
// Ensure the pair is a key-value pair before assignment
|
||||
if (pair.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key: string = pair[0].trim(); // remove whitespace
|
||||
let value: string = pair[1].trim(); // remove whitespace
|
||||
|
||||
// Ensure the key is either 'title' or 'author' before assignment
|
||||
let key: string = pair[0].trim();
|
||||
let value: string = pair[1].trim();
|
||||
if (key !== 'title' && key !== 'author') {
|
||||
continue;
|
||||
}
|
||||
|
||||
obj[key] = value;
|
||||
}
|
||||
return {
|
||||
@@ -99,13 +145,10 @@ export const useFetchSongs = () => {
|
||||
})
|
||||
dispatch(upsertMyLibrary(structureData))
|
||||
|
||||
|
||||
for (const content of structureData) {
|
||||
if (content.name && content.id) {
|
||||
|
||||
if (!imageCoverHash[content.id]) {
|
||||
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 structureData = responseData.map((song: any): SongMeta => {
|
||||
const description = song?.metadata?.description || ""
|
||||
let pairs: string[] = description?.split(';'); // Splits the string into an array based on the semicolon.
|
||||
|
||||
// Define an empty object to hold your title and author
|
||||
let pairs: string[] = description?.split(';');
|
||||
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++) {
|
||||
let pair: string[] = pairs[i].split('=');
|
||||
|
||||
// Ensure the pair is a key-value pair before assignment
|
||||
if (pair.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key: string = pair[0].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') {
|
||||
continue;
|
||||
}
|
||||
|
||||
obj[key] = value;
|
||||
}
|
||||
return {
|
||||
@@ -170,13 +203,10 @@ export const useFetchSongs = () => {
|
||||
})
|
||||
dispatch(upsertQueried(structureData))
|
||||
|
||||
|
||||
for (const content of structureData) {
|
||||
if (content.name && content.id) {
|
||||
|
||||
if (!imageCoverHash[content.id]) {
|
||||
queueFetchAvatars.push(() => getImgCover(content.id, content.name))
|
||||
// getImgCover(content.id, content.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,28 +234,18 @@ export const useFetchSongs = () => {
|
||||
const data = responseData[0]
|
||||
|
||||
const description = data?.metadata?.description || ""
|
||||
let pairs: string[] = description?.split(';'); // Splits the string into an array based on the semicolon.
|
||||
|
||||
// Define an empty object to hold your title and author
|
||||
let pairs: string[] = description?.split(';');
|
||||
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++) {
|
||||
let pair: string[] = pairs[i].split('=');
|
||||
|
||||
// Ensure the pair is a key-value pair before assignment
|
||||
if (pair.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key: string = pair[0].trim(); // remove whitespace
|
||||
let value: string = pair[1].trim(); // remove whitespace
|
||||
|
||||
// Ensure the key is either 'title' or 'author' before assignment
|
||||
let key: string = pair[0].trim();
|
||||
let value: string = pair[1].trim();
|
||||
if (key !== 'title' && key !== 'author') {
|
||||
continue;
|
||||
}
|
||||
|
||||
obj[key] = value;
|
||||
}
|
||||
const object = {
|
||||
@@ -240,22 +260,15 @@ export const useFetchSongs = () => {
|
||||
}
|
||||
songsToSet.push(object)
|
||||
|
||||
|
||||
|
||||
if (!imageCoverHash[object.id]) {
|
||||
queueFetchAvatars.push(() => getImgCover(object.id, object.name))
|
||||
// getImgCover(object.id, object.name)
|
||||
}
|
||||
} catch (error) { } finally { }
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
dispatch(upsertFavorite(songsToSet))
|
||||
|
||||
}, [imageCoverHash, songList, favoriteList]);
|
||||
|
||||
|
||||
const getRecentSongs = useCallback(async (offsetParam?: number, limitParam?: number) => {
|
||||
try {
|
||||
const offset = offsetParam ?? songListRecent.length
|
||||
@@ -270,32 +283,21 @@ export const useFetchSongs = () => {
|
||||
const responseData = await response.json()
|
||||
const structureData = responseData.map((song: any): SongMeta => {
|
||||
const description = song?.metadata?.description || ""
|
||||
let pairs: string[] = description?.split(';'); // Splits the string into an array based on the semicolon.
|
||||
|
||||
// Define an empty object to hold your title and author
|
||||
let pairs: string[] = description?.split(';');
|
||||
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++) {
|
||||
let pair: string[] = pairs[i].split('=');
|
||||
|
||||
// Ensure the pair is a key-value pair before assignment
|
||||
if (pair.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key: string = pair[0].trim(); // remove whitespace
|
||||
let value: string = pair[1].trim(); // remove whitespace
|
||||
|
||||
// Ensure the key is either 'title' or 'author' before assignment
|
||||
let key: string = pair[0].trim();
|
||||
let value: string = pair[1].trim();
|
||||
if (key !== 'title' && key !== 'author') {
|
||||
continue;
|
||||
}
|
||||
|
||||
obj[key] = value;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
title: song?.metadata?.title,
|
||||
description: song?.metadata?.description,
|
||||
@@ -310,10 +312,8 @@ export const useFetchSongs = () => {
|
||||
dispatch(upsertRecent(structureData))
|
||||
for (const content of structureData) {
|
||||
if (content.name && content.id) {
|
||||
|
||||
if (!imageCoverHash[content.id]) {
|
||||
queueFetchAvatars.push(() => getImgCover(content.id, content.name))
|
||||
// getImgCover(content.id, content.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,15 +322,12 @@ export const useFetchSongs = () => {
|
||||
}
|
||||
}, [songListRecent, imageCoverHash]);
|
||||
|
||||
|
||||
const checkStructure = (content: any) => {
|
||||
let isValid = true
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
|
||||
|
||||
const fetchAndEvaluatePlaylists = async (data: any) => {
|
||||
const getPlaylist = async () => {
|
||||
const { user, playlistId, content } = data
|
||||
@@ -338,47 +335,45 @@ export const useFetchSongs = () => {
|
||||
...content,
|
||||
isValid: false
|
||||
}
|
||||
|
||||
if (!user || !playlistId) return obj
|
||||
|
||||
try {
|
||||
|
||||
const responseData = await qortalRequest({
|
||||
const isPrivate = typeof content?.service === 'string' && content.service.endsWith('_PRIVATE');
|
||||
const serviceToFetch = isPrivate ? content.service : 'PLAYLIST';
|
||||
const fetched = await qortalRequest({
|
||||
action: 'FETCH_QDN_RESOURCE',
|
||||
name: user,
|
||||
service: 'PLAYLIST',
|
||||
identifier: playlistId
|
||||
})
|
||||
service: serviceToFetch,
|
||||
identifier: playlistId,
|
||||
...(isPrivate ? { encoding: 'base64' } : {})
|
||||
});
|
||||
const responseData = await decryptByService(serviceToFetch, fetched);
|
||||
if (checkStructure(responseData)) {
|
||||
obj = {
|
||||
...content,
|
||||
...responseData,
|
||||
isValid: true
|
||||
};
|
||||
}
|
||||
return obj;
|
||||
} catch (error) {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
return obj
|
||||
} catch (error) { }
|
||||
}
|
||||
|
||||
const res = await getPlaylist()
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
const getPlaylist = async (user: string, playlistId: string, content: any) => {
|
||||
const res = await fetchAndEvaluatePlaylists({
|
||||
user,
|
||||
playlistId,
|
||||
content
|
||||
})
|
||||
|
||||
dispatch(addToPlaylistHashMap(res))
|
||||
}
|
||||
|
||||
|
||||
const checkAndUpdatePlaylist = React.useCallback(
|
||||
(playlist: PlayList) => {
|
||||
|
||||
const existingPlaylist = playlistHash[playlist.id]
|
||||
if (!existingPlaylist) {
|
||||
return true
|
||||
@@ -418,7 +413,8 @@ export const useFetchSongs = () => {
|
||||
user: playlist.name,
|
||||
image: '',
|
||||
songs: [],
|
||||
id: playlist.identifier
|
||||
id: playlist.identifier,
|
||||
service: playlist?.service
|
||||
}
|
||||
})
|
||||
dispatch(upsertPlaylists(structureData))
|
||||
@@ -441,14 +437,12 @@ export const useFetchSongs = () => {
|
||||
const getRandomPlaylist = useCallback(async () => {
|
||||
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 response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
const responseData = await response.json();
|
||||
const length = responseData.length;
|
||||
const randomIndex = Math.floor(Math.random() * length);
|
||||
@@ -465,28 +459,23 @@ export const useFetchSongs = () => {
|
||||
user: randomItem.name,
|
||||
image: '',
|
||||
songs: [],
|
||||
id: randomItem.identifier
|
||||
id: randomItem.identifier,
|
||||
service: randomItem?.service
|
||||
}
|
||||
dispatch(setRandomPlaylist(structurePlaylist))
|
||||
|
||||
|
||||
const res = checkAndUpdatePlaylist(structurePlaylist)
|
||||
if (res) {
|
||||
getPlaylist(structurePlaylist.user, structurePlaylist.id, structurePlaylist)
|
||||
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
} catch (error) { }
|
||||
}, [])
|
||||
|
||||
|
||||
const getMyPlaylists = useCallback(async () => {
|
||||
try {
|
||||
if (!username) return
|
||||
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, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -506,7 +495,8 @@ export const useFetchSongs = () => {
|
||||
user: playlist.name,
|
||||
image: '',
|
||||
songs: [],
|
||||
id: playlist.identifier
|
||||
id: playlist.identifier,
|
||||
service: playlist?.service
|
||||
}
|
||||
})
|
||||
dispatch(upsertMyPlaylists(structureData))
|
||||
@@ -516,14 +506,10 @@ export const useFetchSongs = () => {
|
||||
const res = checkAndUpdatePlaylist(content)
|
||||
if (res) {
|
||||
getPlaylist(content.user, content.id, content)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
} finally {
|
||||
}
|
||||
} catch (error) { } finally { }
|
||||
}, [myPlaylists, imageCoverHash, username]);
|
||||
|
||||
const getPlaylistsQueried = useCallback(async () => {
|
||||
@@ -553,7 +539,8 @@ export const useFetchSongs = () => {
|
||||
user: playlist.name,
|
||||
image: '',
|
||||
songs: [],
|
||||
id: playlist.identifier
|
||||
id: playlist.identifier,
|
||||
service: playlist?.service
|
||||
}
|
||||
})
|
||||
dispatch(upsertQueriedPlaylist(structureData))
|
||||
@@ -568,12 +555,9 @@ export const useFetchSongs = () => {
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
} finally {
|
||||
}
|
||||
} catch (error) { } finally { }
|
||||
}, [playlistQueried, imageCoverHash, queriedValuePlaylist]);
|
||||
|
||||
|
||||
return {
|
||||
getRecentSongs,
|
||||
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 { useDispatch } from 'react-redux'
|
||||
import { clearSongListLibrary } from '../../state/features/globalSlice'
|
||||
import Header from '../../components/Header'
|
||||
import ListItem from '../../components/ListItem'
|
||||
import likeImg from '../../assets/img/liked.png'
|
||||
@@ -17,6 +19,8 @@ import useOnPlay from '../../hooks/useOnPlay'
|
||||
import { MyPlaylists } from '../Playlists/MyPlaylists'
|
||||
export const Library = () => {
|
||||
const initialFetch = useRef(false)
|
||||
const prevNameRef = useRef<string | undefined>(undefined)
|
||||
const dispatch = useDispatch()
|
||||
const username = useSelector((state: RootState) => state?.auth?.user?.name);
|
||||
const songListLibrary = useSelector((state: RootState) => state?.global.songListLibrary);
|
||||
const [mode, setMode] = useState<string>('songs')
|
||||
@@ -45,13 +49,18 @@ export const Library = () => {
|
||||
}, [username, getYourLibrary])
|
||||
|
||||
useEffect(()=> {
|
||||
if(username && !initialFetch.current){
|
||||
fetchMyLibrary()
|
||||
if (!username) return;
|
||||
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 (
|
||||
|
||||
<div
|
||||
className="
|
||||
bg-neutral-900
|
||||
@@ -113,8 +122,8 @@ export const Library = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchContent songs={songListLibrary} />
|
||||
<LazyLoad onLoadMore={fetchMyLibrary}></LazyLoad>
|
||||
<SearchContent key={`songs-${username || 'anon'}`} songs={songListLibrary} />
|
||||
<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 ListItem from '../../components/ListItem'
|
||||
import likeImg from '../../assets/img/liked.png'
|
||||
@@ -7,20 +7,31 @@ import SearchInput from '../../components/SearchInput'
|
||||
import SearchContent from '../../components/SearchContent'
|
||||
import LazyLoad from '../../components/common/LazyLoad'
|
||||
import { useFetchSongs } from '../../hooks/fetchSongs'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { RootState } from '../../state/store'
|
||||
import { clearMyPlaylists } from '../../state/features/globalSlice'
|
||||
import { PlayListsContent } from '../../components/PlaylistsContent'
|
||||
import { SearchInputPlaylist } from '../../components/SearchInputPlaylist'
|
||||
export const MyPlaylists = () => {
|
||||
const { getMyPlaylists
|
||||
} = useFetchSongs()
|
||||
const { getMyPlaylists } = useFetchSongs()
|
||||
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})
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@@ -29,8 +40,8 @@ export const MyPlaylists = () => {
|
||||
Playlists
|
||||
</h1>
|
||||
</div>
|
||||
<PlayListsContent playlists={playlistsToRender} />
|
||||
<LazyLoad onLoadMore={getMyPlaylists}></LazyLoad>
|
||||
<PlayListsContent key={`pl-${username || 'anon'}`} playlists={playlistsToRender} />
|
||||
<LazyLoad key={`pl-lazy-${username || 'anon'}`} onLoadMore={getMyPlaylists}></LazyLoad>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,9 @@ interface AuthState {
|
||||
address: string;
|
||||
publicKey: string;
|
||||
name?: string;
|
||||
names?: string[];
|
||||
primaryName?: string;
|
||||
selectedName?: string;
|
||||
} | null;
|
||||
}
|
||||
const initialState: AuthState = {
|
||||
@@ -19,9 +22,15 @@ export const authSlice = createSlice({
|
||||
addUser: (state, action) => {
|
||||
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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { Song } from '../../types'
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ interface GlobalState {
|
||||
queriedValue: string;
|
||||
queriedValuePlaylist: string;
|
||||
currentSong: string | null
|
||||
editingSong: Song | null;
|
||||
favorites: null | Favorites
|
||||
favoritesPlaylist: null | PlayList[]
|
||||
favoriteList: Song[]
|
||||
@@ -41,6 +42,7 @@ interface GlobalState {
|
||||
}
|
||||
|
||||
export interface PlayList extends ResourceBase {
|
||||
service: string;
|
||||
title: string;
|
||||
description: string;
|
||||
songs: SongReference[];
|
||||
@@ -98,6 +100,7 @@ const initialState: GlobalState = {
|
||||
queriedValue: "",
|
||||
queriedValuePlaylist: "",
|
||||
currentSong: null,
|
||||
editingSong: null,
|
||||
favorites: null,
|
||||
favoriteList: [],
|
||||
nowPlayingPlaylist: [],
|
||||
@@ -222,7 +225,9 @@ export const globalSlice = createSlice({
|
||||
setCurrentSong: (state, action) => {
|
||||
state.currentSong = action.payload
|
||||
},
|
||||
|
||||
setEditingSong: (state, action: PayloadAction<Song | null>) => {
|
||||
state.editingSong = action.payload;
|
||||
},
|
||||
setQueriedValue: (state, action) => {
|
||||
state.queriedValue = action.payload
|
||||
},
|
||||
@@ -308,6 +313,12 @@ export const globalSlice = createSlice({
|
||||
setRandomPlaylist: (state, action) => {
|
||||
state.randomPlaylist = action.payload
|
||||
},
|
||||
clearSongListLibrary(state) {
|
||||
state.songListLibrary = [];
|
||||
},
|
||||
clearMyPlaylists(state) {
|
||||
state.myPlaylists = [];
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -320,6 +331,7 @@ export const {
|
||||
upsertMyLibrary,
|
||||
upsertRecent,
|
||||
setCurrentSong,
|
||||
setEditingSong,
|
||||
upsertQueried,
|
||||
setQueriedValue,
|
||||
resetQueriedList,
|
||||
@@ -342,7 +354,9 @@ export const {
|
||||
setFavoritesFromStoragePlaylists,
|
||||
setFavPlaylist,
|
||||
removeFavPlaylist,
|
||||
setRandomPlaylist
|
||||
setRandomPlaylist,
|
||||
clearSongListLibrary,
|
||||
clearMyPlaylists
|
||||
} = globalSlice.actions
|
||||
|
||||
export default globalSlice.reducer
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface Song {
|
||||
title: string;
|
||||
name: string;
|
||||
service: string;
|
||||
created?: number;
|
||||
updated?: number;
|
||||
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 { addUser } from "../state/features/authSlice";
|
||||
import { getAccountNames, getPrimaryAccountName } from "../utils/qortalRequestFunctions";
|
||||
import NavBar from "../components/layout/Navbar/Navbar";
|
||||
import PageLoader from "../components/common/PageLoader";
|
||||
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 imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
|
||||
const songListRecent = useSelector((state: RootState) => state.global.imageCoverHash);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.name) return;
|
||||
|
||||
if (!user?.selectedName && !user?.name) return;
|
||||
getAvatar();
|
||||
}, [user?.name]);
|
||||
|
||||
|
||||
|
||||
|
||||
}, [user?.selectedName, user?.name]);
|
||||
|
||||
const getAvatar = async () => {
|
||||
try {
|
||||
let url = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_URL",
|
||||
name: user?.name,
|
||||
name: user?.selectedName || user?.name,
|
||||
service: "THUMBNAIL",
|
||||
identifier: "qortal_avatar"
|
||||
});
|
||||
@@ -63,28 +60,25 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
||||
isLoadingGlobal,
|
||||
} = 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 () => {
|
||||
try {
|
||||
let account = await qortalRequest({
|
||||
action: "GET_USER_ACCOUNT"
|
||||
});
|
||||
|
||||
const name = await getNameInfo(account.address);
|
||||
dispatch(addUser({ ...account, name }));
|
||||
const [names, primaryName] = await Promise.all([
|
||||
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) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import UploadModal from "../components/UploadModal";
|
||||
import UploadPlaylistModal from "../components/UploadPlaylistModal";
|
||||
import UploadFolderModal from "../components/UploadFolderModal";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../state/store";
|
||||
|
||||
@@ -29,6 +30,7 @@ const ModalProvider: React.FC<ModalProviderProps> = () => {
|
||||
{newPlaylist && (
|
||||
<UploadPlaylistModal />
|
||||
)}
|
||||
<UploadFolderModal />
|
||||
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user