diff --git a/src/App.tsx b/src/App.tsx index 4643d25..2f90399 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( diff --git a/src/components/AddLibrary.tsx b/src/components/AddLibrary.tsx index 358396b..f227b15 100644 --- a/src/components/AddLibrary.tsx +++ b/src/components/AddLibrary.tsx @@ -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 = ({ 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 = ({ songs: [], image: null })) - - - - } + const onClickFolder = () => { + if (!username) { + toast.error('Please authenticate') + return + } + uploadFolderModal.onOpen(); + } return ( <>
-
+
- -

+ +

Add Song

-
+
- -

+ +

Add Playlist

+
+ +

+ Add Folder +

+
+ +
{newPlaylist && (
@@ -107,7 +120,7 @@ export const AddLibrary: React.FC = ({ className="bg-blue-500 text-white px-4 py-2 rounded" onClick={() => { uploadPlaylistModal.onOpen() }} > - Save Playlist + Edit Playlist
- {!username && ( + {!selectedName && ( <> -
)} + {selectedName && ( +
+ + {openNames && ( +
+ {names.map((n: string) => ( + + ))} +
+ )} +
+ )}
{children} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 274e375..35ddb8d 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -7,6 +7,7 @@ interface ModalProps { title: string; description: string; children: React.ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl'; } const Modal: React.FC = ({ @@ -14,8 +15,18 @@ const Modal: React.FC = ({ 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 ( @@ -28,7 +39,7 @@ const Modal: React.FC = ({ " /> + `}> = ({ - songs -}) => { +const SearchContent: React.FC = ({ 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 ( -
+
No songs found.
- ) + ); } - return ( + return (
{songs.map((song: Song) => ( -
+
- onPlay(id)} - data={song} - /> + onPlay(id)} data={song} />
+ {username && song.name === username && ( + + )}
))}
); -} - -export default SearchContent; \ No newline at end of file +}; + +export default SearchContent; diff --git a/src/components/SearchInput.tsx b/src/components/SearchInput.tsx index fc4b3da..99c6d5e 100644 --- a/src/components/SearchInput.tsx +++ b/src/components/SearchInput.tsx @@ -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(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); const handleInputKeyDown = (event: any) => { - if (event.key === 'Enter') { - dispatch(resetQueriedList()) - getQueriedSongs() + if (event.key === "Enter") { + dispatch(resetQueriedList()); + getQueriedSongs(); } - } - return ( - dispatch(setQueriedValue(e.target.value))} - onKeyDown={handleInputKeyDown} - /> + }; + + return ( +
+ dispatch(setQueriedValue(e.target.value))} + onKeyDown={handleInputKeyDown} + /> + {queriedValue && ( + + )} +
); -} - -export default SearchInput; \ No newline at end of file +}; + +export default SearchInput; diff --git a/src/components/SearchInputPlaylist.tsx b/src/components/SearchInputPlaylist.tsx index a5f190a..f18f275 100644 --- a/src/components/SearchInputPlaylist.tsx +++ b/src/components/SearchInputPlaylist.tsx @@ -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 inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); + const handleInputKeyDown = (event: any) => { - if (event.key === 'Enter') { - dispatch(resetQueriedListPlaylist()) - if(!queriedValuePlaylist){ - dispatch(setIsQueryingPlaylist(false)) + if (event.key === "Enter") { + dispatch(resetQueriedListPlaylist()); + if (!queriedValuePlaylist) { + dispatch(setIsQueryingPlaylist(false)); } else { - dispatch(setIsQueryingPlaylist(true)) - getPlaylistsQueried() + dispatch(setIsQueryingPlaylist(true)); + getPlaylistsQueried(); } - } - } + }; + return ( -
- { - dispatch(setQueriedValuePlaylist(e.target.value)) - }} - value={queriedValuePlaylist} - onKeyDown={handleInputKeyDown} - /> - {isQueryingPlaylist && ( - { - dispatch(resetQueriedListPlaylist()) - dispatch(setIsQueryingPlaylist(false)) - dispatch(setQueriedValuePlaylist('')) - }} /> - )} - +
+
+ dispatch(setQueriedValuePlaylist(e.target.value))} + onKeyDown={handleInputKeyDown} + /> + {queriedValuePlaylist && ( + + )} +
- ); -} +}; diff --git a/src/components/UploadFolderModal.tsx b/src/components/UploadFolderModal.tsx new file mode 100644 index 0000000..4f871c1 --- /dev/null +++ b/src/components/UploadFolderModal.tsx @@ -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((resolve) => { + new Compressor(img, { + quality: 0.6, + maxWidth: 300, + mimeType: 'image/webp', + success(result) { + compressedFile = new File([result], 'cover.webp', { type: 'image/webp' }); + resolve(); + }, + error() { resolve(); } + }); + }); + if (!compressedFile) return undefined; + const dataURI = await toBase64(compressedFile); + if (!dataURI || typeof dataURI !== 'string') return undefined; + return dataURI.split(',')[1]; // bare base64 +}; + +const parseAuthorFromPath = (relativePath: string) => { + // "Bob/First/song.mp3" => ["Bob","First","song.mp3"] => "Bob - First" + const parts = relativePath.split('/').filter(Boolean); + if (parts.length <= 1) return ''; // no folder level around the file + const noFileParts = parts.slice(0, -1); + return noFileParts.join(' - '); +}; + +const topLevelFolderFromPaths = (relPaths: string[]) => { + // Take first component if it’s common, else take the first file’s first folder + const firstParts = relPaths.map(p => p.split('/').filter(Boolean)[0]).filter(Boolean); + if (!firstParts.length) return ''; + const first = firstParts[0]; + const allSame = firstParts.every(x => x === first); + return allSame ? first : first; +}; + +const UploadFolderModal: React.FC = () => { + const username = useSelector((state: RootState) => state?.auth?.user?.selectedName || state?.auth?.user?.name); + const modal = useUploadFolderModal(); + const dispatch = useDispatch(); + + const [tracks, setTracks] = useState([]); + const [sameAuthor, setSameAuthor] = useState(true); + const [globalAuthor, setGlobalAuthor] = useState(''); + const [sameCover, setSameCover] = useState(true); + const [globalCoverFile, setGlobalCoverFile] = useState(null); + + const [createPlaylist, setCreatePlaylist] = useState(false); + const [playlistTitle, setPlaylistTitle] = useState(''); + const [playlistDescription, setPlaylistDescription] = useState(''); + const [playlistCoverFile, setPlaylistCoverFile] = useState(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 = 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 ( + +
+ {/* Native input to ensure webkitdirectory is passed through */} +
+
Select a folder
+ +
+ +
+
+ {/* Global author toggle */} +
+ setSameAuthor(e.target.checked)} /> + +
+ {sameAuthor ? ( + setGlobalAuthor(e.target.value)} /> + ) : null} +
+ +
+ {/* Global cover toggle */} +
+ setSameCover(e.target.checked)} /> + +
+ {sameCover ? ( +
+
Select a cover image
+ setGlobalCoverFile(e.target.files?.[0] || null)} /> +
+ ) : null} +
+
+ + {/* Files table/list */} + {tracks.length > 0 && ( +
+
+
+ {/* Scroll region with sticky header */} +
+ {/* Header */} +
+
Title
+
{showPerFileAuthor ? 'Author' : 'Author (global)'}
+
{showPerFileCover ? 'Cover' : 'File'}
+
+ + {/* Rows */} +
+ {tracks.map((t, idx) => ( +
+ updateTrackTitle(idx, e.target.value)} + /> + + {showPerFileAuthor ? ( + updateTrackAuthor(idx, e.target.value)} + /> + ) : ( +
+ {t.author || globalAuthor || 'Author will be the global value'} +
+ )} + + {showPerFileCover ? ( + updateTrackCover(idx, e.target.files?.[0] || null)} + /> + ) : ( +
+ {filenameNoExt(t.file.name)} +
+ )} +
+ ))} +
+
+
+
+
+ )} + + {/* Optional playlist */} +
+ setCreatePlaylist(e.target.checked)} + /> + +
+ {createPlaylist && ( +
+ setPlaylistTitle(e.target.value)} + /> +