3
0
mirror of https://github.com/Qortal/ear-bump.git synced 2025-01-28 22:02:19 +00:00

Initial Ear-bump commit in its own repo

This commit is contained in:
Justin Ferrari 2023-12-08 13:46:59 -05:00
commit 17493aa8d6
112 changed files with 16177 additions and 0 deletions

14
.eslintrc.cjs Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
},
}

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.zip

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ear Bump</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8297
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
package.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "ear_bump",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.13",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@reduxjs/toolkit": "^1.9.3",
"compressorjs": "^1.2.1",
"localforage": "^1.10.0",
"moment": "^2.29.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.45.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.10.1",
"react-intersection-observer": "^9.4.3",
"react-redux": "^8.0.5",
"react-router-dom": "^6.9.0",
"react-toastify": "^9.1.2",
"short-unique-id": "^4.4.4",
"tailwind-merge": "^1.13.2",
"ts-key-enum": "^2.0.12",
"use-sound": "^4.0.1",
"zustand": "^4.3.8"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.2",
"vite": "^4.3.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

56
src/App.tsx Normal file
View File

@ -0,0 +1,56 @@
// @ts-nocheck
import { useEffect, useState } from "react";
import { Routes, Route } from "react-router-dom";
import { StoreList } from "./pages/StoreList/StoreList";
import { ThemeProvider } from "@mui/material/styles";
import { CssBaseline } from "@mui/material";
import { lightTheme, darkTheme } from "./styles/theme";
import { store } from "./state/store";
import { Provider } from "react-redux";
import GlobalWrapper from "./wrappers/GlobalWrapper";
import Notification from "./components/common/Notification/Notification";
import { Home } from "./pages/Home/Home";
import { VideoContent } from "./pages/VideoContent/VideoContent";
import { Layout } from "./components/layout/Layout";
import ModalProvider from "./wrappers/ModalProvider";
import { Search } from "./pages/Search/Search";
import ToasterProvider from "./wrappers/ToasterProvider";
import DownloadWrapper from "./wrappers/DownloadWrapper";
import { Liked } from "./pages/Liked/Liked";
import { Library } from "./pages/Library/Library";
import { Playlists } from "./pages/Playlists/Playlists";
import { Playlist } from "./pages/Playlist/Playlist";
import { Newest } from "./pages/Newest/Newest";
function App() {
// const themeColor = window._qdnTheme
const [theme, setTheme] = useState("dark");
return (
<Provider store={store}>
<Notification />
<DownloadWrapper>
<GlobalWrapper setTheme={(val: string) => setTheme(val)}>
<ModalProvider />
<ToasterProvider />
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/search" element={<Search />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:name/:playlistId" element={<Playlist />} />
<Route path="/liked" element={<Liked />} />
<Route path="/library" element={<Library />} />
<Route path="/newest" element={<Newest />} />
</Routes>
</Layout>
</GlobalWrapper>
</DownloadWrapper>
</Provider>
);
}
export default App;

BIN
src/assets/img/liked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,25 @@
interface AccountCircleSVGProps {
color: string
height: string
width: string
}
export const AccountCircleSVG: React.FC<AccountCircleSVGProps> = ({
color,
height,
width
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={height}
viewBox="0 96 960 960"
width={width}
>
<path
fill={color}
d="M222 801q63-44 125-67.5T480 710q71 0 133.5 23.5T739 801q44-54 62.5-109T820 576q0-145-97.5-242.5T480 236q-145 0-242.5 97.5T140 576q0 61 19 116t63 109Zm257.814-195Q422 606 382.5 566.314q-39.5-39.686-39.5-97.5t39.686-97.314q39.686-39.5 97.5-39.5t97.314 39.686q39.5 39.686 39.5 97.5T577.314 566.5q-39.686 39.5-97.5 39.5Zm.654 370Q398 976 325 944.5q-73-31.5-127.5-86t-86-127.266Q80 658.468 80 575.734T111.5 420.5q31.5-72.5 86-127t127.266-86q72.766-31.5 155.5-31.5T635.5 207.5q72.5 31.5 127 86t86 127.032q31.5 72.532 31.5 155T848.5 731q-31.5 73-86 127.5t-127.032 86q-72.532 31.5-155 31.5ZM480 916q55 0 107.5-16T691 844q-51-36-104-55t-107-19q-54 0-107 19t-104 55q51 40 103.5 56T480 916Zm0-370q34 0 55.5-21.5T557 469q0-34-21.5-55.5T480 392q-34 0-55.5 21.5T403 469q0 34 21.5 55.5T480 546Zm0-77Zm0 374Z"
/>
</svg>
)
}

View File

@ -0,0 +1,23 @@
import { IconTypes } from './IconTypes'
export const DarkModeSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc
}) => {
return (
<svg
className={className}
onClick={onClickFunc}
fill={color}
xmlns="http://www.w3.org/2000/svg"
height={height}
viewBox="0 96 960 960"
width={width}
>
<path d="M480 936q-150 0-255-105T120 576q0-150 105-255t255-105q8 0 17 .5t23 1.5q-36 32-56 79t-20 99q0 90 63 153t153 63q52 0 99-18.5t79-51.5q1 12 1.5 19.5t.5 14.5q0 150-105 255T480 936Zm0-60q109 0 190-67.5T771 650q-25 11-53.667 16.5Q688.667 672 660 672q-114.689 0-195.345-80.655Q384 510.689 384 396q0-24 5-51.5t18-62.5q-98 27-162.5 109.5T180 576q0 125 87.5 212.5T480 876Zm-4-297Z" />
</svg>
)
}

View File

@ -0,0 +1,7 @@
export interface IconTypes {
color: string;
height: string;
width: string;
className?: string;
onClickFunc?: (e?: any) => void;
}

View File

@ -0,0 +1,23 @@
import { IconTypes } from './IconTypes'
export const LightModeSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc
}) => {
return (
<svg
className={className}
onClick={onClickFunc}
fill={color}
xmlns="http://www.w3.org/2000/svg"
height={height}
viewBox="0 96 960 960"
width={width}
>
<path d="M479.765 716Q538 716 579 675.235q41-40.764 41-99Q620 518 579.235 477q-40.764-41-99-41Q422 436 381 476.765q-41 40.764-41 99Q340 634 380.765 675q40.764 41 99 41Zm.235 60q-83 0-141.5-58.5T280 576q0-83 58.5-141.5T480 376q83 0 141.5 58.5T680 576q0 83-58.5 141.5T480 776ZM70 606q-12.75 0-21.375-8.675Q40 588.649 40 575.825 40 563 48.625 554.5T70 546h100q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T170 606H70Zm720 0q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T790 546h100q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T890 606H790ZM479.825 296Q467 296 458.5 287.375T450 266V166q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T510 166v100q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625Zm0 720q-12.825 0-21.325-8.62-8.5-8.63-8.5-21.38V886q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T510 886v100q0 12.75-8.675 21.38-8.676 8.62-21.5 8.62ZM240 378l-57-56q-9-9-8.629-21.603.37-12.604 8.526-21.5 8.896-8.897 21.5-8.897Q217 270 226 279l56 57q8 9 8 21t-8 20.5q-8 8.5-20.5 8.5t-21.5-8Zm494 495-56-57q-8-9-8-21.375T678.5 774q8.5-9 20.5-9t21 9l57 56q9 9 8.629 21.603-.37 12.604-8.526 21.5-8.896 8.897-21.5 8.897Q743 882 734 873Zm-56-495q-9-9-9-21t9-21l56-57q9-9 21.603-8.629 12.604.37 21.5 8.526 8.897 8.896 8.897 21.5Q786 313 777 322l-57 56q-8 8-20.364 8-12.363 0-21.636-8ZM182.897 873.103q-8.897-8.896-8.897-21.5Q174 839 183 830l57-56q8.8-9 20.9-9 12.1 0 20.709 9Q291 783 291 795t-9 21l-56 57q-9 9-21.603 8.629-12.604-.37-21.5-8.526ZM480 576Z" />
</svg>
)
}

View File

@ -0,0 +1,124 @@
import { TbPlaylist } from "react-icons/tb";
import { AiOutlinePlus } from "react-icons/ai";
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 useOnPlay from "../hooks/useOnPlay";
import { useCallback, useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../state/store";
import { useFetchSongs } from "../hooks/fetchSongs";
import LazyLoad from "./common/LazyLoad";
import { FcMusic } from "react-icons/fc"
import { BsMusicNoteList, BsMusicNote } from "react-icons/bs"
import { setNewPlayList } from "../state/features/globalSlice";
import Portal from "./common/Portal";
interface LibraryProps {
songs: Song[];
}
export const AddLibrary: React.FC<LibraryProps> = ({
songs
}) => {
const username = useSelector((state: RootState) => state?.auth?.user?.name);
const newPlaylist = useSelector((state: RootState) => state?.global.newPlayList);
const dispatch = useDispatch()
const uploadModal = useUploadModal();
const uploadPlaylistModal = useUploadPlaylistModal()
const onClick = () => {
if (!username) {
toast.error('Please authenticate')
return
}
return uploadModal.onOpen();
}
const onClickPlaylist = () => {
dispatch(setNewPlayList({
title: "",
description: "",
songs: [],
image: null
}))
}
return (
<>
<div className="flex flex-col">
<div 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">
Add Song
</p>
</div>
<AiOutlinePlus
onClick={onClick}
size={20}
className="
text-neutral-400
cursor-pointer
hover:text-white
transition
"
/>
</div>
<div 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">
Add Playlist
</p>
</div>
<AiOutlinePlus
onClick={onClickPlaylist}
size={20}
className="
text-neutral-400
cursor-pointer
hover:text-white
transition
"
/>
</div>
</div>
{newPlaylist && (
<Portal>
<div className="bg-red-500 fixed top-10 right-5 p-3 flex flex-col space-y-2">
<div className="flex space-x-2">
<p>{newPlaylist?.title || 'New Playlist'}</p>
<p>{newPlaylist?.songs?.length}</p>
</div>
<button
className="bg-blue-500 text-white px-4 py-2 rounded"
onClick={() => { uploadPlaylistModal.onOpen() }}
>
Save Playlist
</button>
<button
className="bg-gray-300 text-black px-2 py-1 rounded text-sm"
onClick={() => {dispatch(setNewPlayList(null)) }}
>
Cancel
</button>
</div>
</Portal>
)}
</>
);
}

View File

@ -0,0 +1,62 @@
import { MdPlaylistAdd } from "react-icons/md";
import { useNavigate } from "react-router-dom";
export const AddPlayList = () => {
const navigate = useNavigate()
const onClick = () => {
navigate('/liked')
// router.push(href);
};
return (
<button
onClick={onClick}
className="
relative
group
flex
items-center
rounded-md
overflow-hidden
gap-x-4
bg-neutral-100/10
cursor-pointer
hover:bg-neutral-100/20
transition
pr-4
"
>
<p className="font-medium truncate py-5">
New Playlist
</p>
<div
className="
absolute
transition
opacity-0
rounded-full
flex
items-center
justify-center
bg-green-500
p-4
drop-shadow-md
right-5
group-hover:opacity-100
hover:scale-110
"
>
<MdPlaylistAdd className="text-black" />
</div>
</button>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { AiOutlineHeart, AiFillHeart } from "react-icons/ai";
import { toast } from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../state/store";
import { Favorites, removeFavSong, setFavSong, setNewPlayList } from "../state/features/globalSlice";
import { Song } from "../types";
import {MdPlaylistAdd} from 'react-icons/md'
interface LikeButtonProps {
song: Song
};
export const AddToPlaylistButton: React.FC<LikeButtonProps> = ({
song
}) => {
const newPlaylist = useSelector((state: RootState) => state?.global.newPlayList);
const dispatch = useDispatch()
const addSongToPlaylist = ()=> {
if(!newPlaylist) return
if(newPlaylist && newPlaylist?.songs?.find((item)=> song.id === item.identifier)){
return
}
const playlist = {
...newPlaylist,
songs: [...newPlaylist.songs, {
identifier: song.id,
name: song.name,
service: 'AUDIO',
title: song.title,
author: song.author
}]
}
dispatch(setNewPlayList(playlist))
}
if(!newPlaylist) return null
return (
<button
className="
cursor-pointer
hover:opacity-75
transition
"
onClick={addSongToPlaylist}
>
<MdPlaylistAdd color={ 'white'} size={25} />
</button>
);
}

28
src/components/Box.tsx Normal file
View File

@ -0,0 +1,28 @@
import { twMerge } from "tailwind-merge";
interface BoxProps {
children: React.ReactNode;
className?: string;
}
const Box: React.FC<BoxProps> = ({
children,
className
}) => {
return (
<div
className={twMerge(
`
bg-neutral-900
rounded-lg
h-fit
w-full
`,
className
)}>
{children}
</div>
);
}
export default Box;

47
src/components/Button.tsx Normal file
View File

@ -0,0 +1,47 @@
import { forwardRef } from "react";
import { twMerge } from "tailwind-merge";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
className,
children,
disabled,
type = 'button',
...props
}, ref) => {
return (
<button
type={type}
className={twMerge(
`
w-full
rounded-full
bg-green-500
border
border-transparent
px-3
py-3
disabled:cursor-not-allowed
disabled:opacity-50
text-black
font-bold
hover:opacity-75
transition
`,
disabled && 'opacity-75 cursor-not-allowed',
className
)}
disabled={disabled}
ref={ref}
{...props}
>
{children}
</button>
);
});
Button.displayName = "Button";
export default Button;

152
src/components/Header.tsx Normal file
View File

@ -0,0 +1,152 @@
import { twMerge } from "tailwind-merge";
import { RxCaretLeft, RxCaretRight } from "react-icons/rx";
import { FaUserAlt } from "react-icons/fa";
import { toast } from "react-hot-toast";
import { HiHome } from "react-icons/hi";
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";
interface HeaderProps {
children: React.ReactNode;
className?: string;
}
const Header: React.FC<HeaderProps> = ({
children,
className,
}) => {
const username = useSelector((state: RootState) => state?.auth?.user?.name);
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 askForAccountInformation = useCallback(async () => {
try {
let account = await qortalRequest({
action: "GET_USER_ACCOUNT"
});
const name = await getNameInfo(account.address);
dispatch(addUser({ ...account, name }));
} catch (error) {
console.error(error);
}
}, []);
return (
<div
className={twMerge(`
h-fit
bg-gradient-to-b
from-emerald-800
p-6
`,
className
)}>
<div className="w-full mb-4 flex items-center justify-between">
<div className="hidden md:flex gap-x-2 items-center">
{/* <button
onClick={() => {}}
className="
rounded-full
bg-black
flex
items-center
justify-center
cursor-pointer
hover:opacity-75
transition
"
>
<RxCaretLeft className="text-white" size={35} />
</button>
<button
onClick={() => {}}
className="
rounded-full
bg-black
flex
items-center
justify-center
cursor-pointer
hover:opacity-75
transition
"
>
<RxCaretRight className="text-white" size={35} />
</button> */}
</div>
<div className="flex md:hidden gap-x-2 items-center">
<button
onClick={() => {}}
className="
rounded-full
p-2
bg-white
flex
items-center
justify-center
cursor-pointer
hover:opacity-75
transition
"
>
<HiHome className="text-black" size={20} />
</button>
<button
onClick={() => {}}
className="
rounded-full
p-2
bg-white
flex
items-center
justify-center
cursor-pointer
hover:opacity-75
transition
"
>
<BiSearch className="text-black" size={20} />
</button>
</div>
<div className="flex justify-between items-center gap-x-4">
{!username && (
<>
<div>
<Button
onClick={()=> {
askForAccountInformation()
}}
className="bg-white px-6 py-2"
>
Authenticate
</Button>
</div>
</>
)}
</div>
</div>
{children}
</div>
);
}
export default Header;

48
src/components/Input.tsx Normal file
View File

@ -0,0 +1,48 @@
import { forwardRef } from "react";
import { twMerge } from "tailwind-merge"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = forwardRef<HTMLInputElement, InputProps>(({
className,
type,
disabled,
...props
}, ref) => {
return (
<input
type={type}
className={twMerge(
`
flex
w-full
rounded-md
bg-neutral-700
border
border-transparent
px-3
py-3
text-sm
file:border-0
file:bg-transparent
file:text-sm
file:font-medium
placeholder:text-neutral-400
disabled:cursor-not-allowed
disabled:opacity-50
focus:outline-none
`,
disabled && 'opacity-75',
className
)}
disabled={disabled}
ref={ref}
{...props}
/>
)
});
Input.displayName = "Input";
export default Input

View File

