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:
commit
17493aa8d6
14
.eslintrc.cjs
Normal file
14
.eslintrc.cjs
Normal 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
25
.gitignore
vendored
Normal 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
13
index.html
Normal 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
8297
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
package.json
Normal file
54
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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
56
src/App.tsx
Normal 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
BIN
src/assets/img/liked.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 78 KiB |
BIN
src/assets/img/radio-cassette.webp
Normal file
BIN
src/assets/img/radio-cassette.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 247 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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 |
25
src/assets/svgs/AccountCircleSVG.tsx
Normal file
25
src/assets/svgs/AccountCircleSVG.tsx
Normal 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>
|
||||
)
|
||||
}
|
23
src/assets/svgs/DarkModeSVG.tsx
Normal file
23
src/assets/svgs/DarkModeSVG.tsx
Normal 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>
|
||||
)
|
||||
}
|
7
src/assets/svgs/IconTypes.ts
Normal file
7
src/assets/svgs/IconTypes.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface IconTypes {
|
||||
color: string;
|
||||
height: string;
|
||||
width: string;
|
||||
className?: string;
|
||||
onClickFunc?: (e?: any) => void;
|
||||
}
|
23
src/assets/svgs/LightModeSVG.tsx
Normal file
23
src/assets/svgs/LightModeSVG.tsx
Normal 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>
|
||||
)
|
||||
}
|
124
src/components/AddLibrary.tsx
Normal file
124
src/components/AddLibrary.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
62
src/components/AddPlaylist.tsx
Normal file
62
src/components/AddPlaylist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
62
src/components/AddToPlayistButton.tsx
Normal file
62
src/components/AddToPlayistButton.tsx
Normal 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
28
src/components/Box.tsx
Normal 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
47
src/components/Button.tsx
Normal 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
152
src/components/Header.tsx
Normal 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
48
src/components/Input.tsx
Normal 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
|
116
src/components/LikeButton.tsx
Normal file
116
src/components/LikeButton.tsx
Normal 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;
|
56
src/components/LikedContent.tsx
Normal file
56
src/components/LikedContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
77
src/components/ListItem.tsx
Normal file
77
src/components/ListItem.tsx
Normal 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;
|
104
src/components/MediaItem.tsx
Normal file
104
src/components/MediaItem.tsx
Normal 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
104
src/components/Modal.tsx
Normal 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;
|
99
src/components/PageContent.tsx
Normal file
99
src/components/PageContent.tsx
Normal 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;
|
28
src/components/PlayButton.tsx
Normal file
28
src/components/PlayButton.tsx
Normal 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
138
src/components/Player.tsx
Normal 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;
|
469
src/components/PlayerContent.tsx
Normal file
469
src/components/PlayerContent.tsx
Normal 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;
|
178
src/components/PlayerContentShow.tsx
Normal file
178
src/components/PlayerContentShow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
73
src/components/PlaylistItem.tsx
Normal file
73
src/components/PlaylistItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
76
src/components/PlaylistsContent.tsx
Normal file
76
src/components/PlaylistsContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
56
src/components/SearchContent.tsx
Normal file
56
src/components/SearchContent.tsx
Normal 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;
|
30
src/components/SearchInput.tsx
Normal file
30
src/components/SearchInput.tsx
Normal 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;
|
51
src/components/SearchInputPlaylist.tsx
Normal file
51
src/components/SearchInputPlaylist.tsx
Normal 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
108
src/components/Sidebar.tsx
Normal 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;
|
46
src/components/SidebarItem.tsx
Normal file
46
src/components/SidebarItem.tsx
Normal 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
62
src/components/Slider.tsx
Normal 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
170
src/components/SongItem.tsx
Normal 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;
|
46
src/components/TextArea.tsx
Normal file
46
src/components/TextArea.tsx
Normal 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;
|
272
src/components/UploadModal.tsx
Normal file
272
src/components/UploadModal.tsx
Normal 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;
|
368
src/components/UploadPlaylistModal.tsx
Normal file
368
src/components/UploadPlaylistModal.tsx
Normal 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;
|
@ -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,
|
||||
}));
|
100
src/components/common/BlockedNamesModal/BlockedNamesModal.tsx
Normal file
100
src/components/common/BlockedNamesModal/BlockedNamesModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
89
src/components/common/ImageUploader.tsx
Normal file
89
src/components/common/ImageUploader.tsx
Normal 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
|
48
src/components/common/LazyLoad.tsx
Normal file
48
src/components/common/LazyLoad.tsx
Normal 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
|
86
src/components/common/Notification/Notification.tsx
Normal file
86
src/components/common/Notification/Notification.tsx
Normal 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
|
43
src/components/common/PageLoader.tsx
Normal file
43
src/components/common/PageLoader.tsx
Normal 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;
|
25
src/components/common/Portal.tsx
Normal file
25
src/components/common/Portal.tsx
Normal 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
|
17
src/components/layout/Layout.tsx
Normal file
17
src/components/layout/Layout.tsx
Normal 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 />
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
120
src/components/layout/Navbar/Navbar-styles.tsx
Normal file
120
src/components/layout/Navbar/Navbar-styles.tsx
Normal 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))"
|
||||
}
|
||||
}));
|
162
src/components/layout/Navbar/Navbar.tsx
Normal file
162
src/components/layout/Navbar/Navbar.tsx
Normal 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
59
src/global.d.ts
vendored
Normal 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
587
src/hooks/fetchSongs.ts
Normal 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
15
src/hooks/useAuthModal.ts
Normal 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
17
src/hooks/useDebounce.ts
Normal 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
|
40
src/hooks/useGetSongById.ts
Normal file
40
src/hooks/useGetSongById.ts
Normal 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
15
src/hooks/useLoadImage.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Song } from "../types";
|
||||
|
||||
|
||||
const useLoadImage = (song: Song) => {
|
||||
|
||||
|
||||
if (!song) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export default useLoadImage;
|
14
src/hooks/useLoadSongUrl.ts
Normal file
14
src/hooks/useLoadSongUrl.ts
Normal 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
20
src/hooks/useOnPlay.ts
Normal 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
19
src/hooks/usePlayer.ts
Normal 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;
|
15
src/hooks/useSubscribeModal.ts
Normal file
15
src/hooks/useSubscribeModal.ts
Normal 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;
|
15
src/hooks/useUploadModal.ts
Normal file
15
src/hooks/useUploadModal.ts
Normal 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;
|
15
src/hooks/useUploadPlaylistModal.ts
Normal file
15
src/hooks/useUploadPlaylistModal.ts
Normal 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
29
src/index.css
Normal 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
17
src/main.tsx
Normal 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
78
src/pages/Home/Home.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
127
src/pages/Library/Library.tsx
Normal file
127
src/pages/Library/Library.tsx
Normal 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
171
src/pages/Liked/Liked.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
35
src/pages/Newest/Newest.tsx
Normal file
35
src/pages/Newest/Newest.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
380
src/pages/Playlist/Playlist.tsx
Normal file
380
src/pages/Playlist/Playlist.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
385
src/pages/Playlist/PlaylistStandalone.tsx
Normal file
385
src/pages/Playlist/PlaylistStandalone.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
38
src/pages/Playlists/FavPlaylists.tsx
Normal file
38
src/pages/Playlists/FavPlaylists.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
39
src/pages/Playlists/MyPlaylists.tsx
Normal file
39
src/pages/Playlists/MyPlaylists.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
56
src/pages/Playlists/Playlists.tsx
Normal file
56
src/pages/Playlists/Playlists.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
40
src/pages/Search/Search.tsx
Normal file
40
src/pages/Search/Search.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
27
src/state/features/authSlice.ts
Normal file
27
src/state/features/authSlice.ts
Normal 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;
|
348
src/state/features/globalSlice.ts
Normal file
348
src/state/features/globalSlice.ts
Normal 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
|
73
src/state/features/notificationsSlice.ts
Normal file
73
src/state/features/notificationsSlice.ts
Normal 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
25
src/state/store.ts
Normal 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
BIN
src/styles/fonts/Cairo.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Cambon-Light.ttf
Normal file
BIN
src/styles/fonts/Cambon-Light.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Catamaran.ttf
Normal file
BIN
src/styles/fonts/Catamaran.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Figtree-Regular.ttf
Normal file
BIN
src/styles/fonts/Figtree-Regular.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Karla.ttf
Normal file
BIN
src/styles/fonts/Karla.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Livvic.ttf
Normal file
BIN
src/styles/fonts/Livvic.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Merriweather Sans.ttf
Normal file
BIN
src/styles/fonts/Merriweather Sans.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Oxygen.ttf
Normal file
BIN
src/styles/fonts/Oxygen.ttf
Normal file
Binary file not shown.
BIN
src/styles/fonts/ProximaNova.otf
Normal file
BIN
src/styles/fonts/ProximaNova.otf
Normal file
Binary file not shown.
BIN
src/styles/fonts/Raleway.ttf
Normal file
BIN
src/styles/fonts/Raleway.ttf
Normal file
Binary file not shown.
185
src/styles/theme.tsx
Normal file
185
src/styles/theme.tsx
Normal 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
BIN
src/test/download.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 426 KiB |
BIN
src/test/mockimg.jpg
Normal file
BIN
src/test/mockimg.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
18
src/types.ts
Normal file
18
src/types.ts
Normal 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;
|
||||
}
|
9
src/utils/checkStructure.ts
Normal file
9
src/utils/checkStructure.ts
Normal 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
6
src/utils/extra.ts
Normal 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;
|
||||
}
|
14
src/utils/extractTextFromSlate.ts
Normal file
14
src/utils/extractTextFromSlate.ts
Normal 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
37
src/utils/fetchVideos.ts
Normal 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
Loading…
Reference in New Issue
Block a user