update #1

Merged
crowetic merged 8 commits from :update into main 2025-09-10 18:49:30 +00:00
21 changed files with 1333 additions and 478 deletions
+2
View File
@@ -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}>
+26 -13
View File
@@ -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
View File
@@ -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}
+15 -5
View File
@@ -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
+27 -28
View File
@@ -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;
+47 -20
View File
@@ -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 (
<Input return (
placeholder="What do you want to listen to?" <div style={{ position: "relative", width: "100%" }}>
onChange={(e) => dispatch(setQueriedValue(e.target.value))} <Input
onKeyDown={handleInputKeyDown} 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; export default SearchInput;
+45 -35
View File
@@ -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>
); );
} };
+570
View File
@@ -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 its common, else take the first files 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;
+220 -132
View File
@@ -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 imageFile = values.image?.[0]; const incomingImageFile: File | undefined = values.image?.[0];
const songFile = values.song?.[0]; const incomingSongFile: File | undefined = values.song?.[0];
const title = values.title const title = values.title;
const author = values.author 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; 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 // Only compress image if user selected a new one
const imageError = null let finalImageBase64: string | undefined;
if (!isEditing) {
try { // New publish path: image required
const compressedImg = await compressImg(imageFile) const compressed = await compressImg(incomingImageFile as File);
if(!compressedImg){ if (!compressed) {
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 = {
title: title, // Pick the audio to publish when needed
description: description, const finalSongFile =
created: Date.now(), audioChanged ? incomingSongFile : (isEditing ? prefilledAudio : undefined);
updated: Date.now(), if (!isEditing && !finalSongFile) {
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))
}
if (songError) {
setIsLoading(false);
return toast.error('Failed song upload');
} }
const multiplePublish = {
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
resources
};
if (imageError) { await qortalRequest(multiplePublish);
setIsLoading(false);
return toast.error('Failed image 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 }));
} }
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,63 +287,74 @@ 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>
); );
} }
export default UploadModal; export default UploadModal;
+95 -76
View File
@@ -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
View File
@@ -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,
+27
View File
@@ -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 };
};
+15
View File
@@ -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;
+15 -6
View File
@@ -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>
</> </>
)} )}
+18 -7
View File
@@ -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>
+10 -1
View File
@@ -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;
+17 -3
View File
@@ -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
+2
View File
@@ -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
} }
+14
View File
@@ -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 : '';
}
+18 -24
View File
@@ -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
View File
@@ -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 />
</> </>
); );