@ -0,0 +1,116 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { AiOutlineHeart, AiFillHeart } from "react-icons/ai";
import { toast } from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../state/store";
import { Favorites, removeFavSong, setFavSong } from "../state/features/globalSlice";
import { Song } from "../types";
import localforage from 'localforage'
const favoritesStorage = localforage.createInstance({
name: 'ear-bump-favorites'
})
interface LikeButtonProps {
songId: string;
name: string;
service: string
songData: Song
};
const LikeButton: React.FC<LikeButtonProps> = ({
songId,
name,
service = 'AUDIO',
songData
}) => {
const songs = useSelector((state: RootState) => state.global?.favorites?.songs);
const dispatch = useDispatch()
const isfavoriting = useRef(false)
const isLiked = songs && songs[songId]
const Icon = isLiked ? AiFillHeart : AiOutlineHeart;
const handleLike = async () => {
try {
if(isfavoriting.current) return
isfavoriting.current = true
const isLiked = songs && songs[songId]
if(isLiked){
dispatch(removeFavSong({
identifier: songId,
name,
service
}))
let favoritesObj: Favorites | null =
await favoritesStorage.getItem('favorites') || null
if(favoritesObj && favoritesObj?.songs[songId]){
delete favoritesObj.songs[songId]
await favoritesStorage.setItem('favorites', favoritesObj)
}
}else {
dispatch(setFavSong({
identifier: songId,
name,
service,
songData
}))
let favoritesObj: Favorites | null =
await favoritesStorage.getItem('favorites') || null
if(!favoritesObj){
const newObj: Favorites = {
songs: {
[songId]: {
identifier: songId,
name,
service,
}
},
playlists: {}
}
await favoritesStorage.setItem('favorites', newObj)
} else {
favoritesObj.songs[songId] = {
identifier: songId,
name,
service,
}
await favoritesStorage.setItem('favorites', favoritesObj)
}
}
isfavoriting.current = false
} catch (error) {
console.error(error)
}
}
return (
<button
className="
cursor-pointer
hover:opacity-75
transition
"
onClick={handleLike}
>
<Icon color={isLiked ? '#22c55e' : 'white'} size={25} />
</button>
);
}
export default LikeButton;

View File

@ -0,0 +1,56 @@
import useOnPlay from "../hooks/useOnPlay";
import { Song } from "../types";
import { AddToPlaylistButton } from "./AddToPlayistButton";
import LikeButton from "./LikeButton";
import MediaItem from "./MediaItem";
interface SearchContentProps {
songs: Song[];
}
export const LikedContent: React.FC<SearchContentProps> = ({
songs
}) => {
const onPlay = useOnPlay(songs);
if (songs.length === 0) {
return (
<div
className="
flex
flex-col
gap-y-2
w-full
px-6
text-neutral-400
"
>
No songs found.
</div>
)
}
console.log('liked content')
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 className="flex-1">
<MediaItem
onClick={(id: string) => onPlay(id)}
data={song}
/>
</div>
<AddToPlaylistButton song={song} />
<LikeButton songId={song.id} name={song.name} service={song.service} songData={song} />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,77 @@
import { FaPlay } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
interface ListItemProps {
image: string;
name: string;
href: string;
}
const ListItem: React.FC<ListItemProps> = ({
image,
name,
href,
}) => {
const navigate = useNavigate()
const onClick = () => {
navigate('/liked')
// router.push(href);
};
return (
<button
onClick={onClick}
className="
relative
group
flex
items-center
rounded-md
overflow-hidden
gap-x-4
bg-neutral-100/10
cursor-pointer
hover:bg-neutral-100/20
transition
pr-4
"
>
<div className="relative min-h-[64px] min-w-[64px]">
<img
className="object-cover absolute"
src={image}
alt="Image"
/>
</div>
<p className="font-medium truncate py-5">
{name}
</p>
<div
className="
absolute
transition
opacity-0
rounded-full
flex
items-center
justify-center
bg-green-500
p-4
drop-shadow-md
right-5
group-hover:opacity-100
hover:scale-110
"
>
<FaPlay className="text-black" />
</div>
</button>
);
}
export default ListItem;

View File

@ -0,0 +1,104 @@
"use client";
import { useDispatch, useSelector } from "react-redux";
import useLoadImage from "../hooks/useLoadImage";
import usePlayer from "../hooks/usePlayer";
import { Song } from "../types";
import { RootState } from "../state/store";
import radioImg from '../assets/img/radio-cassette.webp'
import { useContext } from "react";
import { MyContext } from "../wrappers/DownloadWrapper";
import { setAddToDownloads, setCurrentSong } from "../state/features/globalSlice";
interface MediaItemProps {
data: Song;
onClick?: (id: string) => void;
}
const MediaItem: React.FC<MediaItemProps> = ({
data,
onClick,
}) => {
const player = usePlayer();
const imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
const { downloadVideo } = useContext(MyContext)
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
const dispatch = useDispatch()
const handleClick = () => {
if(data?.status?.status === 'READY' || downloads[data.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: data.name,
service: 'AUDIO',
id: data.id,
identifier: data.id,
url:`/arbitrary/AUDIO/${data.name}/${data.id}`,
status: data?.status,
title: data?.title || "",
author: data?.author || "",
}))
}else {
downloadVideo({
name: data.name,
service: 'AUDIO',
identifier: data.id,
title: data?.title || "",
author: data?.author || "",
id: data.id
})
}
dispatch(setCurrentSong(data.id))
// return player.setId(data.id);
};
return (
<div
onClick={handleClick}
className="
flex
items-center
gap-x-3
cursor-pointer
hover:bg-neutral-800/50
w-full
p-2
rounded-md
"
>
<div
className="
relative
rounded-md
min-h-[48px]
min-w-[48px]
overflow-hidden
"
>
<img
src={imageCoverHash[data?.id] || radioImg}
alt="MediaItem"
className="object-cover absolute"
/>
</div>
<div className="flex flex-col gap-y-1 overflow-hidden">
<p className="text-white truncate">{data?.title}</p>
<p className="text-neutral-400 text-sm truncate">
By {data?.author}
</p>
</div>
</div>
);
}
export default MediaItem;

104
src/components/Modal.tsx Normal file
View File

@ -0,0 +1,104 @@
import * as Dialog from '@radix-ui/react-dialog';
import { IoMdClose } from 'react-icons/io';
interface ModalProps {
isOpen: boolean;
onChange: (open: boolean) => void;
title: string;
description: string;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onChange,
title,
description,
children
}) => {
return (
<Dialog.Root open={isOpen} defaultOpen={isOpen} onOpenChange={onChange}>
<Dialog.Portal>
<Dialog.Overlay
className="
bg-neutral-900/90
backdrop-blur-sm
fixed
inset-0
"
/>
<Dialog.Content
className="
fixed
drop-shadow-md
border
border-neutral-700
top-[50%]
left-[50%]
max-h-full
h-full
md:h-auto
md:max-h-[85vh]
w-full
md:w-[90vw]
md:max-w-[450px]
translate-x-[-50%]
translate-y-[-50%]
rounded-md
bg-neutral-800
p-[25px]
focus:outline-none
overflow-y-auto
">
<Dialog.Title
className="
text-xl
text-center
font-bold
mb-4
"
>
{title}
</Dialog.Title>
<Dialog.Description
className="
mb-5
text-sm
leading-normal
text-center
"
>
{description}
</Dialog.Description>
<div>
{children}
</div>
<Dialog.Close asChild>
<button
className="
text-neutral-400
hover:text-white
absolute
top-[10px]
right-[10px]
inline-flex
h-[25px]
w-[25px]
appearance-none
items-center
justify-center
rounded-full
focus:outline-none
"
aria-label="Close"
>
<IoMdClose />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export default Modal;

View File

@ -0,0 +1,99 @@
import { useCallback, useEffect, useRef } from "react";
import { useFetchSongs } from "../hooks/fetchSongs";
import useOnPlay from "../hooks/useOnPlay";
import { Song } from "../types";
import SongItem from "./SongItem";
import LazyLoad from "./common/LazyLoad";
import { useSelector } from "react-redux";
import { RootState } from "../state/store";
import { CircularProgress } from "@mui/material";
interface PageContentProps {
songs: Song[];
}
const PageContent: React.FC<PageContentProps> = ({
songs
}) => {
const onPlay = useOnPlay(songs);
const initialFetch = useRef(songs?.length > 0 ? true : false)
const username = useSelector((state: RootState) => state?.auth?.user?.name);
const songListRecent = useSelector((state: RootState) => state?.global.songListRecent);
const { getRecentSongs } = useFetchSongs()
const fetchRecentSongs = useCallback(async () => {
try {
await getRecentSongs()
initialFetch.current = true
} catch (error) {
}
}, [getRecentSongs])
useEffect(() => {
if (!initialFetch.current) {
fetchRecentSongs()
}
}, [])
if (!initialFetch.current) return (
<div
style={{
display: 'flex',
justifyContent: 'center',
minHeight: '25px',
overflow: 'hidden'
}}
>
<div
>
<CircularProgress />
</div>
</div>
)
if (songs.length === 0) {
return (
<div className="mt-4 text-neutral-400">
No songs available.
</div>
)
}
return (
<>
<div
className="
grid
grid-cols-2
sm:grid-cols-3
md:grid-cols-3
lg:grid-cols-4
xl:grid-cols-5
2xl:grid-cols-8
gap-4
mt-4
"
>
{songListRecent.map((item) => {
return (
<SongItem
onClick={(id: string) => onPlay(id)}
key={item.id}
data={item}
/>
)
})}
</div>
<LazyLoad onLoadMore={fetchRecentSongs}></LazyLoad>
</>
);
}
export default PageContent;

View File

@ -0,0 +1,28 @@
import { FaPlay } from "react-icons/fa";
const PlayButton = () => {
return (
<button
className="
transition
opacity-0
rounded-full
flex
items-center
justify-center
bg-green-500
p-4
drop-shadow-md
translate
translate-y-1/4
group-hover:opacity-100
group-hover:translate-y-0
hover:scale-110
"
>
<FaPlay className="text-black" />
</button>
);
}
export default PlayButton;

138
src/components/Player.tsx Normal file
View File

@ -0,0 +1,138 @@
import PlayerContent from "./PlayerContent";
import { useSelector } from "react-redux";
import { RootState } from "../state/store";
import { PlayerContentShow } from "./PlayerContentShow";
import { useContext, useEffect, useMemo, useRef } from "react";
import { MyContext } from "../wrappers/DownloadWrapper";
const Player = () => {
// const player = usePlayer();
// const { song } = useGetSongById(player.activeId);
// const songUrl = useLoadSongUrl(song!);
const { downloadVideo } = useContext(MyContext)
const hasRedownloaded = useRef(false)
const currentSong = useSelector(
(state: RootState) => state.global.currentSong
)
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
useEffect(()=> {
if(currentSong){
hasRedownloaded.current = false
}
}, [currentSong])
const status = useMemo(()=> {
let statusVar = ""
let song = null
if (currentSong && downloads[currentSong]) {
song = downloads[currentSong]
}
if(song){
statusVar = song?.status?.status || ""
}
return statusVar
}, [downloads, currentSong])
const songItem = useMemo(()=> {
let song = null
if (currentSong && downloads[currentSong]) {
song = downloads[currentSong]
}
return song
}, [downloads, currentSong])
const player = {
activeId: "1",
}
let song: any = null
if (currentSong && downloads[currentSong]) {
song = downloads[currentSong]
}
let songUrl = null
if (song && song?.status?.status === 'READY' &&
!!song.url) {
songUrl = song.url
}
const refetch = async ({name, service, identifier}: any)=> {
try {
await qortalRequest({
action: 'GET_QDN_RESOURCE_PROPERTIES',
name,
service,
identifier
})
} catch (error) {
}
}
useEffect(() => {
if (
songItem && status === 'DOWNLOADED' &&
hasRedownloaded?.current === false
) {
refetch({
name: songItem.name,
service: 'AUDIO',
identifier: songItem.id
})
hasRedownloaded.current = true
}
}, [status, songItem])
if (!song) return null
if (!songUrl) {
return <div
className="
fixed
bottom-0
bg-black
w-full
py-2
h-[80px]
px-4
"
>
<PlayerContentShow song={song} songUrl={songUrl} percentLoaded={`${(song?.status?.percentLoaded || 0)}%`} />
</div>
}
return (
<div
className="
fixed
bottom-0
bg-black
w-full
py-2
h-[80px]
px-4
"
>
<PlayerContent song={song} songUrl={songUrl} />
</div>
);
}
export default Player;

View File

@ -0,0 +1,469 @@
import useSound from "use-sound";
import { useContext, useEffect, useMemo, useState } from "react";
import { BsPauseFill, BsPlayFill } from "react-icons/bs";
import { HiSpeakerWave, HiSpeakerXMark } from "react-icons/hi2";
import { AiFillStepBackward, AiFillStepForward } from "react-icons/ai";
import CircularProgress from '@mui/material/CircularProgress'
import * as RadixSlider from '@radix-ui/react-slider';
// import LikeButton from "./LikeButton";
import MediaItem from "./MediaItem";
import Slider from "./Slider";
import { Song } from "../types";
import usePlayer from "../hooks/usePlayer";
import LikeButton from "./LikeButton";
import { useDispatch, useSelector } from "react-redux";
import { setAddToDownloads, setCurrentSong, setVolumePlayer, upsertNowPlayingPlaylist } from "../state/features/globalSlice";
import { RootState } from "../state/store";
import { AddToPlaylistButton } from "./AddToPlayistButton";
import { MyContext } from "../wrappers/DownloadWrapper";
interface PlayerContentProps {
song: Song;
songUrl: string;
}
const PlayerContent: React.FC<PlayerContentProps> = ({
song,
songUrl
}) => {
const player = usePlayer();
const volume = useSelector(
(state: RootState) => state.global.volume
)
const [isPlaying, setIsPlaying] = useState(false);
const [isLoaded, setIsLoaded] = useState(false)
const playlistHash = useSelector((state: RootState) => state.global.playlistHash);
const dispatch = useDispatch()
const nowPlayingPlaylist = useSelector(
(state: RootState) => state.global.nowPlayingPlaylist
)
const favoriteList = useSelector(
(state: RootState) => state.global.favoriteList
)
const currentPlaylist = useSelector(
(state: RootState) => state.global.currentPlaylist
)
const { downloadVideo } = useContext(MyContext)
const newPlaylist = useSelector((state: RootState) => state?.global.newPlayList);
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
const Icon = isPlaying ? BsPauseFill : BsPlayFill;
const VolumeIcon = volume === 0 ? HiSpeakerXMark : HiSpeakerWave;
const [progress, setProgress] = useState(0)
const setVolume = (val: number)=> {
dispatch(setVolumePlayer(val))
}
const songData = useMemo(()=> {
return song
}, [songUrl])
const handleNowPlayingPlaylist = ()=> {
if (nowPlayingPlaylist.length === 0) {
return;
}
const currentIndex = nowPlayingPlaylist.findIndex((item) => item.id === song.id);
const nextSong = nowPlayingPlaylist[currentIndex + 1];
if (!nextSong) {
dispatch(setCurrentSong(nowPlayingPlaylist[0].id))
return
}
dispatch(setCurrentSong(nextSong.id))
}
const handleLikedPlaylist = ()=> {
if (favoriteList.length === 0) {
return;
}
const currentIndex = favoriteList.findIndex((item) => item.id === song.id);
const nextSong = favoriteList[currentIndex + 1];
let songToPlay = favoriteList[0]
if (nextSong?.id) {
songToPlay = nextSong
}
dispatch(setCurrentSong(songToPlay?.id))
if(songToPlay?.status?.status === 'READY' || downloads[songToPlay.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: songToPlay.name,
service: 'AUDIO',
id: songToPlay.id,
identifier: songToPlay.id,
url:`/arbitrary/AUDIO/${songToPlay.name}/${songToPlay.id}`,
status: songToPlay?.status,
title: songToPlay?.title || "",
author: songToPlay?.author || "",
}))
}else {
downloadVideo({
name: songToPlay.name,
service: 'AUDIO',
identifier: songToPlay.id,
title: songToPlay?.title || "",
author: songToPlay?.author || "",
id: songToPlay.id
})
}
}
const handleCustomPlaylist = (playlist: any)=> {
console.log('handleCustomPlaylist', playlist)
if (!playlist?.songs || playlist?.songs?.length === 0) {
return;
}
const songList = playlist?.songs
const currentIndex = songList.findIndex((item: any) => item?.identifier === song?.id);
const nextSong = songList[currentIndex + 1];
let songToPlay = songList[0]
if (nextSong?.identifier) {
songToPlay = nextSong
}
dispatch(setCurrentSong(songToPlay?.identifier))
if(songToPlay?.status?.status === 'READY' || downloads[songToPlay.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: songToPlay.name,
service: 'AUDIO',
id: songToPlay.identifier,
identifier: songToPlay.identifier,
url:`/arbitrary/AUDIO/${songToPlay.name}/${songToPlay.identifier}`,
status: songToPlay?.status,
title: songToPlay?.title || "",
author: songToPlay?.author || "",
}))
}else {
downloadVideo({
name: songToPlay.name,
service: 'AUDIO',
identifier: songToPlay.identifier,
title: songToPlay?.title || "",
author: songToPlay?.author || "",
id: songToPlay.identifier
})
}
}
const onPlayNext = () => {
console.log('currentPlaylist', currentPlaylist, playlistHash)
if(currentPlaylist === 'nowPlayingPlaylist'){
handleNowPlayingPlaylist()
} else if(currentPlaylist === 'likedPlaylist'){
handleLikedPlaylist()
} else if(playlistHash[currentPlaylist]){
handleCustomPlaylist(playlistHash[currentPlaylist])
}
}
const handleNowPlayingPlaylistPrev = ()=> {
if (nowPlayingPlaylist.length === 0) {
return;
}
const currentIndex = nowPlayingPlaylist.findIndex((item) => item.id === song.id);
const previousSong = nowPlayingPlaylist[currentIndex - 1];
if (!previousSong) {
const lastSong = nowPlayingPlaylist[nowPlayingPlaylist.length - 1]
dispatch(setCurrentSong(lastSong.id))
return
}
dispatch(setCurrentSong(previousSong.id))
}
const handleLikedPlaylistPrev = ()=> {
if (favoriteList.length === 0) {
return;
}
const currentIndex = favoriteList.findIndex((item) => item.id === song.id);
const nextSong = favoriteList[currentIndex - 1];
let songToPlay = favoriteList[0]
if (nextSong?.id) {
songToPlay = nextSong
}
dispatch(setCurrentSong(songToPlay?.id))
if(songToPlay?.status?.status === 'READY' || downloads[songToPlay.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: songToPlay.name,
service: 'AUDIO',
id: songToPlay.id,
identifier: songToPlay.id,
url:`/arbitrary/AUDIO/${songToPlay.name}/${songToPlay.id}`,
status: songToPlay?.status,
title: songToPlay?.title || "",
author: songToPlay?.author || "",
}))
}else {
downloadVideo({
name: songToPlay.name,
service: 'AUDIO',
identifier: songToPlay.id,
title: songToPlay?.title || "",
author: songToPlay?.author || "",
id: songToPlay.id
})
}
}
const handleCustomPlaylistPrev = (playlist: any)=> {
console.log('handleCustomPlaylist', playlist)
if (!playlist?.songs || playlist?.songs?.length === 0) {
return;
}
const songList = playlist?.songs
const currentIndex = songList.findIndex((item: any) => item?.identifier === song?.id);
const nextSong = songList[currentIndex - 1];
let songToPlay = songList[0]
if (nextSong?.identifier) {
songToPlay = nextSong
}
dispatch(setCurrentSong(songToPlay?.identifier))
if(songToPlay?.status?.status === 'READY' || downloads[songToPlay.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: songToPlay.name,
service: 'AUDIO',
id: songToPlay.identifier,
identifier: songToPlay.identifier,
url:`/arbitrary/AUDIO/${songToPlay.name}/${songToPlay.identifier}`,
status: songToPlay?.status,
title: songToPlay?.title || "",
author: songToPlay?.author || "",
}))
}else {
downloadVideo({
name: songToPlay.name,
service: 'AUDIO',
identifier: songToPlay.identifier,
title: songToPlay?.title || "",
author: songToPlay?.author || "",
id: songToPlay.identifier
})
}
}
const onPlayPrevious = () => {
if(currentPlaylist === 'nowPlayingPlaylist'){
handleNowPlayingPlaylistPrev()
} else if(currentPlaylist === 'likedPlaylist'){
handleLikedPlaylistPrev()
} else if(playlistHash[currentPlaylist]){
handleCustomPlaylistPrev(playlistHash[currentPlaylist])
}
}
const [play, { pause, sound }] = useSound(
songUrl || '',
{
volume: volume,
onplay: () => {
setIsLoaded(true)
setIsPlaying(true)
} ,
onend: () => {
setIsPlaying(false);
onPlayNext();
},
onpause: () => setIsPlaying(false),
format: ['mp3', 'wav', 'ogg']
},
);
useEffect(() => {
sound?.play();
return () => {
sound?.unload();
}
}, [sound]);
const handlePlay = () => {
if (!isPlaying) {
play();
} else {
pause();
}
}
const toggleMute = () => {
if (volume === 0) {
setVolume(1);
} else {
setVolume(0);
}
}
const handleProgressChange = (value: number) => {
if (sound) {
sound.seek(value * sound.duration());
}
};
useEffect(() => {
if (sound) {
const interval = setInterval(() => {
setProgress((sound.seek() as number) / (sound.duration() as number));
}, 1000);
return () => {
clearInterval(interval);
};
}
}, [sound]);
useEffect(()=> {
dispatch(upsertNowPlayingPlaylist([songData]))
}, [songData])
return (
<div className="grid grid-cols-2 md:grid-cols-3 h-full">
<div className="flex w-full justify-start">
<div className="flex items-center gap-x-4">
<MediaItem data={song} />
<AddToPlaylistButton song={song} />
<LikeButton songId={song.id} name={song.name} service={song.service} songData={song} />
</div>
</div>
<div
className="
flex
md:hidden
col-auto
w-full
justify-end
items-center
"
>
<div
onClick={handlePlay}
className="
h-10
w-10
flex
items-center
justify-center
rounded-full
bg-white
p-1
cursor-pointer
"
>
<Icon size={30} className="text-black" />
</div>
</div>
<div
className="
hidden
h-full
md:flex
justify-center
items-center
w-full
max-w-[722px]
gap-x-6
"
>
<AiFillStepBackward
onClick={onPlayPrevious}
size={30}
className="
text-neutral-400
cursor-pointer
hover:text-white
transition
"
/>
{!isLoaded ? (
<CircularProgress />
): (
<div className="flex flex-col items-center
justify-center">
<div
onClick={handlePlay}
className="
flex
items-center
justify-center
h-10
w-10
rounded-full
bg-white
p-1
cursor-pointer
"
>
<Icon size={30} className="text-black" />
</div>
<Slider
value={progress}
onChange={(value) => handleProgressChange(value)}
styles={{
width: '250px',
height: 'auto',
padding: '10px 0px 5px 0px',
cursor: 'pointer'
}}
/>
</div>
)}
<AiFillStepForward
onClick={onPlayNext}
size={30}
className="
text-neutral-400
cursor-pointer
hover:text-white
transition
"
/>
</div>
<div className="hidden md:flex w-full justify-end pr-2">
<div className="flex items-center gap-x-2 w-[120px]">
<VolumeIcon
onClick={toggleMute}
className="cursor-pointer"
size={34}
/>
<Slider
value={volume}
onChange={(value) => setVolume(value)}
/>
</div>
</div>
</div>
);
}
export default PlayerContent;

