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
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}>
+26 -13
View File
@@ -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
View File
@@ -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}
+15 -5
View File
@@ -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
+24 -25
View File
@@ -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;
+39 -12
View File
@@ -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;
+40 -30
View File
@@ -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>
);
}
};
+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;
+210 -122
View File
@@ -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>
+70 -51
View File
@@ -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
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) => {
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,
+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 { 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>
</>
)}
+18 -7
View File
@@ -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>
+10 -1
View File
@@ -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;
+17 -3
View File
@@ -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
+2
View File
@@ -6,6 +6,8 @@ export interface Song {
title: string;
name: string;
service: string;
created?: number;
updated?: number;
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 { 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
View File
@@ -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 />
</>
);