View File

@ -0,0 +1,178 @@
import { useContext, useEffect, useState } from "react";
import { BsPauseFill, BsPlayFill } from "react-icons/bs";
import { HiSpeakerWave, HiSpeakerXMark } from "react-icons/hi2";
import { AiFillStepBackward, AiFillStepForward } from "react-icons/ai";
import CircularProgress from '@mui/material/CircularProgress'
// import LikeButton from "./LikeButton";
import MediaItem from "./MediaItem";
import Slider from "./Slider";
import { Song } from "../types";
import usePlayer from "../hooks/usePlayer";
import LikeButton from "./LikeButton";
import { setVolumePlayer } from "../state/features/globalSlice";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../state/store";
import { AddToPlaylistButton } from "./AddToPlayistButton";
import { FaUndoAlt } from "react-icons/fa";
import { MyContext } from "../wrappers/DownloadWrapper";
interface PlayerContentProps {
song: Song;
songUrl: string;
percentLoaded: string
}
export const PlayerContentShow: React.FC<PlayerContentProps> = ({
song,
songUrl,
percentLoaded
}) => {
const dispatch = useDispatch()
const player = usePlayer();
const volume = useSelector(
(state: RootState) => state.global.volume
)
const currentSong = useSelector(
(state: RootState) => state.global.currentSong
)
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
const [isPlaying, setIsPlaying] = useState(false);
const { downloadVideo } = useContext(MyContext)
const Icon = isPlaying ? BsPauseFill : BsPlayFill;
const VolumeIcon = volume === 0 ? HiSpeakerXMark : HiSpeakerWave;
const onPlayNext = () => {
}
const onPlayPrevious = () => {
}
const setVolume = (val: number)=> {
dispatch(setVolumePlayer(val))
}
const refresh = () => {
try {
if(!currentSong) return
const findSongInDownloads = downloads[currentSong]
if(findSongInDownloads){
downloadVideo(findSongInDownloads)
}
} catch (error) {
}
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 h-full">
<div className="flex w-full justify-start">
<div className="flex items-center gap-x-4">
<MediaItem data={song} />
<AddToPlaylistButton song={song} />
<LikeButton songId={song.id} name={song.name} service={song.service} songData={song} />
<FaUndoAlt size={25} className=" ml-2 cursor-pointer" onClick={()=> {
refresh()
}} />
</div>
</div>
<div
className="
flex
md:hidden
col-auto
w-full
justify-end
items-center
"
>
<div
onClick={()=> {}}
className="
h-10
w-10
flex
items-center
justify-center
rounded-full
bg-white
p-1
cursor-pointer
"
>
<Icon size={30} className="text-black" />
</div>
</div>
<div
className="
hidden
h-full
md:flex
justify-center
items-center
w-full
max-w-[722px]
gap-x-6
"
>
{/* <AiFillStepBackward
onClick={onPlayPrevious}
size={30}
className="
text-neutral-400
cursor-pointer
hover:text-white
transition
"
/> */}
<CircularProgress />
{percentLoaded}
{/* <AiFillStepForward
onClick={onPlayNext}
size={30}
className="
text-neutral-400
cursor-pointer
hover:text-white
transition
"
/> */}
</div>
<div className="hidden md:flex w-full justify-end pr-2">
<div className="flex items-center gap-x-2 w-[120px]">
<VolumeIcon
onClick={()=> {}}
className="cursor-pointer"
size={34}
/>
<Slider
value={volume}
onChange={(value) => setVolume(value)}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import { useDispatch, useSelector } from "react-redux";
import useLoadImage from "../hooks/useLoadImage";
import usePlayer from "../hooks/usePlayer";
import { Song } from "../types";
import { RootState } from "../state/store";
import radioImg from '../assets/img/radio-cassette.webp'
import { useContext } from "react";
import { MyContext } from "../wrappers/DownloadWrapper";
import { PlayList, setAddToDownloads, setCurrentSong } from "../state/features/globalSlice";
interface MediaItemProps {
data: PlayList;
onClick?: () => void;
}
export const PlaylistItem: React.FC<MediaItemProps> = ({
data,
onClick,
}) => {
const player = usePlayer();
const imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
const { downloadVideo } = useContext(MyContext)
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
const dispatch = useDispatch()
return (
<div
onClick={onClick}
className="
flex
items-center
gap-x-3
cursor-pointer
hover:bg-neutral-800/50
w-full
p-2
rounded-md
"
>
<div
className="
relative
rounded-md
min-h-[48px]
min-w-[48px]
overflow-hidden
"
>
<img
src={data?.image || radioImg}
alt="MediaItem"
className="object-cover absolute"
/>
</div>
<div className="flex flex-col gap-y-1 overflow-hidden">
<p className="text-white truncate">{data?.title}</p>
<p className="text-neutral-400 text-sm truncate">
{data?.description}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
import { useSelector } from "react-redux";
import useOnPlay from "../hooks/useOnPlay";
import { PlayList } from "../state/features/globalSlice";
import { Song } from "../types";
import LikeButton from "./LikeButton";
import MediaItem from "./MediaItem";
import { RootState } from "../state/store";
import { PlaylistItem } from "./PlaylistItem";
import { useNavigate } from "react-router-dom";
import { Skeleton } from "@mui/material";
interface SearchContentProps {
playlists: PlayList[];
}
export const PlayListsContent: React.FC<SearchContentProps> = ({
playlists
}) => {
const onPlay = useOnPlay([]);
const playlistHash = useSelector((state: RootState) => state.global.playlistHash);
const navigate = useNavigate()
// if (songs.length === 0) {
// return (
// <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">
{playlists.map((playlist: PlayList) => {
const existingPlaylist = playlistHash[playlist.id]
let playlistObj = playlist
if (existingPlaylist) {
playlistObj = existingPlaylist
} else return <Skeleton
variant="rectangular"
style={{
width: '100%',
height: '64px'
}}
/>
return (
<div
key={playlistObj.id}
className="flex items-center gap-x-4 w-full"
>
<div className="flex-1">
<PlaylistItem
onClick={() => {
navigate(`/playlists/${playlistObj.user}/${playlistObj.id}`)
}}
data={playlistObj}
/>
</div>
{/* <LikeButton songId={song.id} name={song.name} service={song.service} songData={song} /> */}
</div>
)
})}
</div>
);
}

View File

@ -0,0 +1,56 @@
import useOnPlay from "../hooks/useOnPlay";
import { Song } from "../types";
import { AddToPlaylistButton } from "./AddToPlayistButton";
import LikeButton from "./LikeButton";
import MediaItem from "./MediaItem";
interface SearchContentProps {
songs: Song[];
}
const SearchContent: React.FC<SearchContentProps> = ({
songs
}) => {
const onPlay = useOnPlay(songs);
if (songs.length === 0) {
return (
<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 className="flex-1">
<MediaItem
onClick={(id: string) => onPlay(id)}
data={song}
/>
</div>
<AddToPlaylistButton song={song} />
<LikeButton songId={song.id} name={song.name} service={song.service} songData={song} />
</div>
))}
</div>
);
}
export default SearchContent;

View File

@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import Input from "./Input";
import { resetQueriedList, setQueriedValue } from "../state/features/globalSlice";
import { useDispatch } from "react-redux";
import { useFetchSongs } from "../hooks/fetchSongs";
const SearchInput = () => {
const dispatch = useDispatch()
const {getQueriedSongs} = useFetchSongs()
const handleInputKeyDown = (event: any) => {
if (event.key === 'Enter') {
dispatch(resetQueriedList())
getQueriedSongs()
}
}
return (
<Input
placeholder="What do you want to listen to? by title"
onChange={(e) => dispatch(setQueriedValue(e.target.value))}
onKeyDown={handleInputKeyDown}
/>
);
}
export default SearchInput;

View File

@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import Input from "./Input";
import { resetQueriedList, resetQueriedListPlaylist, setIsQueryingPlaylist, setQueriedValue, 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 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()
}
}
}
return (
<div className="flex items-center">
<Input
placeholder="What do you want to listen to? by title"
onChange={(e) => {
dispatch(setQueriedValuePlaylist(e.target.value))
}}
value={queriedValuePlaylist}
onKeyDown={handleInputKeyDown}
/>
{isQueryingPlaylist && (
<FaUndoAlt className=" ml-2 cursor-pointer" onClick={()=> {
dispatch(resetQueriedListPlaylist())
dispatch(setIsQueryingPlaylist(false))
dispatch(setQueriedValuePlaylist(''))
}} />
)}
</div>
);
}

108
src/components/Sidebar.tsx Normal file
View File

@ -0,0 +1,108 @@
import { HiHome } from "react-icons/hi";
import { BiSearch } from "react-icons/bi";
import { twMerge } from "tailwind-merge";
import { useLocation } from 'react-router-dom';
import { TbPlaylist } from "react-icons/tb";
import {IoMdCloudUpload} from "react-icons/io"
import {AiFillHeart} from "react-icons/ai"
import SidebarItem from "./SidebarItem";
import Box from "./Box";
import {AddLibrary} from "./AddLibrary";
import { useMemo } from "react";
import { Song } from "../types";
import usePlayer from "../hooks/usePlayer";
import {AiOutlineFieldTime} from "react-icons/ai"
interface SidebarProps {
children: React.ReactNode;
songs: Song[];
}
const Sidebar = ({ children, songs }: SidebarProps) => {
const location = useLocation();
const pathname = useMemo(()=> {
return location.pathname
}, [location])
const player = usePlayer();
const routes = useMemo(() => [
{
icon: HiHome,
label: 'Home',
active: pathname === '/',
href: '/'
},
{
icon: AiOutlineFieldTime,
label: 'Newest songs',
active: pathname === '/newest',
href: '/newest'
},
{
icon: AiFillHeart,
label: 'Liked',
active: pathname === '/liked',
href: '/liked'
},
{
icon: BiSearch,
label: 'Search',
href: '/search',
active: pathname === '/search'
},
{
icon: TbPlaylist,
label: 'Playlists',
href: '/playlists',
active: pathname === '/playlists'
},
{
icon: IoMdCloudUpload,
label: 'Your Library',
href: '/library',
active: pathname === '/library'
},
], [pathname]);
return (
<div
className={twMerge(`
flex
h-full
`,
player.activeId && 'h-[calc(100%-80px)]'
)}
>
<div
className="
hidden
md:flex
flex-col
gap-y-2
bg-black
h-full
w-[300px]
p-2
"
>
<Box>
<div className="flex flex-col gap-y-4 px-5 py-4">
{routes.map((item) => (
<SidebarItem key={item.label} {...item} />
))}
</div>
</Box>
<Box className="overflow-y-auto h-full">
<AddLibrary songs={songs} />
</Box>
</div>
<main className="h-full flex-1 overflow-y-auto py-2">
{children}
</main>
</div>
);
}
export default Sidebar;

View File

@ -0,0 +1,46 @@
import { NavLink } from "react-router-dom";
import { IconType } from 'react-icons';
import { twMerge } from 'tailwind-merge';
interface SidebarItemProps {
icon: IconType;
label: string;
active?: boolean;
href: string;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
icon: Icon,
label,
active,
href
}) => {
return (
<NavLink
to={href}
className={twMerge(`
flex
flex-row
h-auto
items-center
w-full
gap-x-4
text-md
font-medium
cursor-pointer
hover:text-white
transition
text-neutral-400
py-1`,
active && "text-white"
)
}
>
<Icon size={26} />
<p className="truncate w-100">{label}</p>
</NavLink>
);
}
export default SidebarItem;

62
src/components/Slider.tsx Normal file
View File

@ -0,0 +1,62 @@
import * as RadixSlider from '@radix-ui/react-slider';
interface SlideProps {
value?: number;
onChange?: (value: number) => void;
styles?: object
}
const Slider: React.FC<SlideProps> = ({
value = 1,
onChange,
styles = {}
}) => {
const handleChange = (newValue: number[]) => {
onChange?.(newValue[0]);
};
return (
<RadixSlider.Root
className="
relative
flex
items-center
select-none
touch-none
w-full
h-10
"
defaultValue={[1]}
value={[value]}
onValueChange={handleChange}
max={1}
step={0.1}
aria-label="Volume"
style={{
...styles
}}
>
<RadixSlider.Track
className="
bg-neutral-600
relative
grow
rounded-full
h-[3px]
"
>
<RadixSlider.Range
className="
absolute
bg-white
rounded-full
h-full
"
/>
</RadixSlider.Track>
</RadixSlider.Root>
);
}
export default Slider;

170
src/components/SongItem.tsx Normal file
View File

@ -0,0 +1,170 @@
import PlayButton from "./PlayButton";
import { Song } from "../types";
import useLoadImage from "../hooks/useLoadImage";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../state/store";
import radioImg from '../assets/img/radio-cassette.webp'
import { useContext } from "react";
import { MyContext } from "../wrappers/DownloadWrapper";
import { setAddToDownloads, setCurrentSong, setNewPlayList } from "../state/features/globalSlice";
import {MdPlaylistAdd} from 'react-icons/md'
interface SongItemProps {
data: Song;
onClick: (id: string) => void;
}
const SongItem: React.FC<SongItemProps> = ({
data,
onClick
}) => {
const imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
const { downloadVideo } = useContext(MyContext)
const newPlaylist = useSelector((state: RootState) => state?.global.newPlayList);
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
const dispatch = useDispatch()
const addSongToPlaylist = (song: Song)=> {
if(!newPlaylist) return
if(newPlaylist && newPlaylist?.songs?.find((item)=> song.id === item.identifier)){
return
}
const playlist = {
...newPlaylist,
songs: [...newPlaylist.songs, {
identifier: song.id,
name: song.name,
service: 'AUDIO',
title: song.title,
author: song.author
}]
}
dispatch(setNewPlayList(playlist))
}
return (
<div
onClick={() => {
if(data?.status?.status === 'READY' || downloads[data.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: data.name,
service: 'AUDIO',
id: data.id,
identifier: data.id,
url:`/arbitrary/AUDIO/${data.name}/${data.id}`,
status: data?.status,
title: data?.title || "",
author: data?.author || "",
}))
}else {
downloadVideo({
name: data.name,
service: 'AUDIO',
identifier: data.id,
title: data?.title || "",
author: data?.author || "",
id: data.id
})
}
dispatch(setCurrentSong(data.id))
}}
className="
relative
group
flex
flex-col
items-center
justify-center
rounded-md
overflow-hidden
gap-x-4
bg-neutral-400/5
hover:bg-neutral-400/10
transition
p-3
"
>
<div
className="
relative
aspect-square
w-full
h-full
rounded-md
overflow-hidden
"
>
<img
className="object-cover absolute"
src={imageCoverHash[data.id] || radioImg}
alt="Image"
/>
{newPlaylist && (
<button
className="
absolute top-3 left-3
transition
opacity-0
rounded-full
flex
items-center
justify-center
bg-green-500
p-3
drop-shadow-md
translate
translate-y-1/4
group-hover:opacity-100
group-hover:translate-y-0
hover:scale-110
"
>
<MdPlaylistAdd
className="text-black h-6 w-6"
onClick={(event: any) => {
event.stopPropagation();
addSongToPlaylist(data)
// Handle the 'add to playlist' logic here
}}
/>
</button>
)}
</div>
<div className="flex flex-col items-start w-full pt-4 gap-y-1">
<p className="font-semibold truncate w-full">
{data?.title}
</p>
<p
className="
text-neutral-400
text-sm
pb-4
w-full
truncate
"
>
By {data?.author}
</p>
</div>
<div
className="
absolute
bottom-24
right-5
"
>
<PlayButton />
</div>
</div>
);
}
export default SongItem;

View File

@ -0,0 +1,46 @@
import { forwardRef } from "react";
import { twMerge } from "tailwind-merge"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
className,
disabled,
...props
}, ref) => {
return (
<textarea
className={twMerge(
`
flex
w-full
rounded-md
bg-neutral-700
border
border-transparent
px-3
py-3
text-sm
file:border-0
file:bg-transparent
file:text-sm
file:font-medium
placeholder:text-neutral-400
disabled:cursor-not-allowed
disabled:opacity-50
focus:outline-none
`,
disabled && 'opacity-75',
className
)}
disabled={disabled}
ref={ref}
{...props}
/>
)
});
Textarea.displayName = "Textarea";
export default Textarea;

View File

@ -0,0 +1,272 @@
import ShortUniqueId from 'short-unique-id'
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 { removeTrailingUnderscore } from '../utils/extra';
const uid = new ShortUniqueId()
const UploadModal = () => {
const username = useSelector((state: RootState) => state?.auth?.user?.name)
const [songImg, setSongImg] = useState("")
const dispatch = useDispatch()
const [isLoading, setIsLoading] = useState(false);
const uploadModal = useUploadModal();
const {
register,
handleSubmit,
reset,
} = useForm<FieldValues>({
defaultValues: {
author: '',
title: '',
song: null,
image: null,
}
});
const onChange = (open: boolean) => {
if (!open) {
reset();
uploadModal.onClose();
}
}
const compressImg = async (img: File)=> {
try {
const image = img
let compressedFile: File | undefined
await new Promise<void>((resolve) => {
new Compressor(image, {
quality: 0.6,
maxWidth: 300,
mimeType: 'image/webp',
success(result) {
const file = new File([result], 'name', {
type: 'image/webp'
})
compressedFile = file
resolve()
},
error(err) {}
})
})
if (!compressedFile) return
const dataURI = await toBase64(compressedFile)
if(!dataURI || typeof dataURI !== 'string') throw new Error('invalid image')
const base64Data = dataURI?.split(',')[1];
return base64Data
} catch (error) {
console.error(error)
}
}
const onSubmit: SubmitHandler<FieldValues> = async (values) => {
try {
if(!username){
toast.error('Please authenticate')
return;
}
if(!values.image?.[0]){
toast.error('Please attach an image cover')
return;
}
setIsLoading(true);
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')
return;
}
const songError = null
const imageError = null
try {
const compressedImg = await compressImg(imageFile)
if(!compressedImg){
toast.error('Image compression Error')
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}`
const description = `title=${title};author=${author}`
const fileExtension = imageFile?.name?.split('.')?.pop()
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20)
const filename = `${fileTitle}.${fileExtension}`
const resources = [
{
name: username,
service: 'AUDIO',
file: songFile,
title: title,
description: description,
identifier: identifier,
filename
},
{
name: username,
service: 'THUMBNAIL',
data64: compressedImg,
identifier: identifier
}
]
const multiplePublish = {
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
resources: resources
}
await qortalRequest(multiplePublish)
const songData = {
title: title,
description: description,
created: Date.now(),
updated: Date.now(),
name: username,
id: identifier,
author: author
}
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!');
reset();
uploadModal.onClose();
} catch (error) {
toast.error('Something went wrong');
} finally {
setIsLoading(false);
}
}
return (
<Modal
title="Add a song"
description="Upload an audio file- all fields are required"
isOpen={uploadModal.isOpen}
onChange={onChange}
>
<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"
/>
<Input
id="author"
disabled={isLoading}
{...register('author', { required: true })}
placeholder="Song author"
/>
<div>
<div className="pb-1">
Select a song file
</div>
<Input
placeholder="test"
disabled={isLoading}
type="file"
accept="audio/*"
id="song"
{...register('song', { required: true })}
/>
</div>
<div>
<div className="pb-1">
Select an image
</div>
<Input
placeholder="test"
disabled={isLoading}
type="file"
accept="image/*"
id="image"
{...register('image', { required: true })}
/>
</div>
<Button disabled={isLoading} type="submit">
Create
</Button>
</form>
</Modal>
);
}
export default UploadModal;

View File

@ -0,0 +1,368 @@
import ShortUniqueId from 'short-unique-id'
import React, { useEffect, useRef, 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/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 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 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,
handleSubmit,
reset,
watch,
setValue
} = useForm<FieldValues>({
defaultValues: {
description: newPlaylist?.description,
title: newPlaylist?.title || '',
image: null,
}
});
useEffect(()=> {
if(currentPlaylist?.current?.id === newPlaylist?.id) return
if(newPlaylist) reset({
description: newPlaylist?.description,
title: newPlaylist?.title || '',
image: null,
})
if(newPlaylist && newPlaylist?.image) setPrevSavedImg(newPlaylist.image)
if(newPlaylist?.id){
currentPlaylist.current = newPlaylist
}
}, [reset, newPlaylist])
const onChange = async (open: boolean) => {
if (!open) {
if(newPlaylist){
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.log({title})
dispatch(setNewPlayList({
...newPlaylist,
title,
description,
image: playlistImage
}))
}
// reset();
uploadModal.onClose();
}
}
const compressImg = async (img: File)=> {
try {
const image = img
let compressedFile: File | undefined
await new Promise<void>((resolve) => {
new Compressor(image, {
quality: 0.6,
maxWidth: 300,
mimeType: 'image/webp',
success(result) {
const file = new File([result], 'name', {
type: 'image/webp'
})
compressedFile = file
resolve()
},
error(err) {}
})
})
if (!compressedFile) return
const dataURI = await toBase64(compressedFile)
if(!dataURI || typeof dataURI !== 'string') throw new Error('invalid image')
const base64Data = dataURI?.split(',')[1];
return base64Data
} catch (error) {
console.error(error)
}
}
const onSubmit: SubmitHandler<FieldValues> = async (values) => {
try {
if(!username){
toast.error('Please authenticate')
return;
}
if(!values.image?.[0] && !prevSavedImg){
toast.error('Please attach an image cover')
return;
}
if(!newPlaylist) return
setIsLoading(true);
const imageFile = values.image?.[0];
const title = values.title
const description = values.description
if ((!imageFile && !prevSavedImg) || !username || !title || !description) {
toast.error('Missing fields')
return;
}
const songError = null
const imageError = null
try {
const compressedImg = prevSavedImg ? prevSavedImg : await compressImg(imageFile)
if(!compressedImg){
toast.error('Image compression Error')
return;
}
const id = uid(8)
const titleToUnderscore = title?.replace(/ /g, '_')
const titleToLowercase = titleToUnderscore.toLowerCase()
const titleSlice = titleToLowercase.slice(0,25)
const cleanTitle = removeTrailingUnderscore(titleSlice)
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 = {
songs: newPlaylist.songs,
title,
description,
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg
}
console.log({playlistObject})
const playlistToBase64 = await objectToBase64(playlistObject);
const resources = [
{
name: newPlaylist?.user ? newPlaylist?.user : username,
service: 'PLAYLIST',
data64: playlistToBase64,
title: title.slice(0, 55),
description: descriptionSnipped,
identifier: identifier,
filename
}
// {
// name: username,
// service: 'THUMBNAIL',
// data64: compressedImg,
// identifier: identifier
// }
]
const multiplePublish = {
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
resources: resources
}
await qortalRequest(multiplePublish)
toast.success('Song created!');
if(newPlaylist?.id){
//update playlist in store
dispatch(addToPlaylistHashMap(
{
user: newPlaylist?.user ? newPlaylist?.user : username,
service: 'PLAYLIST',
id: identifier,
filename,
songs: newPlaylist.songs,
title,
description,
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg}
))
} else {
//add playlist to store
dispatch(upsertPlaylists(
{
user: newPlaylist?.user ? newPlaylist?.user : username,
service: 'PLAYLIST',
id: identifier,
filename,
songs: newPlaylist.songs,
title,
description,
image: (newPlaylist?.id && prevSavedImg) ? prevSavedImg : 'data:image/webp;base64,' + compressedImg}
))
}
reset();
dispatch(setNewPlayList(null))
uploadModal.onClose();
} 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);
} catch (error) {
toast.error('Something went wrong');
} finally {
setIsLoading(false);
}
}
const removeSongFromPlaylist = (song: SongReference)=> {
if(!newPlaylist) return
const playlist = {
...newPlaylist,
songs: [...newPlaylist.songs].filter((item)=> item.identifier !== song.identifier)
}
dispatch(setNewPlayList(playlist))
}
return (
<Modal
title="Save Playlist"
description="Upload playlist- all fields are required"
isOpen={uploadModal.isOpen}
onChange={onChange}
>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-y-4"
>
<Input
id="title"
disabled={isLoading}
{...register('title', { required: true , maxLength: 35})}
placeholder="Playlist title"
/>
<Textarea
id="description"
disabled={isLoading}
{...register('description', { required: true })}
placeholder="Describe your playlist"
/>
<div>
<div className="pb-1">
Select an image for the playlist
</div>
{prevSavedImg ? <div className='flex items-center gap-1'>
<img src={prevSavedImg}/>
<AiOutlineClose className='cursor-pointer' onClick={()=> {
setPrevSavedImg(null)
}} />
</div> : (
<Input
placeholder="test"
disabled={isLoading}
type="file"
accept="image/*"
id="image"
{...register('image', { required: false })}
/>
)}
</div>
<div>
<div className="pb-1">
Songs
</div>
{newPlaylist?.songs?.map((song: SongReference)=> {
return (
<div className='flex gap-2 items-center'>
<p key={song?.title}>{song?.title}</p>
<AiOutlineClose className='cursor-pointer' onClick={()=> {
removeSongFromPlaylist(song)
}} />
</div>
)
})}
</div>
<Button disabled={isLoading} type="submit">
Publish
</Button>
</form>
</Modal>
);
}
export default UploadPlaylistModal;

View File

@ -0,0 +1,28 @@
import { styled } from '@mui/system';
import {
Box,
Modal,
Typography
} from '@mui/material';
export const StyledModal = styled(Modal)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}))
export const ModalContent = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.primary.main,
padding: theme.spacing(4),
borderRadius: theme.spacing(1),
width: '40%',
'&:focus': {
outline: 'none'
}
}))
export const ModalText = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "25px",
color: theme.palette.text.primary,
}));

View File

@ -0,0 +1,100 @@
import React, { useState } from "react";
import {
Box,
Button,
Modal,
Typography,
SelectChangeEvent,
ListItem,
List,
useTheme
} from "@mui/material";
import {
StyledModal,
ModalContent,
ModalText
} from "./BlockedNamesModal-styles";
interface PostModalProps {
open: boolean;
onClose: () => void;
}
export const BlockedNamesModal: React.FC<PostModalProps> = ({
open,
onClose
}) => {
const [blockedNames, setBlockedNames] = useState<string[]>([]);
const theme = useTheme();
const getBlockedNames = React.useCallback(async () => {
try {
const listName = `blockedNames`;
const response = await qortalRequest({
action: "GET_LIST_ITEMS",
list_name: listName
});
setBlockedNames(response);
} catch (error) {
onClose();
}
}, []);
React.useEffect(() => {
getBlockedNames();
}, [getBlockedNames]);
const removeFromBlockList = async (name: string) => {
try {
const response = await qortalRequest({
action: "DELETE_LIST_ITEM",
list_name: "blockedNames",
item: name
});
if (response === true) {
setBlockedNames((prev) => prev.filter((n) => n !== name));
}
} catch (error) {}
};
return (
<StyledModal open={open} onClose={onClose}>
<ModalContent>
<ModalText>Manage blocked names</ModalText>
<List
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
flex: "1",
overflow: "auto"
}}
>
{blockedNames.map((name, index) => (
<ListItem
key={name + index}
sx={{
display: "flex"
}}
>
<Typography>{name}</Typography>
<Button
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
fontFamily: "Raleway"
}}
onClick={() => removeFromBlockList(name)}
>
Remove
</Button>
</ListItem>
))}
</List>
<Button variant="contained" color="primary" onClick={onClose}>
Close
</Button>
</ModalContent>
</StyledModal>
);
};

View File

@ -0,0 +1,89 @@
import React, { useCallback } from 'react'
import { Box, Button, TextField, Typography, Modal } from '@mui/material'
import {
useDropzone,
DropzoneRootProps,
DropzoneInputProps
} from 'react-dropzone'
import Compressor from 'compressorjs'
const toBase64 = (file: File): Promise<string | ArrayBuffer | null> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => {
reject(error)
}
})
interface ImageUploaderProps {
children: React.ReactNode
onPick: (base64Img: string) => void
}
const ImageUploader: React.FC<ImageUploaderProps> = ({ children, onPick }) => {
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (acceptedFiles.length > 1) {
return
}
let compressedFile: File | undefined
try {
const image = acceptedFiles[0]
await new Promise<void>((resolve) => {
new Compressor(image, {
quality: 0.6,
maxWidth: 1200,
mimeType: 'image/webp',
success(result) {
const file = new File([result], 'name', {
type: 'image/webp'
})
compressedFile = file
resolve()
},
error(err) {}
})
})
if (!compressedFile) return
const base64Img = await toBase64(compressedFile)
onPick(base64Img as string)
} catch (error) {
console.error(error)
}
},
[onPick]
)
const {
getRootProps,
getInputProps,
isDragActive
}: {
getRootProps: () => DropzoneRootProps
getInputProps: () => DropzoneInputProps
isDragActive: boolean
} = useDropzone({
onDrop,
accept: {
'image/*': []
}
})
return (
<Box
{...getRootProps()}
sx={{
display: 'flex'
}}
>
<input {...getInputProps()} />
{children}
</Box>
)
}
export default ImageUploader

View File

@ -0,0 +1,48 @@
import React, { useState, useEffect, useRef } from 'react'
import { useInView } from 'react-intersection-observer'
import CircularProgress from '@mui/material/CircularProgress'
interface Props {
onLoadMore: () => Promise<void>
}
const LazyLoad: React.FC<Props> = ({ onLoadMore }) => {
const [isFetching, setIsFetching] = useState<boolean>(false)
const firstLoad = useRef(false)
const [ref, inView] = useInView({
threshold: 0.7
})
useEffect(() => {
if (inView) {
setIsFetching(true)
onLoadMore().finally(() => {
setIsFetching(false)
firstLoad.current = true
})
}
}, [inView])
return (
<div
ref={ref}
style={{
display: 'flex',
justifyContent: 'center',
minHeight: '25px',
overflow: 'hidden'
}}
>
<div
style={{
visibility: isFetching ? 'visible' : 'hidden'
}}
>
<CircularProgress />
</div>
</div>
)
}
export default LazyLoad

View File

@ -0,0 +1,86 @@
import { useDispatch, useSelector } from 'react-redux'
import { toast, ToastContainer, Zoom, Slide } from 'react-toastify'
import { removeNotification } from '../../../state/features/notificationsSlice'
import 'react-toastify/dist/ReactToastify.css'
import { RootState } from '../../../state/store'
const Notification = () => {
const dispatch = useDispatch()
const { alertTypes } = useSelector((state: RootState) => state.notifications)
if (alertTypes.alertError) {
toast.error(`${alertTypes?.alertError}`, {
position: 'bottom-right',
autoClose: 4000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
icon: false
})
dispatch(removeNotification())
}
if (alertTypes.alertSuccess) {
toast.success(`✔️ ${alertTypes?.alertSuccess}`, {
position: 'bottom-right',
autoClose: 4000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
icon: false
})
dispatch(removeNotification())
}
if (alertTypes.alertInfo) {
toast.info(`${alertTypes?.alertInfo}`, {
position: 'top-right',
autoClose: 1300,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: 'light'
})
dispatch(removeNotification())
}
if (alertTypes.alertInfo) {
return (
<ToastContainer
position="top-right"
autoClose={2000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
toastStyle={{ fontSize: '16px' }}
transition={Slide}
/>
)
}
return (
<ToastContainer
transition={Zoom}
position="bottom-right"
autoClose={false}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
draggable
pauseOnHover
/>
)
}
export default Notification

View File

@ -0,0 +1,43 @@
import React from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/system/Box';
import { useTheme } from '@mui/material'
interface PageLoaderProps {
size?: number
thickness?: number
}
const PageLoader: React.FC<PageLoaderProps> = ({
size = 40,
thickness = 5
}) => {
const theme = useTheme()
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
width: '100%',
position: 'fixed',
top: 0,
left: 0,
backgroundColor: 'rgba(255, 255, 255, 0.25)',
zIndex: 1000
}}
>
<CircularProgress
size={size}
thickness={thickness}
sx={{
color: theme.palette.secondary.main
}}
/>
</Box>
)
}
export default PageLoader;

View File

@ -0,0 +1,25 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
interface PortalProps {
children: React.ReactNode
}
const Portal: React.FC<PortalProps> = ({ children }) => {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
return () => setMounted(false)
}, [])
return mounted
? createPortal(
children,
document.querySelector('#modal-root') as HTMLElement
)
: null
}
export default Portal

View File

@ -0,0 +1,17 @@
import React from 'react'
import Sidebar from '../Sidebar'
import Player from '../Player'
interface SidebarProps {
children: React.ReactNode
}
export const Layout: React.FC<SidebarProps> = ({children}) => {
return (
<>
<Sidebar songs={[]}>{children}</Sidebar>
<Player />
</>
)
}

View File

@ -0,0 +1,120 @@
import { AppBar, Button, Typography, Box } from "@mui/material";
import { styled } from "@mui/system";
import { LightModeSVG } from "../../../assets/svgs/LightModeSVG";
import { DarkModeSVG } from "../../../assets/svgs/DarkModeSVG";
export const CustomAppBar = styled(AppBar)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
padding: "5px 16px",
backgroundImage: "none",
borderBottom: `1px solid ${theme.palette.primary.light}`,
backgroundColor: theme.palette.background.default,
[theme.breakpoints.only("xs")]: {
gap: "15px"
}
}));
export const LogoContainer = styled("div")({
cursor: 'pointer'
});
export const CustomTitle = styled(Typography)({
fontWeight: 600,
color: "#000000"
});
export const AuthenticateButton = styled(Button)(({ theme }) => ({
display: "flex",
flexDirection: "row",
alignItems: "center",
padding: "8px 15px",
borderRadius: "40px",
gap: "4px",
backgroundColor: theme.palette.secondary.main,
color: "#fff",
fontFamily: "Raleway",
transition: "all 0.3s ease-in-out",
boxShadow: "none",
"&:hover": {
cursor: "pointer",
boxShadow: "rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px;",
backgroundColor: theme.palette.secondary.dark,
filter: "brightness(1.1)"
}
}));
export const AvatarContainer = styled(Box)({
display: "flex",
alignItems: "center",
"&:hover": {
cursor: "pointer",
"& #expand-icon": {
transition: "all 0.3s ease-in-out",
filter: "brightness(0.7)"
}
}
});
export const DropdownContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "5px",
backgroundColor: theme.palette.background.paper,
padding: "10px 15px",
transition: "all 0.4s ease-in-out",
"&:hover": {
cursor: "pointer",
filter:
theme.palette.mode === "light" ? "brightness(0.95)" : "brightness(1.1)"
}
}));
export const DropdownText = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "16px",
color: theme.palette.text.primary,
userSelect: "none"
}));
export const NavbarName = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "18px",
color: theme.palette.text.primary,
margin: "0 10px"
}));
export const ThemeSelectRow = styled(Box)({
display: "flex",
alignItems: "center",
gap: "5px",
flexBasis: 0
});
export const LightModeIcon = styled(LightModeSVG)(({ theme }) => ({
transition: "all 0.1s ease-in-out",
"&:hover": {
cursor: "pointer",
filter:
theme.palette.mode === "dark"
? "drop-shadow(0px 4px 6px rgba(255, 255, 255, 0.6))"
: "drop-shadow(0px 4px 6px rgba(99, 88, 88, 0.1))"
}
}));
export const DarkModeIcon = styled(DarkModeSVG)(({ theme }) => ({
transition: "all 0.1s ease-in-out",
"&:hover": {
cursor: "pointer",
filter:
theme.palette.mode === "dark"
? "drop-shadow(0px 4px 6px rgba(255, 255, 255, 0.6))"
: "drop-shadow(0px 4px 6px rgba(99, 88, 88, 0.1))"
}
}));

View File

@ -0,0 +1,162 @@
import React, { useState } from "react";
import { Box, Popover, useTheme } from "@mui/material";
import ExitToAppIcon from "@mui/icons-material/ExitToApp";
import { BlockedNamesModal } from "../../common/BlockedNamesModal/BlockedNamesModal";
import {
AvatarContainer,
CustomAppBar,
DropdownContainer,
DropdownText,
AuthenticateButton,
NavbarName,
LightModeIcon,
DarkModeIcon,
ThemeSelectRow,
LogoContainer,
} from "./Navbar-styles";
import { AccountCircleSVG } from "../../../assets/svgs/AccountCircleSVG";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import PersonOffIcon from "@mui/icons-material/PersonOff";
import { useNavigate } from "react-router-dom";
interface Props {
isAuthenticated: boolean;
userName: string | null;
userAvatar: string;
authenticate: () => void;
setTheme: (val: string) => void;
}
const NavBar: React.FC<Props> = ({
isAuthenticated,
userName,
userAvatar,
authenticate,
setTheme
}) => {
const theme = useTheme();
const navigate = useNavigate()
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [isOpenBlockedNamesModal, setIsOpenBlockedNamesModal] =
useState<boolean>(false);
const [openUserDropdown, setOpenUserDropdown] = useState<boolean>(false);
const handleClick = (event?: React.MouseEvent<HTMLDivElement>) => {
const target = event?.currentTarget as unknown as HTMLButtonElement | null;
setAnchorEl(target);
};
const handleCloseUserDropdown = () => {
setAnchorEl(null);
setOpenUserDropdown(false);
};
const onCloseBlockedNames = () => {
setIsOpenBlockedNamesModal(false);
};
return (
<CustomAppBar position="sticky" elevation={2}>
<ThemeSelectRow>
{theme.palette.mode === "dark" ? (
<LightModeIcon
onClickFunc={() => setTheme("light")}
color="white"
height="22"
width="22"
/>
) : (
<DarkModeIcon
onClickFunc={() => setTheme("dark")}
color="black"
height="22"
width="22"
/>
)}
</ThemeSelectRow>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px"
}}
>
{!isAuthenticated && (
<AuthenticateButton onClick={authenticate}>
<ExitToAppIcon />
Authenticate
</AuthenticateButton>
)}
{isAuthenticated && userName && (
<>
<AvatarContainer
onClick={(e: any) => {
handleClick(e);
setOpenUserDropdown(true);
}}
>
<NavbarName>{userName}</NavbarName>
{!userAvatar ? (
<AccountCircleSVG
color={theme.palette.text.primary}
width="32"
height="32"
/>
) : (
<img
src={userAvatar}
alt="User Avatar"
width="32"
height="32"
style={{
borderRadius: "50%"
}}
/>
)}
<ExpandMoreIcon id="expand-icon" sx={{ color: "#ACB6BF" }} />
</AvatarContainer>
</>
)}
<Popover
id={"user-popover"}
open={openUserDropdown}
anchorEl={anchorEl}
onClose={handleCloseUserDropdown}
anchorOrigin={{
vertical: "bottom",
horizontal: "left"
}}
>
<DropdownContainer
onClick={() => {
setIsOpenBlockedNamesModal(true);
handleCloseUserDropdown();
}}
>
<PersonOffIcon
sx={{
color: "#e35050"
}}
/>
<DropdownText>Blocked Names</DropdownText>
</DropdownContainer>
</Popover>
{isOpenBlockedNamesModal && (
<BlockedNamesModal
open={isOpenBlockedNamesModal}
onClose={onCloseBlockedNames}
/>
)}
</Box>
</CustomAppBar>
);
};
export default NavBar;

59
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,59 @@
// src/global.d.ts
interface QortalRequestOptions {
action: string
name?: string
service?: string
data64?: string
title?: string
description?: string
category?: string
tags?: string[]
identifier?: string
address?: string
metaData?: string
encoding?: string
includeMetadata?: boolean
limit?: numebr
offset?: number
reverse?: boolean
resources?: any[]
filename?: string
list_name?: string
item?: string
items?: strings[]
tag1?: string
tag2?: string
tag3?: string
tag4?: string
tag5?: string
coin?: string
destinationAddress?: string
amount?: number
blob?: Blob
mimeType?: string
file?: File
encryptedData?: string
}
declare function qortalRequest(options: QortalRequestOptions): Promise<any>
declare function qortalRequestWithTimeout(
options: QortalRequestOptions,
time: number
): Promise<any>
declare global {
interface Window {
_qdnBase: any // Replace 'any' with the appropriate type if you know it
_qdnTheme: string
}
}
declare global {
interface Window {
showSaveFilePicker: (
options?: SaveFilePickerOptions
) => Promise<FileSystemFileHandle>
}
}
declare module 'use-sound';

587
src/hooks/fetchSongs.ts Normal file
View File

@ -0,0 +1,587 @@
import React, { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../state/store";
import { FavSong, PlayList, SongMeta, addToPlaylistHashMap, setImageCoverHash, setIsLoadingGlobal, setRandomPlaylist, upsertFavorite, upsertMyLibrary, upsertMyPlaylists, upsertPlaylists, upsertQueried, upsertQueriedPlaylist, upsertRecent } from "../state/features/globalSlice";
import { queueFetchAvatars } from "../wrappers/GlobalWrapper";
export const useFetchSongs = () => {
const dispatch = useDispatch();
const [userAvatar, setUserAvatar] = useState<string>("");
const username = useSelector((state: RootState) => state.auth?.user?.name);
const songListLibrary = useSelector((state: RootState) => state.global.songListLibrary);
const songHash = useSelector((state: RootState) => state.global.songHash);
const imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
const songListRecent = useSelector((state: RootState) => state.global.songListRecent);
const songListQueried = useSelector((state: RootState) => state.global.songListQueried);
const playlistQueried = useSelector((state: RootState) => state.global.playlistQueried);
const queriedValuePlaylist = useSelector((state: RootState) => state.global.queriedValuePlaylist);
const queriedValue = useSelector((state: RootState) => state.global.queriedValue);
const songsInStore = useSelector((state: RootState) => state.global?.favorites?.songs);
const favoriteList = useSelector((state: RootState) => state.global.favoriteList);
const playlistHash = useSelector((state: RootState) => state.global.playlistHash);
const playlists = useSelector((state: RootState) => state.global.playlists);
const myPlaylists = useSelector((state: RootState) => state.global.myPlaylists);
const songList = useMemo(() => {
if (!songsInStore) return []
return Object.keys(songsInStore).map((key) => {
return songsInStore[key]
})
}, [songsInStore])
const getImgCover = async (id: string, name: string, retries: number = 0) => {
try {
let url = await qortalRequest({
action: "GET_QDN_RESOURCE_URL",
name: name,
service: "THUMBNAIL",
identifier: id
});
if (url === "Resource does not exist") return;
dispatch(setImageCoverHash({ url, id }));
} catch (error) {
}
}
const getYourLibrary = useCallback(async (name: string) => {
try {
const offset = songListLibrary.length
const url = `/arbitrary/resources/search?mode=ALL&service=AUDIO&query=earbump_song_&name=${name}&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true&exactmatchnames=true&includestatus=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
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 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 {
title: song?.metadata?.title,
description: song?.metadata?.description,
created: song.created,
updated: song.updated,
name: song.name,
id: song.identifier,
status: song?.status,
...obj
}
})
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)
}
}
}
} catch (error) {
} finally {
}
}, [songListLibrary, imageCoverHash]);
const getQueriedSongs = useCallback(async () => {
try {
if (!queriedValue) return
dispatch(setIsLoadingGlobal(true))
const offset = songListQueried.length
const query = `earbump_song_`
const replaceSpacesWithUnderscore = queriedValue.toLowerCase().replace(/ /g, '_');
const identifier = replaceSpacesWithUnderscore
const url = `/arbitrary/resources/search?mode=ALL&service=AUDIO&query=${query}&identifier=${identifier}&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true&includestatus=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
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 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 {
title: song?.metadata?.title,
description: song?.metadata?.description,
created: song.created,
updated: song.updated,
name: song.name,
id: song.identifier,
status: song?.status,
...obj
}
})
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)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [songListQueried, imageCoverHash, queriedValue]);
const getLikedSongs = useCallback(async () => {
const offset = favoriteList.length
const songs = songList.slice(offset, offset + 20)
let songsToSet = []
for (const song of songs) {
try {
const url = `/arbitrary/resources/search?mode=ALL&service=${song.service}&identifier=${song.identifier}&limit=1&includemetadata=true&offset=${0}&name=${song.name}&includestatus=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
if (responseData.length === 0) continue
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 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;
}
const object = {
title: data?.metadata?.title,
description: data?.metadata?.description,
created: data.created,
updated: data.updated,
name: data.name,
id: data.identifier,
status: data?.status,
...obj
}
songsToSet.push(object)
if (!imageCoverHash[object.id]) {
queueFetchAvatars.push(() => getImgCover(object.id, object.name))
// getImgCover(object.id, object.name)
}
} catch (error) {
} finally {
}
}
dispatch(upsertFavorite(songsToSet))
}, [imageCoverHash, songList, favoriteList]);
const getRecentSongs = useCallback(async (offsetParam?: number, limitParam?: number) => {
try {
const offset = offsetParam ?? songListRecent.length
const limit = limitParam ?? 20
const url = `/arbitrary/resources/search?mode=ALL&service=AUDIO&query=earbump_song_&limit=${limit}&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true&includestatus=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
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 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 {
title: song?.metadata?.title,
description: song?.metadata?.description,
created: song.created,
updated: song.updated,
name: song.name,
id: song.identifier,
status: song?.status,
...obj
}
})
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)
}
}
}
} catch (error) {
} finally {
}
}, [songListRecent, imageCoverHash]);
const checkStructure = (content: any) => {
let isValid = true
return isValid
}
const fetchAndEvaluatePlaylists = async (data: any) => {
const getPlaylist = async () => {
const { user, playlistId, content } = data
let obj: any = {
...content,
isValid: false
}
if (!user || !playlistId) return obj
try {
const responseData = await qortalRequest({
action: 'FETCH_QDN_RESOURCE',
name: user,
service: 'PLAYLIST',
identifier: playlistId
})
if (checkStructure(responseData)) {
obj = {
...content,
...responseData,
isValid: true
}
}
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
} else if (
playlist?.updated &&
existingPlaylist?.updated &&
(playlist.updated > existingPlaylist.updated)
) {
return true
} else {
return false
}
},
[playlistHash]
)
const getPlaylists = useCallback(async () => {
try {
const offset = playlists.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`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((playlist: any): PlayList => {
return {
title: playlist?.metadata?.title,
category: playlist?.metadata?.category,
categoryName: playlist?.metadata?.categoryName,
tags: playlist?.metadata?.tags || [],
description: playlist?.metadata?.description,
created: playlist?.created,
updated: playlist?.updated,
user: playlist.name,
image: '',
songs: [],
id: playlist.identifier
}
})
dispatch(upsertPlaylists(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePlaylist(content)
if (res) {
getPlaylist(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
}
}, [playlists, imageCoverHash]);
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);
const randomItem = responseData[randomIndex];
const structurePlaylist = {
title: randomItem?.metadata?.title,
category: randomItem?.metadata?.category,
categoryName: randomItem?.metadata?.categoryName,
tags: randomItem?.metadata?.tags || [],
description: randomItem?.metadata?.description,
created: randomItem?.created,
updated: randomItem?.updated,
user: randomItem.name,
image: '',
songs: [],
id: randomItem.identifier
}
dispatch(setRandomPlaylist(structurePlaylist))
const res = checkAndUpdatePlaylist(structurePlaylist)
if (res) {
getPlaylist(structurePlaylist.user, structurePlaylist.id, structurePlaylist)
}
} 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 response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((playlist: any): PlayList => {
return {
title: playlist?.metadata?.title,
category: playlist?.metadata?.category,
categoryName: playlist?.metadata?.categoryName,
tags: playlist?.metadata?.tags || [],
description: playlist?.metadata?.description,
created: playlist?.created,
updated: playlist?.updated,
user: playlist.name,
image: '',
songs: [],
id: playlist.identifier
}
})
dispatch(upsertMyPlaylists(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePlaylist(content)
if (res) {
getPlaylist(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
}
}, [myPlaylists, imageCoverHash, username]);
const getPlaylistsQueried = useCallback(async () => {
try {
if (!queriedValuePlaylist) return
const offset = playlistQueried.length
const query = `earbump_playlist_`
const replaceSpacesWithUnderscore = queriedValuePlaylist.toLowerCase().replace(/ /g, '_');
const identifier = replaceSpacesWithUnderscore
const url = `/arbitrary/resources/search?mode=ALL&service=PLAYLIST&query=${query}&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true&excludeblocked=true&includestatus=false`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((playlist: any): PlayList => {
return {
title: playlist?.metadata?.title,
category: playlist?.metadata?.category,
categoryName: playlist?.metadata?.categoryName,
tags: playlist?.metadata?.tags || [],
description: playlist?.metadata?.description,
created: playlist?.created,
updated: playlist?.updated,
user: playlist.name,
image: '',
songs: [],
id: playlist.identifier
}
})
dispatch(upsertQueriedPlaylist(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePlaylist(content)
if (res) {
getPlaylist(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
}
}, [playlistQueried, imageCoverHash, queriedValuePlaylist]);
return {
getRecentSongs,
getYourLibrary,
getQueriedSongs,
getLikedSongs,
getPlaylists,
getPlaylistsQueried,
getMyPlaylists,
getRandomPlaylist
}
}

15
src/hooks/useAuthModal.ts Normal file
View File

@ -0,0 +1,15 @@
import { create } from 'zustand';
interface AuthModalStore {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}
const useAuthModal = create<AuthModalStore>((set) => ({
isOpen: false,
onOpen: () => set({ isOpen: true }),
onClose: () => set({ isOpen: false }),
}));
export default useAuthModal;

17
src/hooks/useDebounce.ts Normal file
View File

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react'
function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
export default useDebounce

View File

@ -0,0 +1,40 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { Song } from "../types";
const useSongById = (id?: string) => {
const [isLoading, setIsLoading] = useState(false);
const [song, setSong] = useState<Song | undefined>(undefined);
useEffect(() => {
if (!id) {
return;
}
setIsLoading(true);
const fetchSong = async () => {
const error = {
message: 'error'
}
if (error) {
setIsLoading(false);
return toast.error(error.message);
}
setSong(undefined);
setIsLoading(false);
}
fetchSong();
}, [id]);
return useMemo(() => ({
isLoading,
song
}), [isLoading, song]);
};
export default useSongById;

15
src/hooks/useLoadImage.ts Normal file
View File

@ -0,0 +1,15 @@
import { Song } from "../types";
const useLoadImage = (song: Song) => {
if (!song) {
return null;
}
return '';
};
export default useLoadImage;

View File

@ -0,0 +1,14 @@
import { Song } from "../types";
const useLoadSongUrl = (song: Song) => {
if (!song) {
return '';
}
return ""
};
export default useLoadSongUrl;

20
src/hooks/useOnPlay.ts Normal file
View File

@ -0,0 +1,20 @@
import usePlayer from "./usePlayer";
import useAuthModal from "./useAuthModal";
import { Song } from "../types";
const useOnPlay = (songs: Song[]) => {
const player = usePlayer();
const onPlay = (id: string) => {
player.setId(id);
player.setIds(songs.map((song) => song.id));
}
return onPlay;
};
export default useOnPlay;

19
src/hooks/usePlayer.ts Normal file
View File

@ -0,0 +1,19 @@
import { create } from 'zustand';
interface PlayerStore {
ids: string[];
activeId?: string;
setId: (id: string) => void;
setIds: (ids: string[]) => void;
reset: () => void;
}
const usePlayer = create<PlayerStore>((set) => ({
ids: [],
activeId: undefined,
setId: (id: string) => set({ activeId: id }),
setIds: (ids: string[]) => set({ ids }),
reset: () => set({ ids: [], activeId: undefined })
}));
export default usePlayer;

View File

@ -0,0 +1,15 @@
import { create } from 'zustand';
interface SubscribeModalStore {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}
const useSubscribeModal = create<SubscribeModalStore>((set) => ({
isOpen: false,
onOpen: () => set({ isOpen: true }),
onClose: () => set({ isOpen: false }),
}));
export default useSubscribeModal;

View File

@ -0,0 +1,15 @@
import { create } from 'zustand';
interface UploadModalStore {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}
const useUploadModal = create<UploadModalStore>((set) => ({
isOpen: false,
onOpen: () => set({ isOpen: true }),
onClose: () => set({ isOpen: false }),
}));
export default useUploadModal;

View File

@ -0,0 +1,15 @@
import { create } from 'zustand';
interface UploadModalStore {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}
const useUploadPlaylistModal = create<UploadModalStore>((set) => ({
isOpen: false,
onOpen: () => set({ isOpen: true }),
onClose: () => set({ isOpen: false }),
}));
export default useUploadPlaylistModal;

29
src/index.css Normal file
View File

@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'Figtree';
src: url("./styles/fonts/Figtree-Regular.ttf") format("truetype");
}
html,
body,
:root {
height: 100%;
background-color: black;
color-scheme: dark;
}
#root {
height: 100%;
}
* {
font-family: Figtree
}

17
src/main.tsx Normal file
View File

@ -0,0 +1,17 @@
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import { BrowserRouter } from 'react-router-dom'
interface CustomWindow extends Window {
_qdnBase: any
}
const customWindow = window as unknown as CustomWindow
const baseUrl = customWindow?._qdnBase || ''
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<BrowserRouter basename={baseUrl}>
<App />
<div id="modal-root" />
</BrowserRouter>
)

78
src/pages/Home/Home.tsx Normal file
View File

@ -0,0 +1,78 @@
import React, { useCallback, useEffect } from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { AddPlayList } from '../../components/AddPlaylist'
import { PlaylistStandalone } from '../Playlist/PlaylistStandalone'
import { PlayList } from '../../state/features/globalSlice'
import { useFetchSongs } from '../../hooks/fetchSongs'
export const Home = () => {
const randomPlaylist = useSelector((state: RootState) => state.global.randomPlaylist);
const {getRandomPlaylist} = useFetchSongs()
useEffect(()=> {
getRandomPlaylist()
}, [getRandomPlaylist])
return (
<div
className="
bg-neutral-900
rounded-lg
h-full
w-full
overflow-hidden
overflow-y-auto
"
>
<Header>
<div className="mb-2">
<div className='flex justify-between'>
<h1
className="
text-white
text-3xl
font-semibold
">
Welcome back
</h1>
</div>
<div
className="
grid
grid-cols-1
sm:grid-cols-2
xl:grid-cols-3
2xl:grid-cols-4
gap-3
mt-4
"
>
<ListItem
name="Liked Songs"
image={likeImg}
href="liked"
/>
</div>
</div>
</Header>
<div className="mt-2 mb-7 px-6">
<div className="flex justify-between items-center">
<h1 className="text-white text-2xl font-semibold">
Discover a Playlist (Shuffle)
</h1>
</div>
{randomPlaylist && (
<PlaylistStandalone playlistId={randomPlaylist?.id}
name={randomPlaylist?.user} />
)}
{/* <PageContent songs={songListRecent} /> */}
</div>
</div>
)
}

View File

@ -0,0 +1,127 @@
import React, { useMemo, useState , useRef, useEffect, useCallback} from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
import SearchInput from '../../components/SearchInput'
import { toast } from "react-hot-toast";
import {IoMdCloudUpload} from "react-icons/io"
import SearchContent from '../../components/SearchContent'
import LazyLoad from '../../components/common/LazyLoad'
import { useFetchSongs } from '../../hooks/fetchSongs'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import useUploadModal from '../../hooks/useUploadModal'
import useOnPlay from '../../hooks/useOnPlay'
import { MyPlaylists } from '../Playlists/MyPlaylists'
export const Library = () => {
const initialFetch = useRef(false)
const username = useSelector((state: RootState) => state?.auth?.user?.name);
const songListLibrary = useSelector((state: RootState) => state?.global.songListLibrary);
const [mode, setMode] = useState<string>('songs')
const {getYourLibrary} = useFetchSongs()
const uploadModal = useUploadModal();
const onClick = () => {
if (!username) {
toast.error('Please authenticate')
return
}
return uploadModal.onOpen();
}
const fetchMyLibrary = useCallback(async()=> {
try {
if(!username) return
await getYourLibrary(username)
initialFetch.current = true
} catch (error) {
}
}, [username, getYourLibrary])
useEffect(()=> {
if(username && !initialFetch.current){
fetchMyLibrary()
}
}, [username])
return (
<div
className="
bg-neutral-900
rounded-lg
h-full
w-full
overflow-hidden
overflow-y-auto
"
>
<Header>
<div className="mt-5 mb-5">
<button
className={ `${mode === 'songs' ? 'bg-neutral-100/10': ''} text-white px-4 py-2 rounded mr-5` }
onClick={() => { setMode('songs') }}
>
My Songs
</button>
<button
className={ `${mode === 'playlists' ? 'bg-neutral-100/10': ''} text-white px-4 py-2 rounded` }
onClick={() => { setMode('playlists') }}
>
My Playlists
</button>
</div>
{mode === 'playlists' && (
<MyPlaylists />
)}
{mode === 'songs' && (
<>
<div className="mt-5">
<div
className="
flex
flex-col
md:flex-row
items-center
gap-x-5
"
>
<div className="relative h-10 w-10">
<IoMdCloudUpload style={{
height: '35px',
width: 'auto'
}} />
</div>
<div className="flex flex-col gap-y-2 mt-4 md:mt-0">
<h1
className="
text-white
text-lg
font-bold
"
>
Your library
</h1>
</div>
</div>
</div>
<SearchContent songs={songListLibrary} />
<LazyLoad onLoadMore={fetchMyLibrary}></LazyLoad>
</>
)}
</Header>
</div>
)
}

171
src/pages/Liked/Liked.tsx Normal file
View File

@ -0,0 +1,171 @@
import React, { useContext, useMemo, useState } from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
import SearchInput from '../../components/SearchInput'
import SearchContent from '../../components/SearchContent'
import LazyLoad from '../../components/common/LazyLoad'
import { useFetchSongs } from '../../hooks/fetchSongs'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { FaPlay } from 'react-icons/fa'
import { setAddToDownloads, setCurrentPlaylist, setCurrentSong } from '../../state/features/globalSlice'
import { MyContext } from '../../wrappers/DownloadWrapper'
import { FavPlaylists } from '../Playlists/FavPlaylists'
export const Liked = () => {
const favoriteList = useSelector((state: RootState) => state.global.favoriteList);
const favorites = useSelector((state: RootState) => state.global.favorites);
const { downloadVideo } = useContext(MyContext)
const [mode, setMode] = useState<string>('songs')
const {getLikedSongs} = useFetchSongs()
const dispatch = useDispatch()
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
const onClickPlaylist = ()=> {
if(!favoriteList || favoriteList?.length === 0) return
const firstLikedSong = favoriteList[0]
dispatch(
setCurrentPlaylist('likedPlaylist')
)
if(firstLikedSong?.status?.status === 'READY' || downloads[firstLikedSong.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: firstLikedSong.name,
service: 'AUDIO',
id: firstLikedSong.id,
identifier: firstLikedSong.id,
url:`/arbitrary/AUDIO/${firstLikedSong.name}/${firstLikedSong.id}`,
status: firstLikedSong?.status,
title: firstLikedSong?.title || "",
author: firstLikedSong?.author || "",
}))
}else {
downloadVideo({
name: firstLikedSong.name,
service: 'AUDIO',
identifier: firstLikedSong.id,
title: firstLikedSong?.title || "",
author: firstLikedSong?.author || "",
id: firstLikedSong.id
})
}
dispatch(setCurrentSong(firstLikedSong.id))
}
if(!favorites) return null
return (
<div
className="
bg-neutral-900
rounded-lg
h-full
w-full
overflow-hidden
overflow-y-auto
"
>
<Header>
<div className="mt-5 mb-5">
<button
className={ `${mode === 'songs' ? 'bg-neutral-100/10': ''} text-white px-4 py-2 rounded mr-5` }
onClick={() => { setMode('songs') }}
>
Liked Songs
</button>
<button
className={ `${mode === 'playlists' ? 'bg-neutral-100/10': ''} text-white px-4 py-2 rounded` }
onClick={() => { setMode('playlists') }}
>
Liked Playlists
</button>
</div>
{mode === 'songs' && (
<>
<div className="mt-20">
<div
className="
flex
flex-col
md:flex-row
items-center
gap-x-5
relative
"
>
<div style={{
position: 'absolute',
top: '10px',
right: '10px'
}}
onClick={onClickPlaylist}
>
{favoriteList && favoriteList?.length > 0 && (
<div
className="
rounded-full
flex
items-center
justify-center
bg-green-500
p-4
drop-shadow-md
right-5
group-hover:opacity-100
hover:scale-110
cursor-pointer
"
>
<FaPlay className="text-black" />
</div>
)}
</div>
<div className="relative h-32 w-32 lg:h-44 lg:w-44">
<img
className="object-cover absolute"
src={likeImg}
alt="Playlist"
/>
</div>
<div className="flex flex-col gap-y-2 mt-4 md:mt-0">
<p className="hidden md:block font-semibold text-sm">
Playlist
</p>
<h1
className="
text-white
text-4xl
sm:text-5xl
lg:text-7xl
font-bold
"
>
Liked Songs
</h1>
</div>
</div>
</div>
<SearchContent songs={favoriteList} />
<LazyLoad onLoadMore={getLikedSongs}></LazyLoad>
</>
)}
{mode === 'playlists' && (
<FavPlaylists />
)}
</Header>
</div>
)
}

View File

@ -0,0 +1,35 @@
import React from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { AddPlayList } from '../../components/AddPlaylist'
export const Newest = () => {
const songListRecent = useSelector((state: RootState) => state.global.songListRecent);
return (
<div
className="
bg-neutral-900
rounded-lg
h-full
w-full
overflow-hidden
overflow-y-auto
"
>
<div className="mt-12 mb-7 px-6">
<div className="flex justify-between items-center">
<h1 className="text-white text-2xl font-semibold">
Newest songs
</h1>
</div>
<PageContent songs={songListRecent} />
</div>
</div>
)
}

View File

@ -0,0 +1,380 @@
import React, { useContext, useMemo, useRef, useState } from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
import SearchInput from '../../components/SearchInput'
import SearchContent from '../../components/SearchContent'
import LazyLoad from '../../components/common/LazyLoad'
import { useFetchSongs } from '../../hooks/fetchSongs'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { PlayListsContent } from '../../components/PlaylistsContent'
import { useParams } from 'react-router-dom'
import { PlayList, addToPlaylistHashMap, removeFavPlaylist, setAddToDownloads, setCurrentPlaylist, setCurrentSong, setFavPlaylist, setImageCoverHash, setIsLoadingGlobal, setNewPlayList } from '../../state/features/globalSlice'
import { AiFillEdit, AiFillHeart, AiOutlineHeart } from "react-icons/ai";
import { FaPlay } from 'react-icons/fa'
import { MyContext } from '../../wrappers/DownloadWrapper'
import { queueFetchAvatars } from '../../wrappers/GlobalWrapper'
import localforage from 'localforage'
const favoritesStorage = localforage.createInstance({
name: 'ear-bump-favorites'
})
export const Playlist = () => {
const username = useSelector((state: RootState) => state.auth?.user?.name);
const { playlistId, name } = useParams()
const isfavoriting = useRef(false)
const { getPlaylists
} = useFetchSongs()
const songListQueried = useSelector((state: RootState) => state.global.songListQueried);
const playlists = useSelector((state: RootState) => state.global.playlists);
const favoritesPlaylist= useSelector((state: RootState) => state.global.favoritesPlaylist);
const dispatch = useDispatch()
const playlistHash = useSelector((state: RootState) => state.global.playlistHash);
const { downloadVideo } = useContext(MyContext)
const imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
const [playListData, setPlaylistData] = useState<any>(null)
console.log({playlists, playlistId})
const getPlaylistData = React.useCallback(async (name: string, id: string) => {
try {
if (!name || !playlistId) return
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=PLAYLIST&query=earbump_playlist_&limit=1&includemetadata=true&reverse=true&excludeblocked=true&name=${name}&exactmatchnames=true&offset=0&identifier=${playlistId}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseDataSearch = await response.json()
// const responseDataSearch = await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// mode: "ALL",
// service: "DOCUMENT",
// query: "bteon_vid_",
// limit: 1,
// offset: 0,
// includeMetadata: true,
// reverse: true,
// excludeBlocked: true,
// exactMatchNames: true,
// name: name,
// identifier: id
// })
if (responseDataSearch?.length > 0) {
let resourceData = responseDataSearch[0]
resourceData = {
title: resourceData?.metadata?.title,
category: resourceData?.metadata?.category,
categoryName: resourceData?.metadata?.categoryName,
tags: resourceData?.metadata?.tags || [],
description: resourceData?.metadata?.description,
created: resourceData?.created,
updated: resourceData?.updated,
user: resourceData.name,
videoImage: '',
id: resourceData.identifier
}
const responseData = await qortalRequest({
action: 'FETCH_QDN_RESOURCE',
name: name,
service: 'PLAYLIST',
identifier: playlistId
})
if (responseData && !responseData.error) {
const combinedData = {
...resourceData,
...responseData
}
setPlaylistData(combinedData)
dispatch(addToPlaylistHashMap(combinedData))
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [])
React.useEffect(() => {
if (name && playlistId) {
const existingVideo = playlistHash[playlistId]
if (existingVideo) {
setPlaylistData(existingVideo)
} else {
getPlaylistData(name, playlistId)
}
}
}, [playlistId, name, playlistHash])
const getImgCover = async (id: string, name: string, retries: number = 0) => {
try {
let url = await qortalRequest({
action: "GET_QDN_RESOURCE_URL",
name: name,
service: "THUMBNAIL",
identifier: id
});
if (url === "Resource does not exist") return;
dispatch(setImageCoverHash({ url, id }));
} catch (error) {
}
}
const isLiked = useMemo(()=> {
let isLiked = false
if(!playlistId || !favoritesPlaylist) {
isLiked = false
return isLiked
}
if(favoritesPlaylist?.find(play=> play.id === playlistId)) return true
return isLiked
}, [playlistId , favoritesPlaylist])
const Icon = isLiked ? AiFillHeart : AiOutlineHeart;
console.log({playListData})
const songs = useMemo(()=> {
const transformSongs = (playListData?.songs || []).map((song: any)=> {
if (!imageCoverHash[song?.identifier]) {
queueFetchAvatars.push(() => getImgCover(song?.identifier, song?.name))
}
return {
...song,
id: song?.identifier || song?.id
}
})
return transformSongs
}, [playListData?.songs, imageCoverHash])
const onClickPlaylist = ()=> {
dispatch(setNewPlayList(playListData))
}
const onClickPlayPlaylist = ()=> {
if(!playListData?.songs && playListData?.songs?.length === 0) return
const firstLikedSong = {
...playListData?.songs[0],
id: playListData?.songs[0].identifier
}
dispatch(
setCurrentPlaylist(playListData.id)
)
if(firstLikedSong?.status?.status === 'READY' || downloads[firstLikedSong.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: firstLikedSong.name,
service: 'AUDIO',
id: firstLikedSong.id,
identifier: firstLikedSong.id,
url:`/arbitrary/AUDIO/${firstLikedSong.name}/${firstLikedSong.id}`,
status: firstLikedSong?.status,
title: firstLikedSong?.title || "",
author: firstLikedSong?.author || "",
}))
}else {
downloadVideo({
name: firstLikedSong.name,
service: 'AUDIO',
identifier: firstLikedSong.id,
title: firstLikedSong?.title || "",
author: firstLikedSong?.author || "",
id: firstLikedSong.id
})
}
dispatch(setCurrentSong(firstLikedSong.id))
}
const handleLike = async () => {
try {
if(isfavoriting.current) return
isfavoriting.current = true
const isLiked = !!favoritesPlaylist?.find(play=> play.id === playlistId)
if(isLiked){
dispatch(removeFavPlaylist(playListData))
let favoritesObj: PlayList[] | null = await favoritesStorage.getItem('favoritesPlaylist') || null
if(favoritesObj){
const newFavs = favoritesObj.filter((fav)=> fav?.id !== playlistId)
await favoritesStorage.setItem('favoritesPlaylist', newFavs)
}
}else {
dispatch(setFavPlaylist(playListData))
let favoritesObj: PlayList[] | null =
await favoritesStorage.getItem('favoritesPlaylist') || null
if(!favoritesObj){
const newObj: PlayList[] = [playListData]
await favoritesStorage.setItem('favoritesPlaylist', newObj)
} else {
const newObj: PlayList[] = [playListData, ...favoritesObj]
await favoritesStorage.setItem('favoritesPlaylist', favoritesObj)
}
}
isfavoriting.current = false
} catch (error) {
console.error(error)
}
}
return (
<div
className="
bg-neutral-900
rounded-lg
h-full
w-full
overflow-hidden
overflow-y-auto
"
>
<Header>
<div className="mt-20">
<div
className="
flex
flex-col
md:flex-row
items-center
gap-x-5
relative
"
>
<div style={{
position: 'absolute',
bottom: '10px',
right: '0px'
}}
>
{playListData?.songs && playListData?.songs?.length > 0 && (
<div className='flex items-center gap-2'>
<div
onClick={onClickPlayPlaylist}
className="
rounded-full
flex
items-center
justify-center
bg-green-500
p-4
drop-shadow-md
right-5
group-hover:opacity-100
hover:scale-110
cursor-pointer
"
>
<FaPlay className="text-black" />
</div>
<button
className="
cursor-pointer
hover:opacity-75
transition
"
onClick={handleLike}
>
<Icon color={isLiked ? '#22c55e' : 'white'} size={40} />
</button>
</div>
)}
</div>
{username === playListData?.user && (
<div style={{
position: 'absolute',
top: '10px',
right: '10px'
}}
onClick={onClickPlaylist}
>
<AiFillEdit className='cursor-pointer
hover:opacity-75
transition'
size={30}
/>
</div>
)}
<div className="relative h-32 w-32 lg:h-44 lg:w-44">
<img
className="object-cover absolute"
src={playListData?.image ? playListData?.image : likeImg}
alt="Playlist"
/>
</div>
<div className="flex flex-col gap-y-2 mt-4 md:mt-0">
<p className="hidden md:block font-semibold text-sm">
Playlist
</p>
<h1
className="
text-white
text-4xl
sm:text-5xl
lg:text-7xl
font-bold
"
>
{playListData?.title}
</h1>
<p className="hidden md:block font-semibold text-sm">
{playListData?.description}
</p>
</div>
</div>
</div>
</Header>
{playListData && (
<SearchContent songs={songs} />
)}
{/* <SearchContent songs={favoriteList} />
<LazyLoad onLoadMore={getPlaylistSongs}></LazyLoad> */}
</div>
)
}

View File

@ -0,0 +1,385 @@
import React, { useContext, useMemo, useRef, useState } from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
import SearchInput from '../../components/SearchInput'
import SearchContent from '../../components/SearchContent'
import LazyLoad from '../../components/common/LazyLoad'
import { useFetchSongs } from '../../hooks/fetchSongs'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { PlayListsContent } from '../../components/PlaylistsContent'
import { useParams } from 'react-router-dom'
import { PlayList, addToPlaylistHashMap, removeFavPlaylist, setAddToDownloads, setCurrentPlaylist, setCurrentSong, setFavPlaylist, setImageCoverHash, setIsLoadingGlobal, setNewPlayList } from '../../state/features/globalSlice'
import { AiFillEdit, AiFillHeart, AiOutlineHeart } from "react-icons/ai";
import { FaPlay } from 'react-icons/fa'
import { MyContext } from '../../wrappers/DownloadWrapper'
import { queueFetchAvatars } from '../../wrappers/GlobalWrapper'
import localforage from 'localforage'
const favoritesStorage = localforage.createInstance({
name: 'ear-bump-favorites'
})
export const PlaylistStandalone = ({
playlistId,
name
}: any) => {
const username = useSelector((state: RootState) => state.auth?.user?.name);
const isfavoriting = useRef(false)
const { getPlaylists
} = useFetchSongs()
const songListQueried = useSelector((state: RootState) => state.global.songListQueried);
const playlists = useSelector((state: RootState) => state.global.playlists);
const favoritesPlaylist= useSelector((state: RootState) => state.global.favoritesPlaylist);
const dispatch = useDispatch()
const playlistHash = useSelector((state: RootState) => state.global.playlistHash);
const { downloadVideo } = useContext(MyContext)
const imageCoverHash = useSelector((state: RootState) => state.global.imageCoverHash);
const downloads = useSelector(
(state: RootState) => state.global.downloads
)
const [playListData, setPlaylistData] = useState<any>(null)
console.log({playlists, playlistId})
const getPlaylistData = React.useCallback(async (name: string, id: string) => {
try {
if (!name || !playlistId) return
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=PLAYLIST&query=earbump_playlist_&limit=1&includemetadata=true&reverse=true&excludeblocked=true&name=${name}&exactmatchnames=true&offset=0&identifier=${playlistId}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseDataSearch = await response.json()
// const responseDataSearch = await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// mode: "ALL",
// service: "DOCUMENT",
// query: "bteon_vid_",
// limit: 1,
// offset: 0,
// includeMetadata: true,
// reverse: true,
// excludeBlocked: true,
// exactMatchNames: true,
// name: name,
// identifier: id
// })
if (responseDataSearch?.length > 0) {
let resourceData = responseDataSearch[0]
resourceData = {
title: resourceData?.metadata?.title,
category: resourceData?.metadata?.category,
categoryName: resourceData?.metadata?.categoryName,
tags: resourceData?.metadata?.tags || [],
description: resourceData?.metadata?.description,
created: resourceData?.created,
updated: resourceData?.updated,
user: resourceData.name,
videoImage: '',
id: resourceData.identifier
}
const responseData = await qortalRequest({
action: 'FETCH_QDN_RESOURCE',
name: name,
service: 'PLAYLIST',
identifier: playlistId
})
if (responseData && !responseData.error) {
const combinedData = {
...resourceData,
...responseData
}
setPlaylistData(combinedData)
dispatch(addToPlaylistHashMap(combinedData))
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [])
React.useEffect(() => {
if (name && playlistId) {
const existingVideo = playlistHash[playlistId]
if (existingVideo) {
setPlaylistData(existingVideo)
} else {
getPlaylistData(name, playlistId)
}
}
}, [playlistId, name, playlistHash])
const getImgCover = async (id: string, name: string, retries: number = 0) => {
try {
let url = await qortalRequest({
action: "GET_QDN_RESOURCE_URL",
name: name,
service: "THUMBNAIL",
identifier: id
});
if (url === "Resource does not exist") return;
dispatch(setImageCoverHash({ url, id }));
} catch (error) {
}
}
const isLiked = useMemo(()=> {
let isLiked = false
if(!playlistId || !favoritesPlaylist) {
isLiked = false
return isLiked
}
if(favoritesPlaylist?.find(play=> play.id === playlistId)) return true
return isLiked
}, [playlistId , favoritesPlaylist])
const Icon = isLiked ? AiFillHeart : AiOutlineHeart;
console.log({playListData})
const songs = useMemo(()=> {
const transformSongs = (playListData?.songs || []).map((song: any)=> {
if (!imageCoverHash[song?.identifier]) {
queueFetchAvatars.push(() => getImgCover(song?.identifier, song?.name))
}
return {
...song,
id: song?.identifier || song?.id
}
})
return transformSongs
}, [playListData?.songs, imageCoverHash])
const onClickPlaylist = ()=> {
dispatch(setNewPlayList(playListData))
}
const onClickPlayPlaylist = ()=> {
if(!playListData?.songs && playListData?.songs?.length === 0) return
const firstLikedSong = {
...playListData?.songs[0],
id: playListData?.songs[0].identifier
}
dispatch(
setCurrentPlaylist(playListData.id)
)
if(firstLikedSong?.status?.status === 'READY' || downloads[firstLikedSong.id]?.status?.status === 'READY'){
dispatch(setAddToDownloads({
name: firstLikedSong.name,
service: 'AUDIO',
id: firstLikedSong.id,
identifier: firstLikedSong.id,
url:`/arbitrary/AUDIO/${firstLikedSong.name}/${firstLikedSong.id}`,
status: firstLikedSong?.status,
title: firstLikedSong?.title || "",
author: firstLikedSong?.author || "",
}))
}else {
downloadVideo({
name: firstLikedSong.name,
service: 'AUDIO',
identifier: firstLikedSong.id,
title: firstLikedSong?.title || "",
author: firstLikedSong?.author || "",
id: firstLikedSong.id
})
}
dispatch(setCurrentSong(firstLikedSong.id))
}
const handleLike = async () => {
try {
if(isfavoriting.current) return
isfavoriting.current = true
const isLiked = !!favoritesPlaylist?.find(play=> play.id === playlistId)
if(isLiked){
dispatch(removeFavPlaylist(playListData))
let favoritesObj: PlayList[] | null = await favoritesStorage.getItem('favoritesPlaylist') || null
if(favoritesObj){
const newFavs = favoritesObj.filter((fav)=> fav?.id !== playlistId)
await favoritesStorage.setItem('favoritesPlaylist', newFavs)
}
}else {
dispatch(setFavPlaylist(playListData))
let favoritesObj: PlayList[] | null =
await favoritesStorage.getItem('favoritesPlaylist') || null
if(!favoritesObj){
const newObj: PlayList[] = [playListData]
await favoritesStorage.setItem('favoritesPlaylist', newObj)
} else {
const newObj: PlayList[] = [playListData, ...favoritesObj]
await favoritesStorage.setItem('favoritesPlaylist', favoritesObj)
}
}
isfavoriting.current = false
} catch (error) {
console.error(error)
}
}
return (
<div
className="
bg-neutral-900
rounded-lg
h-full
w-full
overflow-hidden
overflow-y-auto
"
style={{
marginBottom: '80px'
}}
>
<Header>
<div className="mt-20">
<div
className="
flex
flex-col
md:flex-row
items-center
gap-x-5
relative
"
>
<div style={{
position: 'absolute',
bottom: '10px',
right: '0px'
}}
>
{playListData?.songs && playListData?.songs?.length > 0 && (
<div className='flex items-center gap-2'>
<div
onClick={onClickPlayPlaylist}
className="
rounded-full
flex
items-center
justify-center
bg-green-500
p-4
drop-shadow-md
right-5
group-hover:opacity-100
hover:scale-110
cursor-pointer
"
>
<FaPlay className="text-black" />
</div>
<button
className="
cursor-pointer
hover:opacity-75
transition
"
onClick={handleLike}
>
<Icon color={isLiked ? '#22c55e' : 'white'} size={40} />
</button>
</div>
)}
</div>
{username === playListData?.user && (
<div style={{
position: 'absolute',
top: '10px',
right: '10px'
}}
onClick={onClickPlaylist}
>
<AiFillEdit className='cursor-pointer
hover:opacity-75
transition'
size={30}
/>
</div>
)}
<div className="relative h-32 w-32 lg:h-44 lg:w-44">
<img
className="object-cover absolute"
src={playListData?.image ? playListData?.image : likeImg}
alt="Playlist"
/>
</div>
<div className="flex flex-col gap-y-2 mt-4 md:mt-0">
<p className="hidden md:block font-semibold text-sm">
Playlist
</p>
<h1
className="
text-white
text-4xl
sm:text-5xl
lg:text-7xl
font-bold
"
>
{playListData?.title}
</h1>
<p className="hidden md:block font-semibold text-sm">
{playListData?.description}
</p>
</div>
</div>
</div>
</Header>
{playListData && (
<SearchContent songs={songs} />
)}
{/* <SearchContent songs={favoriteList} />
<LazyLoad onLoadMore={getPlaylistSongs}></LazyLoad> */}
</div>
)
}

View File

@ -0,0 +1,38 @@
import React from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
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 { RootState } from '../../state/store'
import { PlayListsContent } from '../../components/PlaylistsContent'
import { SearchInputPlaylist } from '../../components/SearchInputPlaylist'
export const FavPlaylists = () => {
const { getMyPlaylists
} = useFetchSongs()
const favoritesPlaylist = useSelector((state: RootState) => state.global.favoritesPlaylist);
let playlistsToRender = favoritesPlaylist || []
return (
<div>
<div className="mb-2 flex flex-col gap-y-6">
<h1 className="text-white text-3xl font-semibold">
Playlists
</h1>
</div>
<PlayListsContent playlists={playlistsToRender} />
</div>
)
}

View File

@ -0,0 +1,39 @@
import React from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
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 { RootState } from '../../state/store'
import { PlayListsContent } from '../../components/PlaylistsContent'
import { SearchInputPlaylist } from '../../components/SearchInputPlaylist'
export const MyPlaylists = () => {
const { getMyPlaylists
} = useFetchSongs()
const myPlaylists = useSelector((state: RootState) => state.global.myPlaylists);
console.log({myPlaylists})
let playlistsToRender = myPlaylists
return (
<div>
<div className="mb-2 flex flex-col gap-y-6">
<h1 className="text-white text-3xl font-semibold">
Playlists
</h1>
</div>
<PlayListsContent playlists={playlistsToRender} />
<LazyLoad onLoadMore={getMyPlaylists}></LazyLoad>
</div>
)
}

View File

@ -0,0 +1,56 @@
import React from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
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 { RootState } from '../../state/store'
import { PlayListsContent } from '../../components/PlaylistsContent'
import { SearchInputPlaylist } from '../../components/SearchInputPlaylist'
export const Playlists = () => {
const { getPlaylists
} = useFetchSongs()
const songListQueried = useSelector((state: RootState) => state.global.songListQueried);
const playlists = useSelector((state: RootState) => state.global.playlists);
const playlistQueried = useSelector((state: RootState) => state.global.playlistQueried);
const isQueryingPlaylist = useSelector((state: RootState) => state.global.isQueryingPlaylist);
console.log({playlists})
let playlistsToRender = playlists
if(isQueryingPlaylist){
playlistsToRender = playlistQueried
}
return (
<div
className="
bg-neutral-900
rounded-lg
h-full
w-full
overflow-hidden
overflow-y-auto
"
>
<Header className="from-bg-neutral-900">
<div className="mb-2 flex flex-col gap-y-6">
<h1 className="text-white text-3xl font-semibold">
Playlists
</h1>
<SearchInputPlaylist />
</div>
</Header>
<PlayListsContent playlists={playlistsToRender} />
{!isQueryingPlaylist && (
<LazyLoad onLoadMore={getPlaylists}></LazyLoad>
)}
</div>
)
}

View File

@ -0,0 +1,40 @@
import React from 'react'
import Header from '../../components/Header'
import ListItem from '../../components/ListItem'
import likeImg from '../../assets/img/liked.png'
import PageContent from '../../components/PageContent'
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 { RootState } from '../../state/store'
export const Search = () => {
const {getQueriedSongs} = useFetchSongs()
const songListQueried = useSelector((state: RootState) => state.global.songListQueried);
return (
<div
className="
bg-neutral-900
rounded-lg
h-full
w-full
overflow-hidden
overflow-y-auto
"
>
<Header className="from-bg-neutral-900">
<div className="mb-2 flex flex-col gap-y-6">
<h1 className="text-white text-3xl font-semibold">
Search
</h1>
<SearchInput />
</div>
</Header>
<SearchContent songs={songListQueried} />
<LazyLoad onLoadMore={getQueriedSongs}></LazyLoad>
</div>
)
}

View File

@ -0,0 +1,27 @@
import { createSlice } from '@reduxjs/toolkit';
interface AuthState {
user: {
address: string;
publicKey: string;
name?: string;
} | null;
}
const initialState: AuthState = {
user: null
};
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
addUser: (state, action) => {
state.user = action.payload;
},
},
});
export const { addUser } = authSlice.actions;
export default authSlice.reducer;

View File

@ -0,0 +1,348 @@
import { createSlice } from '@reduxjs/toolkit'
import { Song } from '../../types'
interface ResourceBase {
name?: string;
id: string;
category?: string;
categoryName?: string;
tags?: string[];
created: number;
updated: number;
user?: string;
}
interface GlobalState {
isLoadingGlobal: boolean
downloads: any
userAvatarHash: Record<string, string>
songHash: Record<string, string>
imageCoverHash: Record<string, string>
songListLibrary: Song[]
songListRecent: Song[]
songListQueried: Song[]
queriedValue: string;
queriedValuePlaylist: string;
currentSong: string | null
favorites: null | Favorites
favoritesPlaylist: null | PlayList[]
favoriteList: Song[]
nowPlayingPlaylist: Song[]
volume: number
newPlayList: PlayList | null
playlists: PlayList[]
myPlaylists: PlayList[]
playlistHash: Record<string, PlayList>
currentPlaylist: string
playlistQueried: PlayList[]
isQueryingPlaylist: boolean
randomPlaylist: null | PlayList
}
export interface PlayList extends ResourceBase {
title: string;
description: string;
songs: SongReference[];
image: string | null
}
export interface SongReference {
name: string;
service: string;
identifier: string;
title: string;
artist: string;
}
export interface Status {
status: string;
id: string;
title: string;
description: string;
}
export interface SongMeta {
title: string
description: string
created: number
updated: number
name: string
id: string
status?: Status
}
export interface FavSong {
name: string;
identifier: string;
service: string;
status?: string
}
export interface FavPlaylist {
name: string;
identifier: string;
service: string
}
export interface Favorites {
songs: Record<string, FavSong>
playlists: Record<string, FavPlaylist>
}
const initialState: GlobalState = {
isLoadingGlobal: false,
downloads: {},
userAvatarHash: {},
imageCoverHash: {},
songHash: {},
songListLibrary: [],
songListRecent: [],
songListQueried: [],
queriedValue: "",
queriedValuePlaylist: "",
currentSong: null,
favorites: null,
favoriteList: [],
nowPlayingPlaylist: [],
volume: 0.5,
newPlayList: null,
playlists: [],
myPlaylists: [],
playlistHash: {},
currentPlaylist: 'nowPlayingPlaylist',
playlistQueried: [],
isQueryingPlaylist: false,
favoritesPlaylist: null,
randomPlaylist: null
}
export const globalSlice = createSlice({
name: 'global',
initialState,
reducers: {
setIsLoadingGlobal: (state, action) => {
state.isLoadingGlobal = action.payload
},
setAddToDownloads: (state, action) => {
const download = action.payload
state.downloads[download.identifier] = download
},
updateDownloads: (state, action) => {
const { identifier } = action.payload
const download = action.payload
state.downloads[identifier] = {
...state.downloads[identifier],
...download
}
},
setUserAvatarHash: (state, action) => {
const avatar = action.payload
if (avatar?.name && avatar?.url) {
state.userAvatarHash[avatar?.name] = avatar?.url
}
},
setImageCoverHash: (state, action) => {
const imageCover = action.payload
if (imageCover?.id && imageCover?.url) {
state.imageCoverHash[imageCover.id] = imageCover?.url
}
},
upsertMyLibrary: (state, action) => {
action.payload.forEach((song: Song) => {
const index = state.songListLibrary.findIndex((p) => p.id === song.id)
if (index !== -1) {
state.songListLibrary[index] = song
} else {
state.songListLibrary.push(song)
}
})
},
upsertRecent: (state, action) => {
action.payload.forEach((song: Song) => {
const index = state.songListRecent.findIndex((p) => p.id === song.id)
if (index !== -1) {
state.songListRecent[index] = song
} else {
state.songListRecent.push(song)
}
})
},
upsertPlaylists: (state, action) => {
action.payload.forEach((playlist: PlayList) => {
const index = state.playlists.findIndex((p) => p.id === playlist.id)
if (index !== -1) {
state.playlists[index] = playlist
} else {
state.playlists.push(playlist)
}
})
},
upsertMyPlaylists: (state, action) => {
action.payload.forEach((playlist: PlayList) => {
const index = state.myPlaylists.findIndex((p) => p.id === playlist.id)
if (index !== -1) {
state.myPlaylists[index] = playlist
} else {
state.myPlaylists.push(playlist)
}
})
},
addNewSong: (state, action) => {
const song: Song = action.payload
state.songListRecent.unshift(song)
state.songListLibrary.unshift(song)
},
upsertQueried: (state, action) => {
action.payload.forEach((song: Song) => {
const index = state.songListQueried.findIndex((p) => p.id === song.id)
if (index !== -1) {
state.songListQueried[index] = song
} else {
state.songListQueried.push(song)
}
})
},
upsertQueriedPlaylist: (state, action) => {
action.payload.forEach((playlist: PlayList) => {
const index = state.playlistQueried.findIndex((p) => p.id === playlist.id)
if (index !== -1) {
state.playlistQueried[index] = playlist
} else {
state.playlistQueried.push(playlist)
}
})
},
upsertFavorite: (state, action) => {
action.payload.forEach((song: Song) => {
const index = state.favoriteList.findIndex((p) => p.id === song.id)
if (index !== -1) {
state.favoriteList[index] = song
} else {
state.favoriteList.push(song)
}
})
},
setCurrentSong: (state, action) => {
state.currentSong = action.payload
},
setQueriedValue: (state, action) => {
state.queriedValue = action.payload
},
resetQueriedList: (state) => {
state.songListQueried = []
},
resetQueriedListPlaylist: (state) => {
state.playlistQueried = []
},
setQueriedValuePlaylist: (state, action) => {
state.queriedValuePlaylist = action.payload
},
setFavSong: (state, action) => {
if (state.favorites) {
const song = action.payload
state.favorites.songs[song.identifier] = {
identifier: song.identifier,
name: song.name,
service: song.service
}
state.favoriteList.unshift(song.songData)
}
},
removeFavSong: (state, action) => {
if (state.favorites) {
const song = action.payload
if (state.favorites.songs[song.identifier]) {
delete state.favorites.songs[song.identifier]
}
state.favoriteList = state.favoriteList.filter((songItem) => songItem.id !== song.identifier)
}
},
setFavPlaylist: (state, action) => {
if (state.favoritesPlaylist) {
const playlist = action.payload
state.favoritesPlaylist.unshift(playlist)
}
},
removeFavPlaylist: (state, action) => {
if (state.favoritesPlaylist) {
const playlist = action.payload
state.favoritesPlaylist = state.favoritesPlaylist.filter((play) => play.id !== playlist.id)
}
},
setFavoritesFromStorage: (state, action) => {
state.favorites = action.payload
},
setFavoritesFromStoragePlaylists: (state, action) => {
state.favoritesPlaylist = action.payload
},
upsertNowPlayingPlaylist: (state, action) => {
action.payload.forEach((song: Song) => {
const index = state.nowPlayingPlaylist.findIndex((p) => p.id === song.id)
if (index !== -1) {
state.nowPlayingPlaylist[index] = song
} else {
state.nowPlayingPlaylist.push(song)
}
})
},
setVolumePlayer: (state, action) => {
state.volume = action.payload
},
setNewPlayList: (state, action) => {
state.newPlayList = action.payload
},
addToPlaylistHashMap: (state, action) => {
const playlist = action.payload
state.playlistHash[playlist.id] = playlist
},
setCurrentPlaylist: (state, action) => {
state.currentPlaylist = action.payload
},
setIsQueryingPlaylist: (state, action) => {
state.isQueryingPlaylist = action.payload
},
setRandomPlaylist: (state, action) => {
state.randomPlaylist = action.payload
},
}
})
export const {
setIsLoadingGlobal,
setAddToDownloads,
updateDownloads,
setUserAvatarHash,
setImageCoverHash,
upsertMyLibrary,
upsertRecent,
setCurrentSong,
upsertQueried,
setQueriedValue,
resetQueriedList,
addNewSong,
setFavSong,
removeFavSong,
upsertFavorite,
setFavoritesFromStorage,
upsertNowPlayingPlaylist,
setVolumePlayer,
setNewPlayList,
upsertPlaylists,
addToPlaylistHashMap,
setCurrentPlaylist,
upsertQueriedPlaylist,
setQueriedValuePlaylist,
resetQueriedListPlaylist,
setIsQueryingPlaylist,
upsertMyPlaylists,
setFavoritesFromStoragePlaylists,
setFavPlaylist,
removeFavPlaylist,
setRandomPlaylist
} = globalSlice.actions
export default globalSlice.reducer

View File

@ -0,0 +1,73 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface AlertTypes {
alertSuccess: string
alertError: string
alertInfo: string
}
interface InitialState {
alertTypes: AlertTypes
}
const initialState: InitialState = {
alertTypes: {
alertSuccess: '',
alertError: '',
alertInfo: ''
}
}
export const notificationsSlice = createSlice({
name: "notifications",
initialState,
reducers: {
setNotification: (
state: InitialState,
action: PayloadAction<{ alertType: string; msg: string }>
) => {
if (action.payload.alertType === "success") {
return {
...state,
alertTypes: {
...state.alertTypes,
alertSuccess: action.payload.msg,
},
};
} else if (action.payload.alertType === "error") {
return {
...state,
alertTypes: {
...state.alertTypes,
alertError: action.payload.msg,
},
};
} else if (action.payload.alertType === "info") {
return {
...state,
alertTypes: {
...state.alertTypes,
alertInfo: action.payload.msg,
},
};
}
return state;
},
removeNotification: (state: InitialState) => {
return {
...state,
alertTypes: {
...state.alertTypes,
alertSuccess: '',
alertError: '',
alertInfo: ''
}
}
},
},
});
export const { setNotification, removeNotification } =
notificationsSlice.actions;
export default notificationsSlice.reducer;

25
src/state/store.ts Normal file
View File

@ -0,0 +1,25 @@
import { configureStore } from '@reduxjs/toolkit'
import notificationsReducer from './features/notificationsSlice'
import authReducer from './features/authSlice'
import globalReducer from './features/globalSlice'
export const store = configureStore({
reducer: {
notifications: notificationsReducer,
auth: authReducer,
global: globalReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false
}),
preloadedState: undefined // optional, can be any valid state object
})
// Define the RootState type, which is the type of the entire Redux state tree.
// This is useful when you need to access the state in a component or elsewhere.
export type RootState = ReturnType<typeof store.getState>
// Define the AppDispatch type, which is the type of the Redux store's dispatch function.
// This is useful when you need to dispatch an action in a component or elsewhere.
export type AppDispatch = typeof store.dispatch

BIN
src/styles/fonts/Cairo.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/styles/fonts/Karla.ttf Normal file

Binary file not shown.

BIN
src/styles/fonts/Livvic.ttf Normal file

Binary file not shown.

Binary file not shown.

BIN
src/styles/fonts/Oxygen.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

185
src/styles/theme.tsx Normal file
View File

@ -0,0 +1,185 @@
import { createTheme } from "@mui/material/styles";
const commonThemeOptions = {
typography: {
fontFamily: [
"Figtree",
"Cambon Light",
"Raleway, sans-serif",
"Karla",
"Merriweather Sans",
"Proxima Nova",
"Oxygen",
"Catamaran",
"Cairo",
"Arial"
].join(","),
h1: {
fontSize: "2rem",
fontWeight: 600
},
h2: {
fontSize: "1.75rem",
fontWeight: 500
},
h3: {
fontSize: "1.5rem",
fontWeight: 500
},
h4: {
fontSize: "1.25rem",
fontWeight: 500
},
h5: {
fontSize: "1rem",
fontWeight: 500
},
h6: {
fontSize: "0.875rem",
fontWeight: 500
},
body1: {
fontSize: "23px",
fontFamily: "Figtree",
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: "0.5px"
},
body2: {
fontSize: "18px",
fontFamily: "Figtree, Arial",
fontWeight: 400,
lineHeight: 1.4,
letterSpacing: "0.2px"
}
},
spacing: 8,
shape: {
borderRadius: 4
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 900,
lg: 1200,
xl: 1536
}
},
components: {
MuiButton: {
styleOverrides: {
root: {
backgroundColor: "inherit",
transition: "filter 0.3s ease-in-out",
"&:hover": {
filter: "brightness(1.1)"
}
}
},
defaultProps: {
disableElevation: true,
disableRipple: true
}
}
}
};
const lightTheme = createTheme({
...commonThemeOptions,
palette: {
mode: "light",
primary: {
main: "#ffffff",
dark: "#F5F5F5",
light: "#FCFCFC"
},
secondary: {
main: "#417Ed4",
dark: "#3e74c1"
},
background: {
default: "#fcfcfc",
paper: "#F5F5F5"
},
text: {
primary: "#000000",
secondary: "#525252"
}
},
components: {
MuiCard: {
styleOverrides: {
root: {
boxShadow:
"rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;",
borderRadius: "8px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
boxShadow:
"rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;"
}
}
}
},
MuiIcon: {
defaultProps: {
style: {
color: "#000000"
}
}
}
}
});
const darkTheme = createTheme({
...commonThemeOptions,
palette: {
mode: "dark",
primary: {
main: "#2e3d60",
dark: "#1a2744",
light: "#353535"
},
secondary: {
main: "#417Ed4",
dark: "#3e74c1"
},
background: {
default: "#111111",
paper: "#1A1C1E"
},
text: {
primary: "#ffffff",
secondary: "#b3b3b3"
}
},
components: {
MuiCard: {
styleOverrides: {
root: {
boxShadow: "none",
borderRadius: "8px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
boxShadow:
" 0px 3px 4px 0px hsla(0,0%,0%,0.14), 0px 3px 3px -2px hsla(0,0%,0%,0.12), 0px 1px 8px 0px hsla(0,0%,0%,0.2);"
}
}
}
},
MuiIcon: {
defaultProps: {
style: {
color: "#ffffff"
}
}
}
}
});
export { lightTheme, darkTheme };

BIN
src/test/download.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

BIN
src/test/mockimg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

18
src/types.ts Normal file
View File

@ -0,0 +1,18 @@
import { Status } from "./state/features/globalSlice";
export interface Song {
id: string;
author: string;
title: string;
name: string;
service: string;
status?: Status
}
export interface Product {
id: string;
active?: boolean;
name?: string;
description?: string;
image?: string;
}

View File

@ -0,0 +1,9 @@
export const checkStructure = (content: any) => {
let isValid = true
if (!content?.title) isValid = false
return isValid
}

6
src/utils/extra.ts Normal file
View File

@ -0,0 +1,6 @@
export function removeTrailingUnderscore(str: string) {
if (str.endsWith("_")) {
return str.slice(0, -1); // removes the last character of the string
}
return str;
}

View File

@ -0,0 +1,14 @@
export function extractTextFromSlate(nodes: any) {
if(!Array.isArray(nodes)) return ""
let text = "";
for (const node of nodes) {
if (node.text) {
text += node.text;
} else if (node.children) {
text += extractTextFromSlate(node.children);
}
}
return text;
}

37
src/utils/fetchVideos.ts Normal file
View File

@ -0,0 +1,37 @@
import { checkStructure } from './checkStructure'
export const fetchAndEvaluateVideos = async (data: any) => {
const getVideo = async () => {
const { user, videoId, content } = data
let obj: any = {
...content,
isValid: false
}
if (!user || !videoId) return obj
try {
const url = `/arbitrary/JSON/${user}/${videoId}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
if (checkStructure(responseData)) {
obj = {
...content,
...responseData,
isValid: true
}
}
return obj
} catch (error) { }
}
const res = await getVideo()
return res
}

Some files were not shown because too many files have changed in this diff Show More