Browse Source

version 1

pull/1/head
PhilReact 10 months ago
commit
762fcf9a27
  1. 16
      .eslintrc.js
  2. 25
      .gitignore
  3. 13
      index.html
  4. 7132
      package-lock.json
  5. 47
      package.json
  6. BIN
      public/favicon.ico
  7. 43
      src/App.css
  8. 39
      src/App.tsx
  9. BIN
      src/assets/img/logo.png
  10. BIN
      src/assets/img/logo2.jpg
  11. BIN
      src/assets/img/logo3.jpg
  12. BIN
      src/assets/img/q-share-icon.webp
  13. 1
      src/assets/react.svg
  14. 25
      src/assets/svgs/AccountCircleSVG.tsx
  15. 23
      src/assets/svgs/CircleSVG.tsx
  16. 23
      src/assets/svgs/DarkModeSVG.tsx
  17. 13
      src/assets/svgs/DownloadedLight.tsx
  18. 13
      src/assets/svgs/DownloadingLight.tsx
  19. 23
      src/assets/svgs/EmptyCircleSVG.tsx
  20. 22
      src/assets/svgs/ExpandMoreSVG.tsx
  21. 7
      src/assets/svgs/IconTypes.ts
  22. 23
      src/assets/svgs/LightModeSVG.tsx
  23. 18
      src/assets/svgs/PlaylistSVG.tsx
  24. 23
      src/assets/svgs/TimesSVG.tsx
  25. 617
      src/components/EditPlaylist/EditPlaylist.tsx
  26. 586
      src/components/EditPlaylist/Upload-styles.tsx
  27. 765
      src/components/EditVideo/EditVideo.tsx
  28. 586
      src/components/EditVideo/Upload-styles.tsx
  29. 210
      src/components/PlaylistListEdit/PlaylistListEdit.tsx
  30. 66
      src/components/Playlists/Playlists.tsx
  31. 109
      src/components/ResponsiveImage.tsx
  32. 587
      src/components/UploadVideo/Upload-styles.tsx
  33. 687
      src/components/UploadVideo/UploadVideo.tsx
  34. 28
      src/components/common/BlockedNamesModal/BlockedNamesModal-styles.ts
  35. 100
      src/components/common/BlockedNamesModal/BlockedNamesModal.tsx
  36. 295
      src/components/common/Comments/Comment.tsx
  37. 254
      src/components/common/Comments/CommentEditor.tsx
  38. 279
      src/components/common/Comments/CommentSection.tsx
  39. 281
      src/components/common/Comments/Comments-styles.tsx
  40. 72
      src/components/common/ConsentModal.tsx
  41. 204
      src/components/common/DownloadTaskManager.tsx
  42. 436
      src/components/common/FileElement.tsx
  43. 89
      src/components/common/ImageUploader.tsx
  44. 48
      src/components/common/LazyLoad.tsx
  45. 136
      src/components/common/MultiplePublish/MultiplePublish.tsx
  46. 86
      src/components/common/Notification/Notification.tsx
  47. 43
      src/components/common/PageLoader.tsx
  48. 25
      src/components/common/Portal.tsx
  49. 40
      src/components/common/TextEditor/DisplayHtml.tsx
  50. 38
      src/components/common/TextEditor/TextEditor.tsx
  51. 26
      src/components/common/TextEditor/utils.ts
  52. 857
      src/components/common/VideoPlayer.tsx
  53. 648
      src/components/common/VideoPlayerGlobal.tsx
  54. 125
      src/components/layout/Navbar/Navbar-styles.tsx
  55. 434
      src/components/layout/Navbar/Navbar.tsx
  56. 220
      src/constants/index.ts
  57. 62
      src/global.d.ts
  58. 393
      src/hooks/useFetchVideos.tsx
  59. 25
      src/hooks/useWindowSize.tsx
  60. 229
      src/index.css
  61. 17
      src/main.tsx
  62. 77
      src/pages/Home/Channels.tsx
  63. 87
      src/pages/Home/Home-styles.tsx
  64. 15
      src/pages/Home/Home.tsx
  65. 283
      src/pages/Home/VideoList-styles.tsx
  66. 826
      src/pages/Home/VideoList.tsx
  67. 261
      src/pages/Home/VideoListComponentLevel.tsx
  68. 64
      src/pages/IndividualProfile/IndividualProfile.tsx
  69. 16
      src/pages/IndividualProfile/Profile-styles.tsx
  70. 81
      src/pages/VideoContent/VideoContent-styles.tsx
  71. 486
      src/pages/VideoContent/VideoContent.tsx
  72. 27
      src/state/features/authSlice.ts
  73. 62
      src/state/features/globalSlice.ts
  74. 73
      src/state/features/notificationsSlice.ts
  75. 216
      src/state/features/videoSlice.ts
  76. 27
      src/state/store.ts
  77. BIN
      src/styles/fonts/Cairo.ttf
  78. BIN
      src/styles/fonts/Cambon-Light.ttf
  79. BIN
      src/styles/fonts/Catamaran.ttf
  80. BIN
      src/styles/fonts/Karla.ttf
  81. BIN
      src/styles/fonts/Livvic.ttf
  82. BIN
      src/styles/fonts/Merriweather Sans.ttf
  83. BIN
      src/styles/fonts/Oxygen.ttf
  84. BIN
      src/styles/fonts/ProximaNova.otf
  85. BIN
      src/styles/fonts/Raleway.ttf
  86. 184
      src/styles/theme.tsx
  87. BIN
      src/test/download.gif
  88. BIN
      src/test/mockimg.jpg
  89. 7
      src/utils/checkStructure.ts
  90. 14
      src/utils/extractTextFromSlate.ts
  91. 36
      src/utils/fetchVideos.ts
  92. 43
      src/utils/queue.ts
  93. 46
      src/utils/time.ts
  94. 174
      src/utils/toBase64.ts
  95. 1
      src/vite-env.d.ts
  96. 213
      src/wrappers/DownloadWrapper.tsx
  97. 173
      src/wrappers/GlobalWrapper.tsx
  98. 26
      tsconfig.json
  99. 10
      tsconfig.node.json
  100. 8
      vite.config.ts

16
.eslintrc.js

@ -0,0 +1,16 @@
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',
'@typescript-eslint/no-explicit-any': "off"
},
}

25
.gitignore vendored

@ -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

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

7132
package-lock.json generated

File diff suppressed because it is too large Load Diff

47
package.json

@ -0,0 +1,47 @@
{
"name": "qtube",
"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",
"@reduxjs/toolkit": "^1.9.3",
"compressorjs": "^1.2.1",
"dompurify": "^3.0.6",
"localforage": "^1.10.0",
"moment": "^2.29.4",
"quill-image-resize-module-react": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-intersection-observer": "^9.4.3",
"react-quill": "^2.0.0",
"react-redux": "^8.0.5",
"react-rnd": "^10.4.1",
"react-router-dom": "^6.9.0",
"react-toastify": "^9.1.2",
"short-unique-id": "^4.4.4",
"ts-key-enum": "^2.0.12"
},
"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",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"typescript": "^5.0.2",
"vite": "^4.3.2"
}
}

BIN
public/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

43
src/App.css

@ -0,0 +1,43 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

39
src/App.tsx

@ -0,0 +1,39 @@
import { useState } from "react";
import { Routes, Route } from "react-router-dom";
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 DownloadWrapper from "./wrappers/DownloadWrapper";
import { IndividualProfile } from "./pages/IndividualProfile/IndividualProfile";
function App() {
// const themeColor = window._qdnTheme
const [theme, setTheme] = useState("dark");
return (
<Provider store={store}>
<ThemeProvider theme={theme === "light" ? lightTheme : darkTheme}>
<Notification />
<DownloadWrapper>
<GlobalWrapper setTheme={(val: string) => setTheme(val)}>
<CssBaseline />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/share/:name/:id" element={<VideoContent />} />
<Route path="/channel/:name" element={<IndividualProfile />} />
</Routes>
</GlobalWrapper>
</DownloadWrapper>
</ThemeProvider>
</Provider>
);
}
export default App;

BIN
src/assets/img/logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
src/assets/img/logo2.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/assets/img/logo3.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
src/assets/img/q-share-icon.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg

@ -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

@ -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/CircleSVG.tsx

@ -0,0 +1,23 @@
import { IconTypes } from "./IconTypes";
export const CircleSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc,
}) => {
return (
<svg
onClick={onClickFunc}
className={className}
fill={color}
xmlns="http://www.w3.org/2000/svg"
height={height}
viewBox="0 -960 960 960"
width={width}
>
<path d="m424-296 282-282-56-56-226 226-114-114-56 56 170 170Zm56 216q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
</svg>
);
};

23
src/assets/svgs/DarkModeSVG.tsx

@ -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>
)
}

13
src/assets/svgs/DownloadedLight.tsx

@ -0,0 +1,13 @@
import { IconTypes } from './IconTypes'
export const DownloadedLight: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc
}) => {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" height={height} viewBox="0 0 24 24" width={width} fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M5 18h14v2H5v-2zm4.6-2.7L5 10.7l2-1.9 2.6 2.6L17 4l2 2-9.4 9.3z"/></svg>
)
}

13
src/assets/svgs/DownloadingLight.tsx

@ -0,0 +1,13 @@
import { IconTypes } from './IconTypes'
export const DownloadingLight: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc
}) => {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height={height} viewBox="0 0 24 24" width={width} fill="#FFFFFF"><g><rect fill="none" /></g><g><g><path d="M18.32,4.26C16.84,3.05,15.01,2.25,13,2.05v2.02c1.46,0.18,2.79,0.76,3.9,1.62L18.32,4.26z M19.93,11h2.02 c-0.2-2.01-1-3.84-2.21-5.32L18.31,7.1C19.17,8.21,19.75,9.54,19.93,11z M18.31,16.9l1.43,1.43c1.21-1.48,2.01-3.32,2.21-5.32 h-2.02C19.75,14.46,19.17,15.79,18.31,16.9z M13,19.93v2.02c2.01-0.2,3.84-1,5.32-2.21l-1.43-1.43 C15.79,19.17,14.46,19.75,13,19.93z M13,12V7h-2v5H7l5,5l5-5H13z M11,19.93v2.02c-5.05-0.5-9-4.76-9-9.95s3.95-9.45,9-9.95v2.02 C7.05,4.56,4,7.92,4,12S7.05,19.44,11,19.93z"/></g></g></svg>
)
}

23
src/assets/svgs/EmptyCircleSVG.tsx

@ -0,0 +1,23 @@
import { IconTypes } from "./IconTypes";
export const EmptyCircleSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc,
}) => {
return (
<svg onClick={onClickFunc}
className={className}
fill={color}
height={height}
width={width} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" ><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
);
};

22
src/assets/svgs/ExpandMoreSVG.tsx

@ -0,0 +1,22 @@
import { IconTypes } from "./IconTypes";
export const ExpandMoreSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc
}) => {
return (
<svg
onClick={onClickFunc}
height={height}
width={width}
fill={color}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
>
<path d="M480-345 240-585l43-43 197 198 197-197 43 43-240 239Z" />
</svg>
);
};

7
src/assets/svgs/IconTypes.ts

@ -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

@ -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>
)
}

18
src/assets/svgs/PlaylistSVG.tsx

@ -0,0 +1,18 @@
import { IconTypes } from "./IconTypes";
export const PlaylistSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc
}) => {
return (
<svg onClick={onClickFunc}
className={className}
fill={color}
height={height}
width={width} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" ><path d="M120-320v-80h320v80H120Zm0-160v-80h480v80H120Zm0-160v-80h480v80H120Zm520 520v-320l240 160-240 160Z"/></svg>
);
};

23
src/assets/svgs/TimesSVG.tsx

@ -0,0 +1,23 @@
import { IconTypes } from "./IconTypes";
export const TimesSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
onClickFunc
}) => {
return (
<svg
onClick={onClickFunc}
className={className}
fill={color}
xmlns="http://www.w3.org/2000/svg"
height={height}
viewBox="0 -960 960 960"
width={width}
>
<path d="m249-207-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" />
</svg>
);
};

617
src/components/EditPlaylist/EditPlaylist.tsx

@ -0,0 +1,617 @@
import React, { useEffect, useMemo, useState } from "react";
import {
AddCoverImageButton,
AddLogoIcon,
CoverImagePreview,
CrowdfundActionButton,
CrowdfundActionButtonRow,
CustomInputField,
CustomSelect,
LogoPreviewRow,
ModalBody,
NewCrowdfundTitle,
StyledButton,
TimesIcon,
} from "./Upload-styles";
import {
Box,
FormControl,
InputLabel,
MenuItem,
Modal,
OutlinedInput,
Select,
SelectChangeEvent,
Typography,
useTheme,
} from "@mui/material";
import ShortUniqueId from "short-unique-id";
import { useDispatch, useSelector } from "react-redux";
import AddBoxIcon from "@mui/icons-material/AddBox";
import { useDropzone } from "react-dropzone";
import { setNotification } from "../../state/features/notificationsSlice";
import { objectToBase64, uint8ArrayToBase64 } from "../../utils/toBase64";
import { RootState } from "../../state/store";
import {
upsertVideosBeginning,
addToHashMap,
upsertVideos,
setEditVideo,
updateVideo,
updateInHashMap,
setEditPlaylist,
} from "../../state/features/videoSlice";
import ImageUploader from "../common/ImageUploader";
import { QTUBE_PLAYLIST_BASE, QTUBE_VIDEO_BASE, categories, subCategories } from "../../constants";
import { Playlists } from "../Playlists/Playlists";
import { PlaylistListEdit } from "../PlaylistListEdit/PlaylistListEdit";
import { TextEditor } from "../common/TextEditor/TextEditor";
import { extractTextFromHTML } from "../common/TextEditor/utils";
const uid = new ShortUniqueId();
const shortuid = new ShortUniqueId({ length: 5 });
interface NewCrowdfundProps {
editId?: string;
editContent?: null | {
title: string;
user: string;
coverImage: string | null;
};
}
interface VideoFile {
file: File;
title: string;
description: string;
coverImage?: string;
}
export const EditPlaylist = () => {
const theme = useTheme();
const dispatch = useDispatch();
const username = useSelector((state: RootState) => state.auth?.user?.name);
const userAddress = useSelector(
(state: RootState) => state.auth?.user?.address
);
const editVideoProperties = useSelector(
(state: RootState) => state.video.editPlaylistProperties
);
const [playlistData, setPlaylistData] = useState<any>(null);
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [coverImage, setCoverImage] = useState<string>("");
const [videos, setVideos] = useState([]);
const [selectedCategoryVideos, setSelectedCategoryVideos] =
useState<any>(null);
const [selectedSubCategoryVideos, setSelectedSubCategoryVideos] =
useState<any>(null);
const isNew = useMemo(()=> {
return editVideoProperties?.mode === 'new'
}, [editVideoProperties])
useEffect(()=> {
if(isNew){
setPlaylistData({
videos: []
})
}
}, [isNew])
// useEffect(() => {
// if (editVideoProperties) {
// const descriptionString = editVideoProperties?.description || "";
// // Splitting the string at the asterisks
// const parts = descriptionString.split("**");
// // The part within the asterisks
// const extractedString = parts[1];
// // The part after the last asterisks
// const description = parts[2] || ""; // Using '|| '' to handle cases where there is no text after the last **
// setTitle(editVideoProperties?.title || "");
// setDescription(editVideoProperties?.fullDescription || "");
// setCoverImage(editVideoProperties?.videoImage || "");
// // Split the extracted string into key-value pairs
// const keyValuePairs = extractedString.split(";");
// // Initialize variables to hold the category and subcategory values
// let category, subcategory;
// // Loop through each key-value pair
// keyValuePairs.forEach((pair) => {
// const [key, value] = pair.split(":");
// // Check the key and assign the value to the appropriate variable
// if (key === "category") {
// category = value;
// } else if (key === "subcategory") {
// subcategory = value;
// }
// });
// if(category){
// const selectedOption = categories.find((option) => option.id === +category);
// setSelectedCategoryVideos(selectedOption || null);
// }
// if(subcategory){
// const selectedOption = categories.find((option) => option.id === +subcategory);
// setSelectedCategoryVideos(selectedOption || null);
// }
// }
// }, [editVideoProperties]);
const checkforPlaylist = React.useCallback(async (videoList) => {
try {
const combinedData: any = {};
const videos = [];
if (videoList) {
for (const vid of videoList) {
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${vid.identifier}&limit=1&includemetadata=true&reverse=true&name=${vid.name}&exactmatchnames=true&offset=0`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearchVid = await response.json();
if (responseDataSearchVid?.length > 0) {
let resourceData2 = responseDataSearchVid[0];
videos.push(resourceData2);
}
}
}
combinedData.videos = videos;
setPlaylistData(combinedData);
} catch (error) {}
}, []);
useEffect(() => {
if (editVideoProperties) {
setTitle(editVideoProperties?.title || "");
if(editVideoProperties?.htmlDescription){
setDescription(editVideoProperties?.htmlDescription);
} else if(editVideoProperties?.description) {
const paragraph = `<p>${editVideoProperties?.description}</p>`
setDescription(paragraph);
}
setCoverImage(editVideoProperties?.image || "");
setVideos(editVideoProperties?.videos || []);
if (editVideoProperties?.category) {
const selectedOption = categories.find(
(option) => option.id === +editVideoProperties.category
);
setSelectedCategoryVideos(selectedOption || null);
}
if (
editVideoProperties?.category &&
editVideoProperties?.subcategory &&
subCategories[+editVideoProperties?.category]
) {
const selectedOption = subCategories[
+editVideoProperties?.category
]?.find((option) => option.id === +editVideoProperties.subcategory);
setSelectedSubCategoryVideos(selectedOption || null);
}
if (editVideoProperties?.videos) {
checkforPlaylist(editVideoProperties?.videos);
}
}
}, [editVideoProperties]);
const onClose = () => {
setTitle("")
setDescription("")
setVideos([])
setPlaylistData(null)
setSelectedCategoryVideos(null)
setSelectedSubCategoryVideos(null)
setCoverImage("")
dispatch(setEditPlaylist(null));
};
async function publishQDNResource() {
try {
if(!title) throw new Error('Please enter a title')
if(!description) throw new Error('Please enter a description')
if(!coverImage) throw new Error('Please select cover image')
if(!selectedCategoryVideos) throw new Error('Please select a category')
if (!editVideoProperties) return;
if (!userAddress) throw new Error("Unable to locate user address");
let errorMsg = "";
let name = "";
if (username) {
name = username;
}
if (!name) {
errorMsg =
"Cannot publish without access to your name. Please authenticate.";
}
if (!isNew && editVideoProperties?.user !== username) {
errorMsg = "Cannot publish another user's resource";
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: "error",
})
);
return;
}
const category = selectedCategoryVideos.id;
const subcategory = selectedSubCategoryVideos?.id || "";
const videoStructured = playlistData.videos.map((item) => {
const descriptionVid = item?.metadata?.description;
if (!descriptionVid) throw new Error("cannot find video code");
// Split the string by ';'
let parts = descriptionVid.split(";");
// Initialize a variable to hold the code value
let codeValue = "";
// Loop through the parts to find the one that starts with 'code:'
for (let part of parts) {
if (part.startsWith("code:")) {
codeValue = part.split(":")[1];
break;
}
}
if (!codeValue) throw new Error("cannot find video code");
return {
identifier: item.identifier,
name: item.name,
service: item.service,
code: codeValue,
};
});
const id = uid();
let commentsId = editVideoProperties?.id
if(isNew){
commentsId = `${QTUBE_PLAYLIST_BASE}_cm_${id}`
}
const stringDescription = extractTextFromHTML(description)
const playlistObject: any = {
title,
version: 1,
description: stringDescription,
htmlDescription: description,
image: coverImage,
videos: videoStructured,
commentsId: commentsId,
category,
subcategory
};
const codes = videoStructured.map((item) => `c:${item.code};`).join("");
let metadescription =
`**category:${category};subcategory:${subcategory};${codes}**` +
stringDescription.slice(0, 120);
const crowdfundObjectToBase64 = await objectToBase64(playlistObject);
// Description is obtained from raw data
let identifier = editVideoProperties?.id
const sanitizeTitle = title
.replace(/[^a-zA-Z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim()
.toLowerCase();
if(isNew){
identifier = `${QTUBE_PLAYLIST_BASE}${sanitizeTitle.slice(0, 30)}_${id}`;
}
const requestBodyJson: any = {
action: "PUBLISH_QDN_RESOURCE",
name: username,
service: "PLAYLIST",
data64: crowdfundObjectToBase64,
title: title.slice(0, 50),
description: metadescription,
identifier: identifier,
tag1: QTUBE_VIDEO_BASE,
};
await qortalRequest(requestBodyJson);
if(isNew){
const objectToStore = {
title: title.slice(0, 50),
description: metadescription,
id: identifier,
service: "PLAYLIST",
name: username,
...playlistObject
}
dispatch(
updateVideo(objectToStore)
);
dispatch(
updateInHashMap(objectToStore)
);
} else {
dispatch(
updateVideo({
...editVideoProperties,
...playlistObject,
})
);
dispatch(
updateInHashMap({
...editVideoProperties,
...playlistObject,
})
);
}
onClose();
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to publish update",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to publish update",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to publish update",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
throw new Error("Failed to publish update");
}
}
const handleOnchange = (index: number, type: string, value: string) => {
// setFiles((prev) => {
// let formattedValue = value
// console.log({type})
// if(type === 'title'){
// formattedValue = value.replace(/[^a-zA-Z0-9\s]/g, "")
// }
// const copyFiles = [...prev];
// copyFiles[index] = {
// ...copyFiles[index],
// [type]: formattedValue,
// };
// return copyFiles;
// });
};
const handleOptionCategoryChangeVideos = (
event: SelectChangeEvent<string>
) => {
const optionId = event.target.value;
const selectedOption = categories.find((option) => option.id === +optionId);
setSelectedCategoryVideos(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos(selectedOption || null);
};
const removeVideo = (index) => {
const copyData = structuredClone(playlistData);
copyData.videos.splice(index, 1);
setPlaylistData(copyData);
};
const addVideo = (data) => {
if(playlistData?.videos?.length > 9){
dispatch(setNotification({
msg: "Max 10 videos per playlist",
alertType: "error",
}));
return
}
const copyData = structuredClone(playlistData);
copyData.videos = [...copyData.videos, { ...data }];
setPlaylistData(copyData);
};
return (
<>
<Modal
open={!!editVideoProperties}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<ModalBody>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
{isNew ? (
<NewCrowdfundTitle>Create new playlist</NewCrowdfundTitle>
) : (
<NewCrowdfundTitle>Update Playlist properties</NewCrowdfundTitle>
)}
</Box>
<>
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
}}
>
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedCategoryVideos?.id || ""}
onChange={handleOptionCategoryChangeVideos}
>
{categories.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
{selectedCategoryVideos &&
subCategories[selectedCategoryVideos?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Sub-Category</InputLabel>
<Select
labelId="Sub-Category"
input={<OutlinedInput label="Select a Sub-Category" />}
value={selectedSubCategoryVideos?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos(
e,
subCategories[selectedCategoryVideos?.id]
)
}
>
{subCategories[selectedCategoryVideos.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
</Box>
<React.Fragment>
{!coverImage ? (
<ImageUploader onPick={(img: string) => setCoverImage(img)}>
<AddCoverImageButton variant="contained">
Add Cover Image
<AddLogoIcon
sx={{
height: "25px",
width: "auto",
}}
></AddLogoIcon>
</AddCoverImageButton>
</ImageUploader>
) : (
<LogoPreviewRow>
<CoverImagePreview src={coverImage} alt="logo" />
<TimesIcon
color={theme.palette.text.primary}
onClickFunc={() => setCoverImage("")}
height={"32"}
width={"32"}
></TimesIcon>
</LogoPreviewRow>
)}
<CustomInputField
name="title"
label="Title of playlist"
variant="filled"
value={title}
onChange={(e) => {
const value = e.target.value;
const formattedValue = value.replace(/[^a-zA-Z0-9\s-_!?]/g, "");
setTitle(formattedValue);
}}
inputProps={{ maxLength: 180 }}
required
/>
{/* <CustomInputField
name="description"
label="Describe your playlist in a few words"
variant="filled"
value={description}
onChange={(e) => setDescription(e.target.value)}
inputProps={{ maxLength: 10000 }}
multiline
maxRows={3}
required
/> */}
<Typography sx={{
fontSize: '18px'
}}>Description of playlist</Typography>
<TextEditor inlineContent={description} setInlineContent={(value)=> {
setDescription(value)
}} />
</React.Fragment>
<PlaylistListEdit
playlistData={playlistData}
removeVideo={removeVideo}
addVideo={addVideo}
/>
</>
<CrowdfundActionButtonRow>
<CrowdfundActionButton
onClick={() => {
onClose();
}}
variant="contained"
color="error"
>
Cancel
</CrowdfundActionButton>
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
}}
>
<CrowdfundActionButton
variant="contained"
onClick={() => {
publishQDNResource();
}}
>
Publish
</CrowdfundActionButton>
</Box>
</CrowdfundActionButtonRow>
</ModalBody>
</Modal>
</>
);
};

586
src/components/EditPlaylist/Upload-styles.tsx

@ -0,0 +1,586 @@
import { styled } from "@mui/system";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Grid,
Rating,
TextField,
Typography,
Select
} from "@mui/material";
import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate";
import { TimesSVG } from "../../assets/svgs/TimesSVG";
export const DoubleLine = styled(Typography)`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
`;
export const MainContainer = styled(Grid)({
width: "100%",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
margin: 0,
});
export const MainCol = styled(Grid)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
padding: "20px",
}));
export const CreateContainer = styled(Box)(({ theme }) => ({
position: "fixed",
bottom: "20px",
right: "20px",
cursor: "pointer",
background: theme.palette.background.default,
width: "50px",
height: "50px",
display: "flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "50%",
}));
export const ModalBody = styled(Box)(({ theme }) => ({
position: "absolute",
backgroundColor: theme.palette.background.default,
borderRadius: "4px",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "75%",
maxWidth: "900px",
padding: "15px 35px",
display: "flex",
flexDirection: "column",
gap: "17px",
overflowY: "auto",
maxHeight: "95vh",
boxShadow:
theme.palette.mode === "dark"
? "0px 4px 5px 0px hsla(0,0%,0%,0.14), 0px 1px 10px 0px hsla(0,0%,0%,0.12), 0px 2px 4px -1px hsla(0,0%,0%,0.2)"
: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px",
"&::-webkit-scrollbar-track": {
backgroundColor: theme.palette.background.paper,
},
"&::-webkit-scrollbar-track:hover": {
backgroundColor: theme.palette.background.paper,
},
"&::-webkit-scrollbar": {
width: "16px",
height: "10px",
backgroundColor: theme.palette.mode === "light" ? "#f6f8fa" : "#292d3e",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.mode === "light" ? "#d3d9e1" : "#575757",
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
},
"&::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.mode === "light" ? "#b7bcc4" : "#474646",
},
}));
export const NewCrowdfundTitle = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "25px",
userSelect: "none",
}));
export const NewCrowdFundFont = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "18px",
userSelect: "none",
}));
export const NewCrowdfundTimeDescription = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "18px",
userSelect: "none",
fontStyle: "italic",
textDecoration: "underline",
}));
export const CustomInputField = styled(TextField)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.default,
borderColor: theme.palette.background.paper,
"& label": {
color: theme.palette.mode === "light" ? "#808183" : "#edeef0",
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
},
"& label.Mui-focused": {
color: theme.palette.mode === "light" ? "#A0AAB4" : "#d7d8da",
},
"& .MuiInput-underline:after": {
borderBottomColor: theme.palette.mode === "light" ? "#B2BAC2" : "#c9cccf",
},
"& .MuiOutlinedInput-root": {
"& fieldset": {
borderColor: "#E0E3E7",
},
"&:hover fieldset": {
borderColor: "#B2BAC2",
},
"&.Mui-focused fieldset": {
borderColor: "#6F7E8C",
},
},
"& .MuiInputBase-root": {
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
},
"& [class$='-MuiFilledInput-root']": {
padding: "30px 12px 8px",
},
"& .MuiFilledInput-root:after": {
borderBottomColor: theme.palette.secondary.main,
},
}));
export const CrowdfundTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
letterSpacing: "1px",
fontWeight: 400,
fontSize: "20px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
}));
export const CrowdfundSubTitleRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
flexDirection: "row",
});
export const CrowdfundSubTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
letterSpacing: "1px",
fontWeight: 400,
fontSize: "17px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
borderBottom: `1px solid ${theme.palette.text.primary}`,
paddingBottom: "1.5px",
width: "fit-content",
textDecoration: "none",
}));
export const CrowdfundDescription = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "16px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
}));
export const Spacer = ({ height }: any) => {
return (
<Box
sx={{
height: height,
}}
/>
);
};
export const StyledCardHeaderComment = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: "5px",
padding: "7px 0px",
});
export const StyledCardCol = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const StyledCardColComment = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const AuthorTextComment = styled(Typography)({
fontFamily: "Raleway, sans-serif",
fontSize: "16px",
lineHeight: "1.2",
});
export const AddLogoIcon = styled(AddPhotoAlternateIcon)(({ theme }) => ({
color: "#fff",
height: "25px",
width: "auto",
}));
export const CoverImagePreview = styled("img")(({ theme }) => ({
width: "100px",
height: "100px",
objectFit: "contain",
userSelect: "none",
borderRadius: "3px",
marginBottom: "10px",
}));
export const LogoPreviewRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "10px",
}));
export const TimesIcon = styled(TimesSVG)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
borderRadius: "50%",
padding: "5px",
transition: "all 0.2s ease-in-out",
"&:hover": {
cursor: "pointer",
scale: "1.1",
},
}));
export const CrowdfundCardTitle = styled(DoubleLine)(({ theme }) => ({
fontFamily: "Montserrat",
fontSize: "24px",
letterSpacing: "-0.3px",
userSelect: "none",
marginBottom: "auto",
textAlign: "center",
"@media (max-width: 650px)": {
fontSize: "18px",
},
}));
export const CrowdfundUploadDate = styled(Typography)(({ theme }) => ({
fontFamily: "Montserrat",
fontSize: "12px",
letterSpacing: "0.2px",
color: theme.palette.text.primary,
userSelect: "none",
}));
export const CATContainer = styled(Box)(({ theme }) => ({
position: "relative",
display: "flex",
padding: "15px",
flexDirection: "column",
gap: "20px",
justifyContent: "center",
width: "100%",
alignItems: "center",
}));
export const AddCrowdFundButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
textTransform: "none",
padding: "10px 25px",
fontSize: "15px",
gap: "8px",
color: "#ffffff",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.main : "#2a9a86",
border: "none",
borderRadius: "5px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.dark : "#217e6d",
},
}));
export const EditCrowdFundButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
textTransform: "none",
padding: "5px 12px",
gap: "8px",
color: "#ffffff",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.main : "#2a9a86",
border: "none",
borderRadius: "5px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.dark : "#217e6d",
},
}));
export const CrowdfundListWrapper = styled(Box)(({ theme }) => ({
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
marginTop: "0px",
background: theme.palette.background.default,
}));
export const CrowdfundTitleRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
gap: "10px",
}));
export const CrowdfundPageTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontSize: "35px",
fontWeight: 400,
letterSpacing: "1px",
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundStatusRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "Mulish",
fontSize: "21px",
fontWeight: 400,
letterSpacing: 0,
border: `1px solid ${theme.palette.text.primary}`,
borderRadius: "8px",
padding: "15px 25px",
userSelect: "none",
}));
export const CrowdfundDescriptionRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontFamily: "Montserrat",
fontSize: "18px",
fontWeight: 400,
letterSpacing: 0,
});
export const AboutMyCrowdfund = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontSize: "23px",
fontWeight: 400,
letterSpacing: "1px",
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundInlineContentRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
});
export const CrowdfundInlineContent = styled(Box)(({ theme }) => ({
display: "flex",
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: 0,
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundAccordion = styled(Accordion)(({ theme }) => ({
backgroundColor: theme.palette.primary.light,
"& .Mui-expanded": {
minHeight: "auto !important",
},
}));
export const CrowdfundAccordionSummary = styled(AccordionSummary)({
height: "50px",
"& .Mui-expanded": {
margin: "0px !important",
},
});
export const CrowdfundAccordionFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "20px",
fontWeight: 400,
letterSpacing: "0px",
color: theme.palette.text.primary,
userSelect: "none",
}));
export const CrowdfundAccordionDetails = styled(AccordionDetails)({
padding: "0px 16px 16px 16px",
});
export const AddCoverImageButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "16px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
}));
export const CoverImage = styled("img")({
width: "100%",
height: "250px",
objectFit: "cover",
objectPosition: "center",
});
export const CrowdfundActionButtonRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
});
export const CrowdfundActionButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "16px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
}));
export const BackToHomeButton = styled(Button)(({ theme }) => ({
position: "absolute",
top: "20px",
left: "20px",
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "13px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
padding: "5px 10px",
backgroundColor: theme.palette.secondary.main,
transition: "all 0.3s ease-in-out",
"&:hover": {
backgroundColor: theme.palette.secondary.dark,
cursor: "pointer",
},
}));
export const CrowdfundLoaderRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
padding: "10px",
});
export const RatingContainer = styled(Box)({
display: "flex",
alignItems: "center",
padding: "1px 5px",
borderRadius: "5px",
backgroundColor: "transparent",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor: "#e4ddddac",
},
});
export const StyledRating = styled(Rating)({
fontSize: "28px",
});
export const NoReviewsFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontWeight: 400,
letterSpacing: 0,
color: theme.palette.text.primary,
}));
export const StyledButton = styled(Button)(({ theme }) => ({
fontWeight: 600,
color: theme.palette.text.primary
}))
export const CustomSelect = styled(Select)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.default,
'& .MuiSelect-select': {
padding: '12px',
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
borderRadius: theme.shape.borderRadius, // Match border radius
},
'&:before': {
// Underline style
borderBottomColor: theme.palette.mode === "light" ? "#B2BAC2" : "#c9cccf",
},
'&:after': {
// Underline style when focused
borderBottomColor: theme.palette.secondary.main,
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: "#E0E3E7",
},
'&:hover fieldset': {
borderColor: "#B2BAC2",
},
'&.Mui-focused fieldset': {
borderColor: "#6F7E8C",
},
},
'& .MuiInputBase-root': {
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
},
}));

765
src/components/EditVideo/EditVideo.tsx

@ -0,0 +1,765 @@
import React, { useEffect, useState } from "react";
import {
AddCoverImageButton,
AddLogoIcon,
CoverImagePreview,
CrowdfundActionButton,
CrowdfundActionButtonRow,
CustomInputField,
CustomSelect,
LogoPreviewRow,
ModalBody,
NewCrowdfundTitle,
StyledButton,
TimesIcon,
} from "./Upload-styles";
import {
Box,
FormControl,
InputLabel,
MenuItem,
Modal,
OutlinedInput,
Select,
SelectChangeEvent,
Typography,
useTheme,
} from "@mui/material";
import RemoveIcon from "@mui/icons-material/Remove";
import ShortUniqueId from "short-unique-id";
import { useDispatch, useSelector } from "react-redux";
import AddBoxIcon from "@mui/icons-material/AddBox";
import { useDropzone } from "react-dropzone";
import { setNotification } from "../../state/features/notificationsSlice";
import { objectToBase64, uint8ArrayToBase64 } from "../../utils/toBase64";
import { RootState } from "../../state/store";
import {
upsertVideosBeginning,
addToHashMap,
upsertVideos,
setEditVideo,
updateVideo,
updateInHashMap,
} from "../../state/features/videoSlice";
import ImageUploader from "../common/ImageUploader";
import { QTUBE_VIDEO_BASE, categories, subCategories, subCategories2,
subCategories3, } from "../../constants";
import { MultiplePublish } from "../common/MultiplePublish/MultiplePublish";
import { TextEditor } from "../common/TextEditor/TextEditor";
import { extractTextFromHTML } from "../common/TextEditor/utils";
const uid = new ShortUniqueId();
const shortuid = new ShortUniqueId({ length: 5 });
interface NewCrowdfundProps {
editId?: string;
editContent?: null | {
title: string;
user: string;
coverImage: string | null;
};
}
interface VideoFile {
file: File;
title: string;
description: string;
coverImage?: string;
identifier?:string;
filename?:string
}
export const EditVideo = () => {
const theme = useTheme();
const dispatch = useDispatch();
const username = useSelector((state: RootState) => state.auth?.user?.name);
const userAddress = useSelector(
(state: RootState) => state.auth?.user?.address
);
const editVideoProperties = useSelector(
(state: RootState) => state.video.editVideoProperties
);
const [publishes, setPublishes] = useState<any[]>([]);
const [isOpenMultiplePublish, setIsOpenMultiplePublish] = useState(false);
const [videoPropertiesToSetToRedux, setVideoPropertiesToSetToRedux] =
useState(null);
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [coverImage, setCoverImage] = useState<string>("");
const [file, setFile] = useState(null);
const [files, setFiles] = useState<VideoFile[]>([]);
const [selectedCategoryVideos, setSelectedCategoryVideos] =
useState<any>(null);
const [selectedSubCategoryVideos, setSelectedSubCategoryVideos] =
useState<any>(null);
const [selectedSubCategoryVideos2, setSelectedSubCategoryVideos2] =
useState<any>(null);
const [selectedSubCategoryVideos3, setSelectedSubCategoryVideos3] =
useState<any>(null);
const { getRootProps, getInputProps } = useDropzone({
maxFiles: 10,
maxSize: 419430400, // 400 MB in bytes
onDrop: (acceptedFiles, rejectedFiles) => {
const formatArray = acceptedFiles.map((item) => {
return {
file: item,
title: "",
description: "",
coverImage: "",
};
});
setFiles((prev) => [...prev, ...formatArray]);
let errorString = null;
rejectedFiles.forEach(({ file, errors }) => {
errors.forEach((error) => {
if (error.code === "file-too-large") {
errorString = "File must be under 400mb";
}
console.log(`Error with file ${file.name}: ${error.message}`);
});
});
if (errorString) {
const notificationObj = {
msg: errorString,
alertType: "error",
};
dispatch(setNotification(notificationObj));
}
},
});
// useEffect(() => {
// if (editVideoProperties) {
// const descriptionString = editVideoProperties?.description || "";
// // Splitting the string at the asterisks
// const parts = descriptionString.split("**");
// // The part within the asterisks
// const extractedString = parts[1];
// // The part after the last asterisks
// const description = parts[2] || ""; // Using '|| '' to handle cases where there is no text after the last **
// setTitle(editVideoProperties?.title || "");
// setDescription(editVideoProperties?.fullDescription || "");
// setCoverImage(editVideoProperties?.videoImage || "");
// // Split the extracted string into key-value pairs
// const keyValuePairs = extractedString.split(";");
// // Initialize variables to hold the category and subcategory values
// let category, subcategory;
// // Loop through each key-value pair
// keyValuePairs.forEach((pair) => {
// const [key, value] = pair.split(":");
// // Check the key and assign the value to the appropriate variable
// if (key === "category") {
// category = value;
// } else if (key === "subcategory") {
// subcategory = value;
// }
// });
// if(category){
// const selectedOption = categories.find((option) => option.id === +category);
// setSelectedCategoryVideos(selectedOption || null);
// }
// if(subcategory){
// const selectedOption = categories.find((option) => option.id === +subcategory);
// setSelectedCategoryVideos(selectedOption || null);
// }
// }
// }, [editVideoProperties]);
useEffect(() => {
if (editVideoProperties) {
setTitle(editVideoProperties?.title || "");
setFiles(editVideoProperties?.files || [])
if(editVideoProperties?.htmlDescription){
setDescription(editVideoProperties?.htmlDescription);
} else if(editVideoProperties?.fullDescription) {
const paragraph = `<p>${editVideoProperties?.fullDescription}</p>`
setDescription(paragraph);
}
if (editVideoProperties?.category) {
const selectedOption = categories.find(
(option) => option.id === +editVideoProperties.category
);
setSelectedCategoryVideos(selectedOption || null);
}
if (
editVideoProperties?.category &&
editVideoProperties?.subcategory &&
subCategories[+editVideoProperties?.category]
) {
const selectedOption = subCategories[
+editVideoProperties?.category
]?.find((option) => option.id === +editVideoProperties.subcategory);
setSelectedSubCategoryVideos(selectedOption || null);
}
if (
editVideoProperties?.category &&
editVideoProperties?.subcategory2 &&
subCategories2[+editVideoProperties?.subcategory]
) {
const selectedOption = subCategories2[
+editVideoProperties?.subcategory
]?.find((option) => option.id === +editVideoProperties.subcategory2);
setSelectedSubCategoryVideos2(selectedOption || null);
}
if (
editVideoProperties?.category &&
editVideoProperties?.subcategory3 &&
subCategories3[+editVideoProperties?.subcategory2]
) {
const selectedOption = subCategories3[
+editVideoProperties?.subcategory2
]?.find((option) => option.id === +editVideoProperties.subcategory3);
setSelectedSubCategoryVideos3(selectedOption || null);
}
}
}, [editVideoProperties]);
const onClose = () => {
dispatch(setEditVideo(null));
setVideoPropertiesToSetToRedux(null);
setFile(null);
setTitle("");
setDescription("");
setCoverImage("");
};
async function publishQDNResource() {
try {
if (!title) throw new Error("Please enter a title");
if (!description) throw new Error("Please enter a description");
if (!selectedCategoryVideos) throw new Error("Please select a category");
if (!editVideoProperties) return;
if (!userAddress) throw new Error("Unable to locate user address");
if(files.length === 0) throw new Error("Add at least one file");
let errorMsg = "";
let name = "";
if (username) {
name = username;
}
if (!name) {
errorMsg =
"Cannot publish without access to your name. Please authenticate.";
}
if (editVideoProperties?.user !== username) {
errorMsg = "Cannot publish another user's resource";
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: "error",
})
);
return;
}
let fileReferences = []
let listOfPublishes = [];
const fullDescription = extractTextFromHTML(description);
const category = selectedCategoryVideos.id;
const subcategory = selectedSubCategoryVideos?.id || "";
const subcategory2 = selectedSubCategoryVideos2?.id || "";
const subcategory3 = selectedSubCategoryVideos3?.id || "";
const sanitizeTitle = title
.replace(/[^a-zA-Z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim()
.toLowerCase();
for (const publish of files) {
if(publish?.identifier){
fileReferences.push(publish)
continue
}
const file = publish.file;
const id = uid();
const identifier = `${QTUBE_VIDEO_BASE}${sanitizeTitle.slice(0, 30)}_${id}`;
let fileExtension = "";
const fileExtensionSplit = file?.name?.split(".");
if (fileExtensionSplit?.length > 1) {
fileExtension = fileExtensionSplit?.pop() || "";
}
let firstPartName = fileExtensionSplit[0]
let filename = firstPartName.slice(0, 15);
// Step 1: Replace all white spaces with underscores
// Replace all forms of whitespace (including non-standard ones) with underscores
let stringWithUnderscores = filename.replace(/[\s\uFEFF\xA0]+/g, "_");
// Remove all non-alphanumeric characters (except underscores)
let alphanumericString = stringWithUnderscores.replace(
/[^a-zA-Z0-9_]/g,
""
);
if(fileExtension){
filename = `${alphanumericString.trim()}.${fileExtension}`
} else {
filename = alphanumericString
}
let metadescription =
`**cat:${category};sub:${subcategory};sub2:${subcategory2};sub3:${subcategory3}**` +
fullDescription.slice(0, 150);
const requestBodyVideo: any = {
action: "PUBLISH_QDN_RESOURCE",
name: name,
service: "FILE",
file,
title: title.slice(0, 50),
description: metadescription,
identifier,
filename,
tag1: QTUBE_VIDEO_BASE,
};
listOfPublishes.push(requestBodyVideo);
fileReferences.push({
filename: file.name,
identifier,
name,
service: 'FILE',
mimetype: file.type,
size: file.size
})
}
const fileObject: any = {
title,
version: editVideoProperties.version,
fullDescription,
htmlDescription: description,
commentsId: editVideoProperties.commentsId,
category,
subcategory,
subcategory2,
subcategory3,
files: fileReferences
};
let metadescription =
`**cat:${category};sub:${subcategory};sub2:${subcategory2}**` +
fullDescription.slice(0, 150);
const crowdfundObjectToBase64 = await objectToBase64(fileObject);
// Description is obtained from raw data
const requestBodyJson: any = {
action: "PUBLISH_QDN_RESOURCE",
name: name,
service: "DOCUMENT",
data64: crowdfundObjectToBase64,
title: title.slice(0, 50),
description: metadescription,
identifier: editVideoProperties.id,
tag1: QTUBE_VIDEO_BASE,
filename: `video_metadata.json`,
};
listOfPublishes.push(requestBodyJson);
setPublishes(listOfPublishes);
setIsOpenMultiplePublish(true);
setVideoPropertiesToSetToRedux({
...editVideoProperties,
...fileObject,
});
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to publish update",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to publish update",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to publish update",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
throw new Error("Failed to publish update");
}
}
const handleOnchange = (index: number, type: string, value: string) => {
// setFiles((prev) => {
// let formattedValue = value
// console.log({type})
// if(type === 'title'){
// formattedValue = value.replace(/[^a-zA-Z0-9\s]/g, "")
// }
// const copyFiles = [...prev];
// copyFiles[index] = {
// ...copyFiles[index],
// [type]: formattedValue,
// };
// return copyFiles;
// });
};
const handleOptionCategoryChangeVideos = (
event: SelectChangeEvent<string>
) => {
const optionId = event.target.value;
const selectedOption = categories.find((option) => option.id === +optionId);
setSelectedCategoryVideos(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos2 = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos2(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos3 = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos3(selectedOption || null);
};
return (
<>
<Modal
open={!!editVideoProperties}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<ModalBody>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<NewCrowdfundTitle>Update share</NewCrowdfundTitle>
</Box>
<>
<Box
{...getRootProps()}
sx={{
border: "1px dashed gray",
padding: 2,
textAlign: "center",
marginBottom: 2,
cursor: "pointer",
}}
>
<input {...getInputProps()} />
<Typography>Click to add more files</Typography>
</Box>
{files.map((file, index) => {
const isExistingFile = !!file?.identifier
return (
<React.Fragment key={index}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography>{isExistingFile? file.filename : file?.file?.name}</Typography>
<RemoveIcon
onClick={() => {
setFiles((prev) => {
const copyPrev = [...prev];
copyPrev.splice(index, 1);
return copyPrev;
});
}}
sx={{
cursor: "pointer",
}}
/>
</Box>
</React.Fragment>
);
})}
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "flex-start",
}}
>
{files?.length > 0 && (
<>
<Box sx={{
display: 'flex',
flexDirection: 'column',
gap: '20px',
width: '50%'
}}>
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedCategoryVideos?.id || ""}
onChange={handleOptionCategoryChangeVideos}
>
{categories.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{selectedCategoryVideos && (
<>
<Box sx={{
display: 'flex',
flexDirection: 'column',
gap: '20px',
width: '50%'
}}>
{selectedCategoryVideos &&
subCategories[selectedCategoryVideos?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">
Select a Sub-Category
</InputLabel>
<Select
labelId="Sub-Category"
input={
<OutlinedInput label="Select a Sub-Category" />
}
value={selectedSubCategoryVideos?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos(
e,
subCategories[selectedCategoryVideos?.id]
)
}
>
{subCategories[selectedCategoryVideos.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
{selectedSubCategoryVideos &&
subCategories2[selectedSubCategoryVideos?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">
Select a Sub-sub-Category
</InputLabel>
<Select
labelId="Sub-Category"
input={
<OutlinedInput label="Select a Sub-sub-Category" />
}
value={selectedSubCategoryVideos2?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos2(
e,
subCategories2[selectedSubCategoryVideos?.id]
)
}
>
{subCategories2[selectedSubCategoryVideos.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
{selectedSubCategoryVideos2 &&
subCategories3[selectedSubCategoryVideos2?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">
Select a Sub-3x-subCategory
</InputLabel>
<Select
labelId="Sub-Category"
input={
<OutlinedInput label="Select a Sub-3x-Category" />
}
value={selectedSubCategoryVideos3?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos3(
e,
subCategories3[selectedSubCategoryVideos2?.id]
)
}
>
{subCategories3[selectedSubCategoryVideos2.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
</Box>
</>
)}
</>
)}
</Box>
{files?.length > 0 && (
<>
<CustomInputField
name="title"
label="Title of share"
variant="filled"
value={title}
onChange={(e) => {
const value = e.target.value;
const formattedValue = value.replace(
/[^a-zA-Z0-9\s-_!?]/g,
""
);
setTitle(formattedValue);
}}
inputProps={{ maxLength: 180 }}
required
/>
<Typography
sx={{
fontSize: "18px",
}}
>
Description of share
</Typography>
<TextEditor
inlineContent={description}
setInlineContent={(value) => {
setDescription(value);
}}
/>
</>
)}
</>
<CrowdfundActionButtonRow>
<CrowdfundActionButton
onClick={() => {
onClose();
}}
variant="contained"
color="error"
>
Cancel
</CrowdfundActionButton>
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
}}
>
<CrowdfundActionButton
variant="contained"
onClick={() => {
publishQDNResource();
}}
>
Publish
</CrowdfundActionButton>
</Box>
</CrowdfundActionButtonRow>
</ModalBody>
</Modal>
{isOpenMultiplePublish && (
<MultiplePublish
isOpen={isOpenMultiplePublish}
onSubmit={() => {
setIsOpenMultiplePublish(false);
const clonedCopy = structuredClone(videoPropertiesToSetToRedux);
dispatch(updateVideo(clonedCopy));
dispatch(updateInHashMap(clonedCopy));
dispatch(
setNotification({
msg: "Video updated",
alertType: "success",
})
);
onClose();
}}
publishes={publishes}
/>
)}
</>
);
};

586
src/components/EditVideo/Upload-styles.tsx

@ -0,0 +1,586 @@
import { styled } from "@mui/system";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Grid,
Rating,
TextField,
Typography,
Select
} from "@mui/material";
import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate";
import { TimesSVG } from "../../assets/svgs/TimesSVG";
export const DoubleLine = styled(Typography)`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
`;
export const MainContainer = styled(Grid)({
width: "100%",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
margin: 0,
});
export const MainCol = styled(Grid)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
padding: "20px",
}));
export const CreateContainer = styled(Box)(({ theme }) => ({
position: "fixed",
bottom: "20px",
right: "20px",
cursor: "pointer",
background: theme.palette.background.default,
width: "50px",
height: "50px",
display: "flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "50%",
}));
export const ModalBody = styled(Box)(({ theme }) => ({
position: "absolute",
backgroundColor: theme.palette.background.default,
borderRadius: "4px",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "75%",
maxWidth: "900px",
padding: "15px 35px",
display: "flex",
flexDirection: "column",
gap: "17px",
overflowY: "auto",
maxHeight: "95vh",
boxShadow:
theme.palette.mode === "dark"
? "0px 4px 5px 0px hsla(0,0%,0%,0.14), 0px 1px 10px 0px hsla(0,0%,0%,0.12), 0px 2px 4px -1px hsla(0,0%,0%,0.2)"
: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px",
"&::-webkit-scrollbar-track": {
backgroundColor: theme.palette.background.paper,
},
"&::-webkit-scrollbar-track:hover": {
backgroundColor: theme.palette.background.paper,
},
"&::-webkit-scrollbar": {
width: "16px",
height: "10px",
backgroundColor: theme.palette.mode === "light" ? "#f6f8fa" : "#292d3e",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.mode === "light" ? "#d3d9e1" : "#575757",
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
},
"&::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.mode === "light" ? "#b7bcc4" : "#474646",
},
}));
export const NewCrowdfundTitle = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "25px",
userSelect: "none",
}));
export const NewCrowdFundFont = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "18px",
userSelect: "none",
}));
export const NewCrowdfundTimeDescription = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "18px",
userSelect: "none",
fontStyle: "italic",
textDecoration: "underline",
}));
export const CustomInputField = styled(TextField)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.default,
borderColor: theme.palette.background.paper,
"& label": {
color: theme.palette.mode === "light" ? "#808183" : "#edeef0",
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
},
"& label.Mui-focused": {
color: theme.palette.mode === "light" ? "#A0AAB4" : "#d7d8da",
},
"& .MuiInput-underline:after": {
borderBottomColor: theme.palette.mode === "light" ? "#B2BAC2" : "#c9cccf",
},
"& .MuiOutlinedInput-root": {
"& fieldset": {
borderColor: "#E0E3E7",
},
"&:hover fieldset": {
borderColor: "#B2BAC2",
},
"&.Mui-focused fieldset": {
borderColor: "#6F7E8C",
},
},
"& .MuiInputBase-root": {
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
},
"& [class$='-MuiFilledInput-root']": {
padding: "30px 12px 8px",
},
"& .MuiFilledInput-root:after": {
borderBottomColor: theme.palette.secondary.main,
},
}));
export const CrowdfundTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
letterSpacing: "1px",
fontWeight: 400,
fontSize: "20px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
}));
export const CrowdfundSubTitleRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
flexDirection: "row",
});
export const CrowdfundSubTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
letterSpacing: "1px",
fontWeight: 400,
fontSize: "17px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
borderBottom: `1px solid ${theme.palette.text.primary}`,
paddingBottom: "1.5px",
width: "fit-content",
textDecoration: "none",
}));
export const CrowdfundDescription = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "16px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
}));
export const Spacer = ({ height }: any) => {
return (
<Box
sx={{
height: height,
}}
/>
);
};
export const StyledCardHeaderComment = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: "5px",
padding: "7px 0px",
});
export const StyledCardCol = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const StyledCardColComment = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const AuthorTextComment = styled(Typography)({
fontFamily: "Raleway, sans-serif",
fontSize: "16px",
lineHeight: "1.2",
});
export const AddLogoIcon = styled(AddPhotoAlternateIcon)(({ theme }) => ({
color: "#fff",
height: "25px",
width: "auto",
}));
export const CoverImagePreview = styled("img")(({ theme }) => ({
width: "100px",
height: "100px",
objectFit: "contain",
userSelect: "none",
borderRadius: "3px",
marginBottom: "10px",
}));
export const LogoPreviewRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "10px",
}));
export const TimesIcon = styled(TimesSVG)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
borderRadius: "50%",
padding: "5px",
transition: "all 0.2s ease-in-out",
"&:hover": {
cursor: "pointer",
scale: "1.1",
},
}));
export const CrowdfundCardTitle = styled(DoubleLine)(({ theme }) => ({
fontFamily: "Montserrat",
fontSize: "24px",
letterSpacing: "-0.3px",
userSelect: "none",
marginBottom: "auto",
textAlign: "center",
"@media (max-width: 650px)": {
fontSize: "18px",
},
}));
export const CrowdfundUploadDate = styled(Typography)(({ theme }) => ({
fontFamily: "Montserrat",
fontSize: "12px",
letterSpacing: "0.2px",
color: theme.palette.text.primary,
userSelect: "none",
}));
export const CATContainer = styled(Box)(({ theme }) => ({
position: "relative",
display: "flex",
padding: "15px",
flexDirection: "column",
gap: "20px",
justifyContent: "center",
width: "100%",
alignItems: "center",
}));
export const AddCrowdFundButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
textTransform: "none",
padding: "10px 25px",
fontSize: "15px",
gap: "8px",
color: "#ffffff",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.main : "#2a9a86",
border: "none",
borderRadius: "5px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.dark : "#217e6d",
},
}));
export const EditCrowdFundButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
textTransform: "none",
padding: "5px 12px",
gap: "8px",
color: "#ffffff",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.main : "#2a9a86",
border: "none",
borderRadius: "5px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.dark : "#217e6d",
},
}));
export const CrowdfundListWrapper = styled(Box)(({ theme }) => ({
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
marginTop: "0px",
background: theme.palette.background.default,
}));
export const CrowdfundTitleRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
gap: "10px",
}));
export const CrowdfundPageTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontSize: "35px",
fontWeight: 400,
letterSpacing: "1px",
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundStatusRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "Mulish",
fontSize: "21px",
fontWeight: 400,
letterSpacing: 0,
border: `1px solid ${theme.palette.text.primary}`,
borderRadius: "8px",
padding: "15px 25px",
userSelect: "none",
}));
export const CrowdfundDescriptionRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontFamily: "Montserrat",
fontSize: "18px",
fontWeight: 400,
letterSpacing: 0,
});
export const AboutMyCrowdfund = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontSize: "23px",
fontWeight: 400,
letterSpacing: "1px",
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundInlineContentRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
});
export const CrowdfundInlineContent = styled(Box)(({ theme }) => ({
display: "flex",
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: 0,
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundAccordion = styled(Accordion)(({ theme }) => ({
backgroundColor: theme.palette.primary.light,
"& .Mui-expanded": {
minHeight: "auto !important",
},
}));
export const CrowdfundAccordionSummary = styled(AccordionSummary)({
height: "50px",
"& .Mui-expanded": {
margin: "0px !important",
},
});
export const CrowdfundAccordionFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "20px",
fontWeight: 400,
letterSpacing: "0px",
color: theme.palette.text.primary,
userSelect: "none",
}));
export const CrowdfundAccordionDetails = styled(AccordionDetails)({
padding: "0px 16px 16px 16px",
});
export const AddCoverImageButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "16px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
}));
export const CoverImage = styled("img")({
width: "100%",
height: "250px",
objectFit: "cover",
objectPosition: "center",
});
export const CrowdfundActionButtonRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
});
export const CrowdfundActionButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "16px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
}));
export const BackToHomeButton = styled(Button)(({ theme }) => ({
position: "absolute",
top: "20px",
left: "20px",
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "13px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
padding: "5px 10px",
backgroundColor: theme.palette.secondary.main,
transition: "all 0.3s ease-in-out",
"&:hover": {
backgroundColor: theme.palette.secondary.dark,
cursor: "pointer",
},
}));
export const CrowdfundLoaderRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
padding: "10px",
});
export const RatingContainer = styled(Box)({
display: "flex",
alignItems: "center",
padding: "1px 5px",
borderRadius: "5px",
backgroundColor: "transparent",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor: "#e4ddddac",
},
});
export const StyledRating = styled(Rating)({
fontSize: "28px",
});
export const NoReviewsFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontWeight: 400,
letterSpacing: 0,
color: theme.palette.text.primary,
}));
export const StyledButton = styled(Button)(({ theme }) => ({
fontWeight: 600,
color: theme.palette.text.primary
}))
export const CustomSelect = styled(Select)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.default,
'& .MuiSelect-select': {
padding: '12px',
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
borderRadius: theme.shape.borderRadius, // Match border radius
},
'&:before': {
// Underline style
borderBottomColor: theme.palette.mode === "light" ? "#B2BAC2" : "#c9cccf",
},
'&:after': {
// Underline style when focused
borderBottomColor: theme.palette.secondary.main,
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: "#E0E3E7",
},
'&:hover fieldset': {
borderColor: "#B2BAC2",
},
'&.Mui-focused fieldset': {
borderColor: "#6F7E8C",
},
},
'& .MuiInputBase-root': {
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
},
}));

210
src/components/PlaylistListEdit/PlaylistListEdit.tsx

@ -0,0 +1,210 @@
import React, { useState } from "react";
import { CardContentContainerComment } from "../common/Comments/Comments-styles";
import {
CrowdfundSubTitle,
CrowdfundSubTitleRow,
} from "../UploadVideo/Upload-styles";
import { Box, Button, Input, Typography, useTheme } from "@mui/material";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { removeVideo } from "../../state/features/videoSlice";
import AddIcon from '@mui/icons-material/Add';
import { QTUBE_VIDEO_BASE } from "../../constants";
import { useSelector } from "react-redux";
import { RootState } from "../../state/store";
export const PlaylistListEdit = ({ playlistData, removeVideo, addVideo }) => {
const theme = useTheme();
const navigate = useNavigate();
const username = useSelector((state: RootState) => state.auth?.user?.name);
const [searchResults, setSearchResults] = useState([])
const [filterSearch, setFilterSearch] = useState("")
const search = async()=> {
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&mode=ALL&identifier=${QTUBE_VIDEO_BASE}&title=${filterSearch}&limit=20&includemetadata=true&reverse=true&name=${username}&exactmatchnames=true&offset=0`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseDataSearchVid = await response.json()
setSearchResults(responseDataSearchVid)
}
return (
<Box sx={{
display: 'flex',
gap: '10px',
width: '100%',
justifyContent: 'center'
}}>
<Box
sx={{
display: "flex",
flexDirection: "column",
maxWidth: "300px",
width: "100%",
}}
>
<CrowdfundSubTitleRow>
<CrowdfundSubTitle>Playlist</CrowdfundSubTitle>
</CrowdfundSubTitleRow>
<CardContentContainerComment
sx={{
marginTop: "25px",
height: "450px",
overflow: 'auto'
}}
>
{playlistData?.videos?.map((vid, index) => {
return (
<Box
key={vid?.identifier}
sx={{
display: "flex",
gap: "10px",
width: "100%",
alignItems: "center",
padding: "10px",
borderRadius: "5px",
userSelect: "none",
}}
>
<Typography
sx={{
fontSize: "14px",
}}
>
{index + 1}
</Typography>
<Typography
sx={{
fontSize: "18px",
wordBreak: 'break-word'
}}
>
{vid?.metadata?.title}
</Typography>
<DeleteOutlineIcon
onClick={() => {
removeVideo(index);
}}
sx={{
cursor: "pointer",
}}
/>
</Box>
);
})}
</CardContentContainerComment>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
maxWidth: "300px",
width: "100%",
}}
>
<CrowdfundSubTitleRow>
<CrowdfundSubTitle>Add videos to playlist</CrowdfundSubTitle>
</CrowdfundSubTitleRow>
<CardContentContainerComment
sx={{
marginTop: "25px",
height: "450px",
overflow: 'auto'
}}
>
<Box sx={{
display: 'flex',
gap: '10px'
}}>
<Input
id="standard-adornment-name"
onChange={(e) => {
setFilterSearch(e.target.value);
}}
value={filterSearch}
placeholder="Search by title"
sx={{
borderBottom: "1px solid white",
"&&:before": {
borderBottom: "none",
},
"&&:after": {
borderBottom: "none",
},
"&&:hover:before": {
borderBottom: "none",
},
"&&.Mui-focused:before": {
borderBottom: "none",
},
"&&.Mui-focused": {
outline: "none",
},
fontSize: "18px",
}}
/>
<Button
onClick={() => {
search();
}}
variant="contained"
>
Search
</Button>
</Box>
{searchResults?.map((vid, index) => {
return (
<Box
key={vid?.identifier}
sx={{
display: "flex",
gap: "10px",
width: "100%",
alignItems: "center",
padding: "10px",
borderRadius: "5px",
userSelect: "none",
}}
>
<Typography
sx={{
fontSize: "14px",
}}
>
{index + 1}
</Typography>
<Typography
sx={{
fontSize: "18px",
wordBreak: 'break-word'
}}
>
{vid?.metadata?.title}
</Typography>
<AddIcon
onClick={() => {
addVideo(vid);
}}
sx={{
cursor: "pointer",
}}
/>
</Box>
);
})}
</CardContentContainerComment>
</Box>
</Box>
);
};

66
src/components/Playlists/Playlists.tsx

@ -0,0 +1,66 @@
import React from 'react'
import { CardContentContainerComment } from '../common/Comments/Comments-styles'
import { CrowdfundSubTitle, CrowdfundSubTitleRow } from '../UploadVideo/Upload-styles'
import { Box, Typography, useTheme } from '@mui/material'
import { useNavigate } from 'react-router-dom'
export const Playlists = ({playlistData, currentVideoIdentifier}) => {
const theme = useTheme();
const navigate = useNavigate()
return (
<Box sx={{
display: 'flex',
flexDirection: 'column',
maxWidth: '400px',
width: '100%'
}}>
<CrowdfundSubTitleRow >
<CrowdfundSubTitle>Playlist</CrowdfundSubTitle>
</CrowdfundSubTitleRow>
<CardContentContainerComment sx={{
marginTop: '25px',
height: '450px',
overflow: 'auto'
}}>
{playlistData?.videos?.map((vid, index)=> {
const isCurrentVidPlayling = vid?.identifier === currentVideoIdentifier;
return (
<Box key={vid?.identifier} sx={{
display: 'flex',
gap: '10px',
width: '100%',
background: isCurrentVidPlayling && theme.palette.primary.main,
alignItems: 'center',
padding: '10px',
borderRadius: '5px',
cursor: isCurrentVidPlayling ? 'default' : 'pointer',
userSelect: 'none'
}}
onClick={()=> {
if(isCurrentVidPlayling) return
navigate(`/video/${vid.name}/${vid.identifier}`)
}}
>
<Typography sx={{
fontSize: '14px'
}}>{index + 1}</Typography>
<Typography sx={{
fontSize: '18px',
wordBreak: 'break-word'
}}>{vid?.metadata?.title}</Typography>
</Box>
)
})}
</CardContentContainerComment>
</Box>
)
}

109
src/components/ResponsiveImage.tsx

@ -0,0 +1,109 @@
import React, { useState, useEffect, CSSProperties } from 'react'
import Skeleton from '@mui/material/Skeleton'
import { Box } from '@mui/material'
interface ResponsiveImageProps {
src: string
width: number
height: number
alt?: string
className?: string
style?: CSSProperties
}
const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
src,
width,
height,
alt,
className,
style
}) => {
const [loading, setLoading] = useState(true)
const aspectRatio = (height / width) * 100
const imageStyle: CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'cover'
}
const wrapperStyle: CSSProperties = {
position: 'relative',
paddingBottom: `${aspectRatio}%`,
overflow: 'hidden',
...style
}
return (
<Box
sx={{
padding: '2px',
maxHeight: '50%'
}}
>
{loading && (
<Skeleton
variant="rectangular"
style={{
width: '100%',
height: 0,
paddingBottom: `${(height / width) * 100}%`,
objectFit: 'contain',
visibility: loading ? 'visible' : 'hidden',
borderRadius: '8px'
}}
/>
)}
<img
onLoad={() => setLoading(false)}
src={src}
style={{
width: '100%',
height: '100%',
borderRadius: '8px',
visibility: loading ? 'hidden' : 'visible',
position: loading ? 'absolute' : 'unset',
objectFit: 'contain'
}}
/>
</Box>
)
return (
<div style={wrapperStyle} className={className}>
{loading ? (
<Skeleton
variant="rectangular"
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0
}}
/>
) : (
<img
src={src}
alt={alt}
style={{
...imageStyle,
position: 'absolute',
top: 0,
left: 0
}}
/>
)}
</div>
)
}
export default ResponsiveImage

587
src/components/UploadVideo/Upload-styles.tsx

@ -0,0 +1,587 @@
import { styled } from "@mui/system";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Grid,
Rating,
TextField,
Typography,
Select
} from "@mui/material";
import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate";
import { TimesSVG } from "../../assets/svgs/TimesSVG";
export const DoubleLine = styled(Typography)`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
`;
export const MainContainer = styled(Grid)({
width: "100%",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
margin: 0,
});
export const MainCol = styled(Grid)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
padding: "20px",
}));
export const CreateContainer = styled(Box)(({ theme }) => ({
position: "fixed",
bottom: "20px",
right: "20px",
cursor: "pointer",
background: theme.palette.background.default,
width: "50px",
height: "50px",
display: "flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "50%",
}));
export const ModalBody = styled(Box)(({ theme }) => ({
position: "absolute",
backgroundColor: theme.palette.background.default,
borderRadius: "4px",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "75%",
maxWidth: "900px",
padding: "15px 35px",
display: "flex",
flexDirection: "column",
gap: "17px",
overflowY: "auto",
maxHeight: "95vh",
boxShadow:
theme.palette.mode === "dark"
? "0px 4px 5px 0px hsla(0,0%,0%,0.14), 0px 1px 10px 0px hsla(0,0%,0%,0.12), 0px 2px 4px -1px hsla(0,0%,0%,0.2)"
: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px",
"&::-webkit-scrollbar-track": {
backgroundColor: theme.palette.background.paper,
},
"&::-webkit-scrollbar-track:hover": {
backgroundColor: theme.palette.background.paper,
},
"&::-webkit-scrollbar": {
width: "16px",
height: "10px",
backgroundColor: theme.palette.mode === "light" ? "#f6f8fa" : "#292d3e",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.mode === "light" ? "#d3d9e1" : "#575757",
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
},
"&::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.mode === "light" ? "#b7bcc4" : "#474646",
},
}));
export const NewCrowdfundTitle = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "25px",
userSelect: "none",
}));
export const NewCrowdFundFont = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "18px",
userSelect: "none",
}));
export const NewCrowdfundTimeDescription = styled(Typography)(({ theme }) => ({
fontWeight: 400,
fontFamily: "Raleway",
fontSize: "18px",
userSelect: "none",
fontStyle: "italic",
textDecoration: "underline",
}));
export const CustomInputField = styled(TextField)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.default,
borderColor: theme.palette.background.paper,
"& label": {
color: theme.palette.mode === "light" ? "#808183" : "#edeef0",
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
},
"& label.Mui-focused": {
color: theme.palette.mode === "light" ? "#A0AAB4" : "#d7d8da",
},
"& .MuiInput-underline:after": {
borderBottomColor: theme.palette.mode === "light" ? "#B2BAC2" : "#c9cccf",
},
"& .MuiOutlinedInput-root": {
"& fieldset": {
borderColor: "#E0E3E7",
},
"&:hover fieldset": {
borderColor: "#B2BAC2",
},
"&.Mui-focused fieldset": {
borderColor: "#6F7E8C",
},
},
"& .MuiInputBase-root": {
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
},
"& [class$='-MuiFilledInput-root']": {
padding: "30px 12px 8px",
},
"& .MuiFilledInput-root:after": {
borderBottomColor: theme.palette.secondary.main,
},
}));
export const CrowdfundTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
letterSpacing: "1px",
fontWeight: 400,
fontSize: "20px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
}));
export const CrowdfundSubTitleRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
flexDirection: "row",
});
export const CrowdfundSubTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
letterSpacing: "1px",
fontWeight: 400,
fontSize: "17px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
borderBottom: `1px solid ${theme.palette.text.primary}`,
paddingBottom: "1.5px",
width: "fit-content",
textDecoration: "none",
}));
export const CrowdfundDescription = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "16px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word",
}));
export const Spacer = ({ height }: any) => {
return (
<Box
sx={{
height: height,
}}
/>
);
};
export const StyledCardHeaderComment = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: "5px",
padding: "7px 0px",
});
export const StyledCardCol = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const StyledCardColComment = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const AuthorTextComment = styled(Typography)({
fontFamily: "Raleway, sans-serif",
fontSize: "16px",
lineHeight: "1.2",
});
export const AddLogoIcon = styled(AddPhotoAlternateIcon)(({ theme }) => ({
color: "#fff",
height: "25px",
width: "auto",
}));
export const CoverImagePreview = styled("img")(({ theme }) => ({
width: "100px",
height: "100px",
objectFit: "contain",
userSelect: "none",
borderRadius: "3px",
marginBottom: "10px",
}));
export const LogoPreviewRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "10px",
}));
export const TimesIcon = styled(TimesSVG)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
borderRadius: "50%",
padding: "5px",
transition: "all 0.2s ease-in-out",
"&:hover": {
cursor: "pointer",
scale: "1.1",
},
}));
export const CrowdfundCardTitle = styled(DoubleLine)(({ theme }) => ({
fontFamily: "Montserrat",
fontSize: "24px",
letterSpacing: "-0.3px",
userSelect: "none",
marginBottom: "auto",
textAlign: "center",
"@media (max-width: 650px)": {
fontSize: "18px",
},
}));
export const CrowdfundUploadDate = styled(Typography)(({ theme }) => ({
fontFamily: "Montserrat",
fontSize: "12px",
letterSpacing: "0.2px",
color: theme.palette.text.primary,
userSelect: "none",
}));
export const CATContainer = styled(Box)(({ theme }) => ({
position: "relative",
display: "flex",
padding: "15px",
flexDirection: "column",
gap: "20px",
justifyContent: "center",
width: "100%",
alignItems: "center",
}));
export const AddCrowdFundButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
textTransform: "none",
padding: "10px 25px",
fontSize: "15px",
gap: "8px",
color: "#ffffff",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.main : "#2a9a86",
border: "none",
borderRadius: "5px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.dark : "#217e6d",
},
}));
export const EditCrowdFundButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
textTransform: "none",
padding: "5px 12px",
gap: "8px",
color: "#ffffff",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.main : "#2a9a86",
border: "none",
borderRadius: "5px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor:
theme.palette.mode === "dark" ? theme.palette.primary.dark : "#217e6d",
},
}));
export const CrowdfundListWrapper = styled(Box)(({ theme }) => ({
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
marginTop: "0px",
background: theme.palette.background.default,
}));
export const CrowdfundTitleRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
gap: "10px",
}));
export const CrowdfundPageTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontSize: "35px",
fontWeight: 400,
letterSpacing: "1px",
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundStatusRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "Mulish",
fontSize: "21px",
fontWeight: 400,
letterSpacing: 0,
border: `1px solid ${theme.palette.text.primary}`,
borderRadius: "8px",
padding: "15px 25px",
userSelect: "none",
}));
export const CrowdfundDescriptionRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontFamily: "Montserrat",
fontSize: "18px",
fontWeight: 400,
letterSpacing: 0,
});
export const AboutMyCrowdfund = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontSize: "23px",
fontWeight: 400,
letterSpacing: "1px",
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundInlineContentRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
});
export const CrowdfundInlineContent = styled(Box)(({ theme }) => ({
display: "flex",
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: 0,
userSelect: "none",
color: theme.palette.text.primary,
}));
export const CrowdfundAccordion = styled(Accordion)(({ theme }) => ({
backgroundColor: theme.palette.primary.light,
"& .Mui-expanded": {
minHeight: "auto !important",
},
}));
export const CrowdfundAccordionSummary = styled(AccordionSummary)({
height: "50px",
"& .Mui-expanded": {
margin: "0px !important",
},
});
export const CrowdfundAccordionFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "20px",
fontWeight: 400,
letterSpacing: "0px",
color: theme.palette.text.primary,
userSelect: "none",
}));
export const CrowdfundAccordionDetails = styled(AccordionDetails)({
padding: "0px 16px 16px 16px",
});
export const AddCoverImageButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "16px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
}));
export const CoverImage = styled("img")({
width: "100%",
height: "250px",
objectFit: "cover",
objectPosition: "center",
});
export const CrowdfundActionButtonRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
});
export const CrowdfundActionButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "16px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
}));
export const BackToHomeButton = styled(Button)(({ theme }) => ({
position: "absolute",
top: "20px",
left: "20px",
display: "flex",
alignItems: "center",
fontFamily: "Montserrat",
fontSize: "13px",
fontWeight: 400,
letterSpacing: "0.2px",
color: "white",
gap: "5px",
padding: "5px 10px",
backgroundColor: theme.palette.secondary.main,
transition: "all 0.3s ease-in-out",
"&:hover": {
backgroundColor: theme.palette.secondary.dark,
cursor: "pointer",
},
}));
export const CrowdfundLoaderRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
padding: "10px",
});
export const RatingContainer = styled(Box)({
display: "flex",
alignItems: "center",
padding: "1px 5px",
borderRadius: "5px",
backgroundColor: "transparent",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor: "#e4ddddac",
},
});
export const StyledRating = styled(Rating)({
fontSize: "28px",
});
export const NoReviewsFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontWeight: 400,
letterSpacing: 0,
color: theme.palette.text.primary,
}));
export const StyledButton = styled(Button)(({ theme }) => ({
fontWeight: 600,
color: theme.palette.text.primary,
fontFamily: "Cairo"
}))
export const CustomSelect = styled(Select)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.default,
'& .MuiSelect-select': {
padding: '12px',
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
borderRadius: theme.shape.borderRadius, // Match border radius
},
'&:before': {
// Underline style
borderBottomColor: theme.palette.mode === "light" ? "#B2BAC2" : "#c9cccf",
},
'&:after': {
// Underline style when focused
borderBottomColor: theme.palette.secondary.main,
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: "#E0E3E7",
},
'&:hover fieldset': {
borderColor: "#B2BAC2",
},
'&.Mui-focused fieldset': {
borderColor: "#6F7E8C",
},
},
'& .MuiInputBase-root': {
fontFamily: "Mulish",
fontSize: "19px",
letterSpacing: "0px",
fontWeight: 400,
color: theme.palette.text.primary,
},
}));

687
src/components/UploadVideo/UploadVideo.tsx

@ -0,0 +1,687 @@
import React, { useEffect, useState } from "react";
import {
AddCoverImageButton,
AddLogoIcon,
CoverImagePreview,
CrowdfundActionButton,
CrowdfundActionButtonRow,
CustomInputField,
CustomSelect,
LogoPreviewRow,
ModalBody,
NewCrowdfundTitle,
StyledButton,
TimesIcon,
} from "./Upload-styles";
import {
Box,
Button,
FormControl,
Input,
InputLabel,
MenuItem,
Modal,
OutlinedInput,
Select,
SelectChangeEvent,
Typography,
useTheme,
} from "@mui/material";
import RemoveIcon from "@mui/icons-material/Remove";
import ShortUniqueId from "short-unique-id";
import { useDispatch, useSelector } from "react-redux";
import AddBoxIcon from "@mui/icons-material/AddBox";
import { useDropzone } from "react-dropzone";
import AddIcon from "@mui/icons-material/Add";
import { setNotification } from "../../state/features/notificationsSlice";
import { objectToBase64, uint8ArrayToBase64 } from "../../utils/toBase64";
import { RootState } from "../../state/store";
import {
upsertVideosBeginning,
addToHashMap,
upsertVideos,
} from "../../state/features/videoSlice";
import ImageUploader from "../common/ImageUploader";
import {
QTUBE_PLAYLIST_BASE,
QTUBE_VIDEO_BASE,
categories,
subCategories,
subCategories2,
subCategories3,
} from "../../constants";
import { MultiplePublish } from "../common/MultiplePublish/MultiplePublish";
import {
CrowdfundSubTitle,
CrowdfundSubTitleRow,
} from "../EditPlaylist/Upload-styles";
import { CardContentContainerComment } from "../common/Comments/Comments-styles";
import { TextEditor } from "../common/TextEditor/TextEditor";
import { extractTextFromHTML } from "../common/TextEditor/utils";
const uid = new ShortUniqueId();
const shortuid = new ShortUniqueId({ length: 5 });
interface NewCrowdfundProps {
editId?: string;
editContent?: null | {
title: string;
user: string;
coverImage: string | null;
};
}
interface VideoFile {
file: File;
title: string;
description: string;
coverImage?: string;
}
export const UploadVideo = ({ editId, editContent }: NewCrowdfundProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const [isOpenMultiplePublish, setIsOpenMultiplePublish] = useState(false);
const username = useSelector((state: RootState) => state.auth?.user?.name);
const userAddress = useSelector(
(state: RootState) => state.auth?.user?.address
);
const [files, setFiles] = useState<VideoFile[]>([]);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [step, setStep] = useState<string>("videos");
const [playlistCoverImage, setPlaylistCoverImage] = useState<null | string>(
null
);
const [selectExistingPlaylist, setSelectExistingPlaylist] =
useState<any>(null);
const [playlistTitle, setPlaylistTitle] = useState<string>("");
const [playlistDescription, setPlaylistDescription] = useState<string>("");
const [selectedCategory, setSelectedCategory] = useState<any>(null);
const [selectedSubCategory, setSelectedSubCategory] = useState<any>(null);
const [selectedCategoryVideos, setSelectedCategoryVideos] =
useState<any>(null);
const [selectedSubCategoryVideos, setSelectedSubCategoryVideos] =
useState<any>(null);
const [selectedSubCategoryVideos2, setSelectedSubCategoryVideos2] =
useState<any>(null);
const [selectedSubCategoryVideos3, setSelectedSubCategoryVideos3] =
useState<any>(null);
const [playlistSetting, setPlaylistSetting] = useState<null | string>(null);
const [publishes, setPublishes] = useState<any[]>([]);
const { getRootProps, getInputProps } = useDropzone({
maxFiles: 10,
maxSize: 419430400, // 400 MB in bytes
onDrop: (acceptedFiles, rejectedFiles) => {
const formatArray = acceptedFiles.map((item) => {
return {
file: item,
title: "",
description: "",
coverImage: "",
};
});
setFiles((prev) => [...prev, ...formatArray]);
let errorString = null;
rejectedFiles.forEach(({ file, errors }) => {
errors.forEach((error) => {
if (error.code === "file-too-large") {
errorString = "File must be under 400mb";
}
console.log(`Error with file ${file.name}: ${error.message}`);
});
});
if (errorString) {
const notificationObj = {
msg: errorString,
alertType: "error",
};
dispatch(setNotification(notificationObj));
}
},
});
useEffect(() => {
if (editContent) {
}
}, [editContent]);
const onClose = () => {
setIsOpen(false);
};
async function publishQDNResource() {
try {
if (!userAddress) throw new Error("Unable to locate user address");
if (!title) throw new Error("Please enter a title");
if (!description) throw new Error("Please enter a description");
if (!selectedCategoryVideos) throw new Error("Please select a category");
if(files.length === 0) throw new Error("Add at least one file");
let errorMsg = "";
let name = "";
if (username) {
name = username;
}
if (!name) {
errorMsg =
"Cannot publish without access to your name. Please authenticate.";
}
if (editId && editContent?.user !== name) {
errorMsg = "Cannot publish another user's resource";
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: "error",
})
);
return;
}
let fileReferences = []
let listOfPublishes = [];
const fullDescription = extractTextFromHTML(description);
const category = selectedCategoryVideos.id;
const subcategory = selectedSubCategoryVideos?.id || "";
const subcategory2 = selectedSubCategoryVideos2?.id || "";
const subcategory3 = selectedSubCategoryVideos3?.id || "";
const sanitizeTitle = title
.replace(/[^a-zA-Z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim()
.toLowerCase();
for (const publish of files) {
const file = publish.file;
const id = uid();
const identifier = `${QTUBE_VIDEO_BASE}${sanitizeTitle.slice(0, 30)}_${id}`;
let fileExtension = "";
const fileExtensionSplit = file?.name?.split(".");
if (fileExtensionSplit?.length > 1) {
fileExtension = fileExtensionSplit?.pop() || "";
}
let firstPartName = fileExtensionSplit[0]
let filename = firstPartName.slice(0, 15);
// Step 1: Replace all white spaces with underscores
// Replace all forms of whitespace (including non-standard ones) with underscores
let stringWithUnderscores = filename.replace(/[\s\uFEFF\xA0]+/g, "_");
// Remove all non-alphanumeric characters (except underscores)
let alphanumericString = stringWithUnderscores.replace(
/[^a-zA-Z0-9_]/g,
""
);
if(fileExtension){
filename = `${alphanumericString.trim()}.${fileExtension}`
} else {
filename = alphanumericString
}
let metadescription =
`**cat:${category};sub:${subcategory};sub2:${subcategory2};sub3:${subcategory3}**` +
fullDescription.slice(0, 150);
const requestBodyVideo: any = {
action: "PUBLISH_QDN_RESOURCE",
name: name,
service: "FILE",
file,
title: title.slice(0, 50),
description: metadescription,
identifier,
filename,
tag1: QTUBE_VIDEO_BASE,
};
listOfPublishes.push(requestBodyVideo);
fileReferences.push({
filename: file.name,
identifier,
name,
service: 'FILE',
mimetype: file.type,
size: file.size
})
}
const idMeta = uid();
const identifier = `${QTUBE_VIDEO_BASE}${sanitizeTitle.slice(0, 30)}_${idMeta}`;
const fileObject: any = {
title,
version: 1,
fullDescription,
htmlDescription: description,
commentsId: `${QTUBE_VIDEO_BASE}_cm_${idMeta}`,
category,
subcategory,
subcategory2,
subcategory3,
files: fileReferences
};
let metadescription =
`**cat:${category};sub:${subcategory};sub2:${subcategory2}**` +
fullDescription.slice(0, 150);
const crowdfundObjectToBase64 = await objectToBase64(fileObject);
// Description is obtained from raw data
const requestBodyJson: any = {
action: "PUBLISH_QDN_RESOURCE",
name: name,
service: "DOCUMENT",
data64: crowdfundObjectToBase64,
title: title.slice(0, 50),
description: metadescription,
identifier: identifier + "_metadata",
tag1: QTUBE_VIDEO_BASE,
filename: `video_metadata.json`,
};
listOfPublishes.push(requestBodyJson);
setPublishes(listOfPublishes);
setIsOpenMultiplePublish(true);
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to publish share",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to publish share",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to publish share",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
}
}
const handleOptionCategoryChangeVideos = (
event: SelectChangeEvent<string>
) => {
const optionId = event.target.value;
const selectedOption = categories.find((option) => option.id === +optionId);
setSelectedCategoryVideos(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos2 = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos2(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos3 = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos3(selectedOption || null);
};
return (
<>
{username && (
<>
{editId ? null : (
<StyledButton
color="primary"
startIcon={<AddBoxIcon />}
onClick={() => {
setIsOpen(true);
}}
>
share
</StyledButton>
)}
</>
)}
<Modal
open={isOpen}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<ModalBody>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<NewCrowdfundTitle>Share</NewCrowdfundTitle>
</Box>
{step === "videos" && (
<>
<Box
{...getRootProps()}
sx={{
border: "1px dashed gray",
padding: 2,
textAlign: "center",
marginBottom: 2,
cursor: "pointer",
}}
>
<input {...getInputProps()} />
<Typography>
Drag and drop files here or click to select files
</Typography>
</Box>
{files.map((file, index) => {
return (
<React.Fragment key={index}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography>{file?.file?.name}</Typography>
<RemoveIcon
onClick={() => {
setFiles((prev) => {
const copyPrev = [...prev];
copyPrev.splice(index, 1);
return copyPrev;
});
}}
sx={{
cursor: "pointer",
}}
/>
</Box>
</React.Fragment>
);
})}
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "flex-start",
}}
>
{files?.length > 0 && (
<>
<Box sx={{
display: 'flex',
flexDirection: 'column',
gap: '20px',
width: '50%'
}}>
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedCategoryVideos?.id || ""}
onChange={handleOptionCategoryChangeVideos}
>
{categories.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{selectedCategoryVideos && (
<>
<Box sx={{
display: 'flex',
flexDirection: 'column',
gap: '20px',
width: '50%'
}}>
{selectedCategoryVideos &&
subCategories[selectedCategoryVideos?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">
Select a Sub-Category
</InputLabel>
<Select
labelId="Sub-Category"
input={
<OutlinedInput label="Select a Sub-Category" />
}
value={selectedSubCategoryVideos?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos(
e,
subCategories[selectedCategoryVideos?.id]
)
}
>
{subCategories[selectedCategoryVideos.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
{selectedSubCategoryVideos &&
subCategories2[selectedSubCategoryVideos?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">
Select a Sub-sub-Category
</InputLabel>
<Select
labelId="Sub-Category"
input={
<OutlinedInput label="Select a Sub-sub-Category" />
}
value={selectedSubCategoryVideos2?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos2(
e,
subCategories2[selectedSubCategoryVideos?.id]
)
}
>
{subCategories2[selectedSubCategoryVideos.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
{selectedSubCategoryVideos2 &&
subCategories3[selectedSubCategoryVideos2?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">
Select a Sub-3x-subCategory
</InputLabel>
<Select
labelId="Sub-Category"
input={
<OutlinedInput label="Select a Sub-3x-Category" />
}
value={selectedSubCategoryVideos3?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos3(
e,
subCategories3[selectedSubCategoryVideos2?.id]
)
}
>
{subCategories3[selectedSubCategoryVideos2.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
</Box>
</>
)}
</>
)}
</Box>
{files?.length > 0 && (
<>
<CustomInputField
name="title"
label="Title of share"
variant="filled"
value={title}
onChange={(e) => {
const value = e.target.value;
const formattedValue = value.replace(
/[^a-zA-Z0-9\s-_!?]/g,
""
);
setTitle(formattedValue);
}}
inputProps={{ maxLength: 180 }}
required
/>
<Typography
sx={{
fontSize: "18px",
}}
>
Description of share
</Typography>
<TextEditor
inlineContent={description}
setInlineContent={(value) => {
setDescription(value);
}}
/>
</>
)}
</>
)}
<CrowdfundActionButtonRow>
<CrowdfundActionButton
onClick={() => {
onClose();
}}
variant="contained"
color="error"
>
Cancel
</CrowdfundActionButton>
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
}}
>
<CrowdfundActionButton
variant="contained"
onClick={() => {
publishQDNResource();
}}
>
Publish
</CrowdfundActionButton>
</Box>
</CrowdfundActionButtonRow>
</ModalBody>
</Modal>
{isOpenMultiplePublish && (
<MultiplePublish
isOpen={isOpenMultiplePublish}
onSubmit={() => {
setIsOpenMultiplePublish(false);
setIsOpen(false);
setFiles([]);
setStep("videos");
setPlaylistCoverImage(null);
setPlaylistTitle("");
setPlaylistDescription("");
setSelectedCategory(null);
setSelectedSubCategory(null);
setSelectedCategoryVideos(null);
setSelectedSubCategoryVideos(null);
setPlaylistSetting(null);
dispatch(
setNotification({
msg: "Videos published",
alertType: "success",
})
);
}}
publishes={publishes}
/>
)}
</>
);
};

28
src/components/common/BlockedNamesModal/BlockedNamesModal-styles.ts

@ -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

@ -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>
);
};

295
src/components/common/Comments/Comment.tsx

@ -0,0 +1,295 @@
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
useTheme,
} from "@mui/material";
import React, { useCallback, useState, useEffect } from "react";
import { CommentEditor } from "./CommentEditor";
import {
CardContentContainerComment,
CommentActionButtonRow,
CommentDateText,
EditReplyButton,
StyledCardComment,
} from "./Comments-styles";
import { StyledCardHeaderComment } from "./Comments-styles";
import { StyledCardColComment } from "./Comments-styles";
import { AuthorTextComment } from "./Comments-styles";
import {
StyledCardContentComment,
LoadMoreCommentsButton as CommentActionButton,
} from "./Comments-styles";
import { useSelector } from "react-redux";
import { RootState } from "../../../state/store";
import Portal from "../Portal";
import { formatDate } from "../../../utils/time";
interface CommentProps {
comment: any;
postId: string;
postName: string;
onSubmit: (obj?: any, isEdit?: boolean) => void;
}
export const Comment = ({
comment,
postId,
postName,
onSubmit,
}: CommentProps) => {
const [isReplying, setIsReplying] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
const { user } = useSelector((state: RootState) => state.auth);
const [currentEdit, setCurrentEdit] = useState<any>(null);
const theme = useTheme();
const handleSubmit = useCallback((comment: any, isEdit?: boolean) => {
onSubmit(comment, isEdit);
setCurrentEdit(null);
setIsReplying(false);
}, []);
return (
<Box
id={comment?.identifier}
sx={{
display: "flex",
width: "100%",
flexDirection: "column",
}}
>
{currentEdit && (
<Portal>
<Dialog
open={!!currentEdit}
onClose={() => setCurrentEdit(null)}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title"></DialogTitle>
<DialogContent>
<Box
sx={{
width: "300px",
display: "flex",
justifyContent: "center",
}}
>
<CommentEditor
onSubmit={obj => handleSubmit(obj, true)}
postId={postId}
postName={postName}
isEdit
commentId={currentEdit?.identifier}
commentMessage={currentEdit?.message}
/>
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setCurrentEdit(null)}>
Close
</Button>
</DialogActions>
</Dialog>
</Portal>
)}
<CommentCard
name={comment?.name}
message={comment?.message}
replies={comment?.replies || []}
setCurrentEdit={setCurrentEdit}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "5px",
marginTop: "20px",
justifyContent: "space-between",
}}
>
{comment?.created && (
<Typography
variant="h6"
sx={{
fontSize: "12px",
marginLeft: "5px",
}}
color={theme.palette.text.primary}
>
{formatDate(+comment?.created)}
</Typography>
)}
<CommentActionButtonRow>
<CommentActionButton
size="small"
variant="contained"
onClick={() => setIsReplying(true)}
>
reply
</CommentActionButton>
{user?.name === comment?.name && (
<CommentActionButton
size="small"
variant="contained"
onClick={() => setCurrentEdit(comment)}
>
edit
</CommentActionButton>
)}
{isReplying && (
<CommentActionButton
size="small"
variant="contained"
onClick={() => {
setIsReplying(false);
setIsEditing(false);
}}
>
close
</CommentActionButton>
)}
</CommentActionButtonRow>
</Box>
</CommentCard>
<Box
sx={{
display: "flex",
width: "100%",
flexDirection: "column",
alignItems: "center",
}}
>
{isReplying && (
<CommentEditor
onSubmit={handleSubmit}
postId={postId}
postName={postName}
isReply
commentId={comment.identifier}
/>
)}
</Box>
</Box>
);
};
const CommentCard = ({
message,
created,
name,
replies,
children,
setCurrentEdit,
}: any) => {
const [avatarUrl, setAvatarUrl] = React.useState<string>("");
const { user } = useSelector((state: RootState) => state.auth);
const theme = useTheme();
const getAvatar = React.useCallback(async (author: string) => {
try {
const url = await qortalRequest({
action: "GET_QDN_RESOURCE_URL",
name: author,
service: "THUMBNAIL",
identifier: "qortal_avatar",
});
setAvatarUrl(url);
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
getAvatar(name);
}, [name]);
return (
<CardContentContainerComment>
<StyledCardHeaderComment
sx={{
"& .MuiCardHeader-content": {
overflow: "hidden",
},
}}
>
<Box>
<Avatar
src={avatarUrl}
alt={`${name}'s avatar`}
sx={{ width: "35px", height: "35px" }}
/>
</Box>
<StyledCardColComment>
<AuthorTextComment>{name}</AuthorTextComment>
</StyledCardColComment>
</StyledCardHeaderComment>
<StyledCardContentComment>
<StyledCardComment>{message}</StyledCardComment>
</StyledCardContentComment>
<Box
sx={{
paddingLeft: "15px",
display: "flex",
flexDirection: "column",
}}
>
{replies?.map((reply: any) => {
return (
<Box
key={reply?.identifier}
id={reply?.identifier}
sx={{
display: "flex",
border: "1px solid grey",
borderRadius: "10px",
marginTop: "8px",
}}
>
<CommentCard
name={reply?.name}
message={reply?.message}
setCurrentEdit={setCurrentEdit}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "5px",
justifyContent: "space-between",
}}
>
{reply?.created && (
<CommentDateText>
{formatDate(+reply?.created)}
</CommentDateText>
)}
{user?.name === reply?.name ? (
<EditReplyButton
size="small"
variant="contained"
onClick={() => setCurrentEdit(reply)}
sx={{}}
>
edit
</EditReplyButton>
) : (
<Box />
)}
</Box>
</CommentCard>
</Box>
);
})}
</Box>
{children}
</CardContentContainerComment>
);
};

254
src/components/common/Comments/CommentEditor.tsx

@ -0,0 +1,254 @@
import { Box, Button, TextField } from "@mui/material";
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../../state/store";
import ShortUniqueId from "short-unique-id";
import { setNotification } from "../../../state/features/notificationsSlice";
import { toBase64 } from "../../../utils/toBase64";
import localforage from "localforage";
import {
CommentInput,
CommentInputContainer,
SubmitCommentButton,
} from "./Comments-styles";
import { COMMENT_BASE } from "../../../constants";
const uid = new ShortUniqueId();
const notification = localforage.createInstance({
name: "notification",
});
const MAX_ITEMS = 10;
export interface Item {
id: string;
lastSeen: number;
postId: string;
postName: string;
}
export async function addItem(item: Item): Promise<void> {
// Get all items
let notificationComments: Item[] =
(await notification.getItem("comments")) || [];
// Find the item with the same id, if it exists
let existingItemIndex = notificationComments.findIndex(i => i.id === item.id);
if (existingItemIndex !== -1) {
// If the item exists, update its date
notificationComments[existingItemIndex].lastSeen = item.lastSeen;
} else {
// If the item doesn't exist, add it
notificationComments.push(item);
// If adding the item has caused us to exceed the max number of items, remove the oldest one
if (notificationComments.length > MAX_ITEMS) {
notificationComments.sort((a, b) => b.lastSeen - a.lastSeen); // sort items by date, newest first
notificationComments.pop(); // remove the oldest item
}
}
// Store the items back into localForage
await notification.setItem("comments", notificationComments);
}
export async function updateItemDate(item: any): Promise<void> {
// Get all items
let notificationComments: Item[] =
(await notification.getItem("comments")) || [];
let notificationCreatorComment: any =
(await notification.getItem("post-comments")) || {};
const findPostId = notificationCreatorComment[item.postId];
if (findPostId) {
notificationCreatorComment[item.postId].lastSeen = item.lastSeen;
}
// Find the item with the same id, if it exists
notificationComments.forEach((nc, index) => {
if (nc.postId === item.postId) {
notificationComments[index].lastSeen = item.lastSeen;
}
});
// Store the items back into localForage
await notification.setItem("comments", notificationComments);
await notification.setItem("post-comments", notificationCreatorComment);
}
interface CommentEditorProps {
postId: string;
postName: string;
onSubmit: (obj: any) => void;
isReply?: boolean;
commentId?: string;
isEdit?: boolean;
commentMessage?: string;
}
function utf8ToBase64(inputString: string): string {
// Encode the string as UTF-8
const utf8String = encodeURIComponent(inputString).replace(
/%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(Number("0x" + p1))
);
// Convert the UTF-8 encoded string to base64
const base64String = btoa(utf8String);
return base64String;
}
export const CommentEditor = ({
onSubmit,
postId,
postName,
isReply,
commentId,
isEdit,
commentMessage,
}: CommentEditorProps) => {
const [value, setValue] = useState<string>("");
const dispatch = useDispatch();
const { user } = useSelector((state: RootState) => state.auth);
useEffect(() => {
if (isEdit && commentMessage) {
setValue(commentMessage);
}
}, [isEdit, commentMessage]);
const publishComment = async (
identifier: string,
idForNotification?: string
) => {
let address;
let name;
let errorMsg = "";
address = user?.address;
name = user?.name || "";
if (!address) {
errorMsg = "Cannot post: your address isn't available";
}
if (!name) {
errorMsg = "Cannot post without a name";
}
if (value.length > 200) {
errorMsg = "Comment needs to be under 200 characters";
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: "error",
})
);
throw new Error(errorMsg);
}
try {
const base64 = utf8ToBase64(value);
const resourceResponse = await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
name: name,
service: "BLOG_COMMENT",
data64: base64,
identifier: identifier,
});
dispatch(
setNotification({
msg: "Comment successfully published",
alertType: "success",
})
);
if (idForNotification) {
addItem({
id: idForNotification,
lastSeen: Date.now(),
postId,
postName: postName,
});
}
return resourceResponse;
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to publish comment",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to publish comment",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to publish comment",
alertType: "error",
};
}
if (!notificationObj) throw new Error("Failed to publish comment");
dispatch(setNotification(notificationObj));
throw new Error("Failed to publish comment");
}
};
const handleSubmit = async () => {
try {
const id = uid();
let identifier = `${COMMENT_BASE}${postId.slice(-12)}_base_${id}`;
let idForNotification = identifier;
if (isReply && commentId) {
const removeBaseCommentId = commentId;
removeBaseCommentId.replace("_base_", "");
identifier = `${COMMENT_BASE}${postId.slice(
-12
)}_reply_${removeBaseCommentId.slice(-6)}_${id}`;
idForNotification = commentId;
}
if (isEdit && commentId) {
identifier = commentId;
}
await publishComment(identifier, idForNotification);
onSubmit({
created: Date.now(),
identifier,
message: value,
service: "BLOG_COMMENT",
name: user?.name,
});
setValue("");
} catch (error) {
console.error(error);
}
};
return (
<CommentInputContainer>
<CommentInput
id="standard-multiline-flexible"
label="Your comment"
multiline
maxRows={4}
variant="filled"
value={value}
inputProps={{
maxLength: 200,
}}
InputLabelProps={{ style: { fontSize: "18px" } }}
onChange={e => setValue(e.target.value)}
/>
<SubmitCommentButton variant="contained" onClick={handleSubmit}>
{isReply ? "Submit reply" : isEdit ? "Edit" : "Submit comment"}
</SubmitCommentButton>
</CommentInputContainer>
);
};

279
src/components/common/Comments/CommentSection.tsx

@ -0,0 +1,279 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CommentEditor } from "./CommentEditor";
import { Comment } from "./Comment";
import { Box, Button, CircularProgress, useTheme } from "@mui/material";
import { styled } from "@mui/system";
import { useSelector } from "react-redux";
import { RootState } from "../../../state/store";
import { useNavigate, useLocation } from "react-router-dom";
import {
CommentContainer,
CommentEditorContainer,
CommentsContainer,
LoadMoreCommentsButton,
LoadMoreCommentsButtonRow,
NoCommentsRow,
} from "./Comments-styles";
import { COMMENT_BASE } from "../../../constants";
import { CrowdfundSubTitle, CrowdfundSubTitleRow } from "../../UploadVideo/Upload-styles";
interface CommentSectionProps {
postId: string;
postName: string;
}
const Panel = styled("div")`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
padding-bottom: 10px;
height: 100%;
overflow: hidden;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #555;
}
`;
export const CommentSection = ({ postId, postName }: CommentSectionProps) => {
const navigate = useNavigate();
const location = useLocation();
const [listComments, setListComments] = useState<any[]>([]);
const [isOpen, setIsOpen] = useState<boolean>(false);
const { user } = useSelector((state: RootState) => state.auth);
const [newMessages, setNewMessages] = useState(0);
const [loadingComments, setLoadingComments] = useState<boolean>(false);
const onSubmit = (obj?: any, isEdit?: boolean) => {
if (isEdit) {
setListComments((prev: any[]) => {
const findCommentIndex = prev.findIndex(
item => item?.identifier === obj?.identifier
);
if (findCommentIndex === -1) return prev;
const newArray = [...prev];
newArray[findCommentIndex] = obj;
return newArray;
});
return;
}
setListComments(prev => [
...prev,
{
...obj,
},
]);
};
useEffect(() => {
const query = new URLSearchParams(location.search);
let commentVar = query?.get("comment");
if (commentVar) {
if (commentVar && commentVar.endsWith("/")) {
commentVar = commentVar.slice(0, -1);
}
setIsOpen(true);
if (listComments.length > 0) {
const el = document.getElementById(commentVar);
if (el) {
el.scrollIntoView();
el.classList.add("glow");
setTimeout(() => {
el.classList.remove("glow");
}, 2000);
}
navigate(location.pathname, { replace: true });
}
}
}, [navigate, location, listComments]);
const getReplies = useCallback(
async (commentId, postId) => {
const offset = 0;
const removeBaseCommentId = commentId.replace("_base_", "");
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=${COMMENT_BASE}${postId.slice(
-12
)}_reply_${removeBaseCommentId.slice(
-6
)}&limit=0&includemetadata=false&offset=${offset}&reverse=false&excludeblocked=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
const comments: any[] = [];
for (const comment of responseData) {
if (comment.identifier && comment.name) {
const url = `/arbitrary/BLOG_COMMENT/${comment.name}/${comment.identifier}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData2 = await response.text();
if (responseData) {
comments.push({
message: responseData2,
...comment,
});
}
}
}
return comments;
},
[postId]
);
const getComments = useCallback(
async (isNewMessages?: boolean, numberOfComments?: number) => {
try {
setLoadingComments(true);
let offset = 0;
if (isNewMessages && numberOfComments) {
offset = numberOfComments;
}
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=${COMMENT_BASE}${postId.slice(
-12
)}_base_&limit=20&includemetadata=false&offset=${offset}&reverse=false&excludeblocked=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
let comments: any[] = [];
for (const comment of responseData) {
if (comment.identifier && comment.name) {
const url = `/arbitrary/BLOG_COMMENT/${comment.name}/${comment.identifier}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData2 = await response.text();
if (responseData) {
comments.push({
message: responseData2,
...comment,
});
}
const res = await getReplies(comment.identifier, postId);
comments = [...comments, ...res];
}
}
if (isNewMessages) {
setListComments(prev => [...prev, ...comments]);
setNewMessages(0);
} else {
setListComments(comments);
}
} catch (error) {
console.error(error);
} finally {
setLoadingComments(false);
}
},
[postId]
);
useEffect(() => {
getComments();
}, [getComments, postId]);
const structuredCommentList = useMemo(() => {
return listComments.reduce((acc, curr, index, array) => {
if (curr?.identifier?.includes("_reply_")) {
return acc;
}
acc.push({
...curr,
replies: array.filter(comment =>
comment.identifier.includes(`_reply_${curr.identifier.slice(-6)}`)
),
});
return acc;
}, []);
}, [listComments]);
return (
<>
<Panel>
<CrowdfundSubTitleRow >
<CrowdfundSubTitle>Comments</CrowdfundSubTitle>
</CrowdfundSubTitleRow>
<CommentsContainer>
{loadingComments ? (
<NoCommentsRow>
<CircularProgress />
</NoCommentsRow>
) : listComments.length === 0 ? (
<NoCommentsRow>
There are no comments yet. Be the first to comment!
</NoCommentsRow>
) : (
<CommentContainer>
{structuredCommentList.map((comment: any) => {
return (
<Comment
key={comment?.identifier}
comment={comment}
onSubmit={onSubmit}
postId={postId}
postName={postName}
/>
);
})}
</CommentContainer>
)}
{listComments.length > 20 && (
<LoadMoreCommentsButtonRow>
<LoadMoreCommentsButton
onClick={() => {
getComments(
true,
listComments.filter(
item => !item.identifier.includes("_reply_")
).length
);
}}
variant="contained"
size="small"
>
Load More Comments
</LoadMoreCommentsButton>
</LoadMoreCommentsButtonRow>
)}
</CommentsContainer>
<CommentEditorContainer>
<CommentEditor
onSubmit={onSubmit}
postId={postId}
postName={postName}
/>
</CommentEditorContainer>
</Panel>
</>
);
};

281
src/components/common/Comments/Comments-styles.tsx

@ -0,0 +1,281 @@
import { styled } from "@mui/system";
import { Card, Box, Typography, Button, TextField } from "@mui/material";
export const StyledCard = styled(Card)(({ theme }) => ({
backgroundColor:
theme.palette.mode === "light"
? theme.palette.primary.main
: theme.palette.primary.dark,
maxWidth: "600px",
width: "100%",
margin: "10px 0px",
cursor: "pointer",
"@media (max-width: 450px)": {
width: "100%;",
},
}));
export const CardContentContainer = styled(Box)(({ theme }) => ({
backgroundColor:
theme.palette.mode === "light"
? theme.palette.primary.dark
: theme.palette.primary.light,
margin: "5px 10px",
borderRadius: "15px",
}));
export const CardContentContainerComment = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.mode === "light" ? "#a9d9d038" : "#c3abe414",
border: `1px solid ${theme.palette.primary.main}`,
margin: "0px",
padding: "8px 15px",
borderRadius: "8px",
width: "100%",
display: "flex",
flexDirection: "column",
}));
export const StyledCardHeader = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: "5px",
padding: "7px",
});
export const StyledCardHeaderComment = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: "7px",
padding: "9px 7px",
});
export const StyledCardCol = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const StyledCardColComment = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const StyledCardContent = styled(Box)({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
padding: "5px 10px",
gap: "10px",
});
export const StyledCardContentComment = styled(Box)({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "flex-start",
padding: "5px 10px",
gap: "10px",
});
export const StyledCardComment = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
color: theme.palette.text.primary,
fontSize: "19px",
wordBreak: "break-word"
}));
export const TitleText = styled(Typography)({
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
fontFamily: "Cairo, sans-serif",
fontSize: "22px",
lineHeight: "1.2",
});
export const AuthorText = styled(Typography)({
fontFamily: "Raleway, sans-serif",
fontSize: "16px",
lineHeight: "1.2",
});
export const AuthorTextComment = styled(Typography)(({ theme }) => ({
fontFamily: "Montserrat, sans-serif",
fontSize: "17px",
letterSpacing: "0.3px",
fontWeight: 400,
color: theme.palette.text.primary,
userSelect: "none",
}));
export const IconsBox = styled(Box)({
display: "flex",
gap: "3px",
position: "absolute",
top: "12px",
right: "5px",
transition: "all 0.3s ease-in-out",
});
export const BookmarkIconContainer = styled(Box)({
display: "flex",
boxShadow: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;",
backgroundColor: "#fbfbfb",
color: "#50e3c2",
padding: "5px",
borderRadius: "3px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
transform: "scale(1.1)",
},
});
export const BlockIconContainer = styled(Box)({
display: "flex",
boxShadow: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;",
backgroundColor: "#fbfbfb",
color: "#c25252",
padding: "5px",
borderRadius: "3px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
transform: "scale(1.1)",
},
});
export const CommentsContainer = styled(Box)({
width: "90%",
maxWidth: "1000px",
display: "flex",
flexDirection: "column",
flex: "1",
overflow: "auto",
});
export const CommentContainer = styled(Box)({
display: "flex",
flexDirection: "column",
margin: "25px 0px 50px 0px",
maxWidth: "100%",
width: "100%",
gap: "10px",
padding: "0px 5px",
});
export const NoCommentsRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
flex: "1",
padding: "10px 0px",
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
fontSize: "18px",
});
export const LoadMoreCommentsButtonRow = styled(Box)({
display: "flex",
});
export const EditReplyButton = styled(Button)(({ theme }) => ({
width: "30px",
alignSelf: "flex-end",
background: theme.palette.primary.light,
color: "#ffffff",
}));
export const LoadMoreCommentsButton = styled(Button)(({ theme }) => ({
fontFamily: "Montserrat",
fontWeight: 400,
letterSpacing: "0.2px",
fontSize: "15px",
backgroundColor: theme.palette.primary.main,
color: "#ffffff",
}));
export const CommentActionButtonRow = styled(Box)({
display: "flex",
alignItems: "center",
gap: "5px",
});
export const CommentEditorContainer = styled(Box)({
width: "100%",
display: "flex",
justifyContent: "center",
});
export const CommentDateText = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
fontSize: "13px",
marginLeft: "5px",
color: theme.palette.text.primary,
}));
export const CommentInputContainer = styled(Box)({
display: "flex",
flexDirection: "column",
marginTop: "15px",
width: "90%",
maxWidth: "1000px",
borderRadius: "8px",
gap: "10px",
alignItems: "center",
marginBottom: "25px",
});
export const CommentInput = styled(TextField)(({ theme }) => ({
backgroundColor: theme.palette.mode === "light" ? "#a9d9d01d" : "#c3abe4a",
border: `1px solid ${theme.palette.primary.main}`,
width: "100%",
borderRadius: "8px",
'& [class$="-MuiFilledInput-root"]': {
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
color: theme.palette.text.primary,
fontSize: "19px",
minHeight: "100px",
backgroundColor: "transparent",
"&:before": {
borderBottom: "none",
"&:hover": {
borderBottom: "none",
},
},
"&:hover": {
backgroundColor: "transparent",
"&:before": {
borderBottom: "none",
},
},
},
}));
export const SubmitCommentButton = styled(Button)(({ theme }) => ({
fontFamily: "Montserrat",
fontWeight: 400,
letterSpacing: "0.2px",
fontSize: "15px",
backgroundColor: theme.palette.primary.main,
color: "#ffffff",
width: "75%",
}));

72
src/components/common/ConsentModal.tsx

@ -0,0 +1,72 @@
import * as React from "react";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import localForage from "localforage";
import { useTheme } from "@mui/material";
const generalLocal = localForage.createInstance({
name: "q-tube-general",
});
export default function ConsentModal() {
const theme = useTheme();
const [open, setOpen] = React.useState(false);
const handleClose = () => {
setOpen(false);
};
const getIsConsented = React.useCallback(async () => {
try {
const hasConsented = await generalLocal.getItem("general-consent");
if (hasConsented) return;
setOpen(true);
generalLocal.setItem("general-consent", true);
} catch (error) {}
}, []);
React.useEffect(() => {
getIsConsented();
}, []);
return (
<div>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">Welcome</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Q-Tube is currently in its first version and as such there could be
some bugs. The Qortal community, along with its development team and
the creators of this application, cannot be held accountable for any
content published or displayed. Also, they are not responsible for
any loss of coin due to either bad actors or bugs in the
application. Furthermore, they bear no responsibility for any data
loss that may occur as a result of using this application. Finally, they bear no responsibility for any of the content uploaded by users.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
fontFamily: "Arial",
}}
onClick={handleClose}
autoFocus
>
Close
</Button>
</DialogActions>
</Dialog>
</div>
);
}

204
src/components/common/DownloadTaskManager.tsx

@ -0,0 +1,204 @@
import React, { useState, useEffect } from 'react'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
LinearProgress,
List,
ListItem,
ListItemIcon,
Popover,
Typography,
useTheme
} from '@mui/material'
import { Movie } from '@mui/icons-material'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { useLocation, useNavigate } from 'react-router-dom'
import { DownloadingLight } from '../../assets/svgs/DownloadingLight'
import { DownloadedLight } from '../../assets/svgs/DownloadedLight'
import AttachFileIcon from "@mui/icons-material/AttachFile";
export const DownloadTaskManager: React.FC = () => {
const { downloads } = useSelector((state: RootState) => state.global)
const location = useLocation()
const theme = useTheme()
const [visible, setVisible] = useState(false)
const [hidden, setHidden] = useState(true)
const navigate = useNavigate()
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [openDownload, setOpenDownload] = useState<boolean>(false);
const handleClick = (event?: React.MouseEvent<HTMLDivElement>) => {
const target = event?.currentTarget as unknown as HTMLButtonElement | null;
setAnchorEl(target);
};
const handleCloseDownload = () => {
setAnchorEl(null);
setOpenDownload(false);
};
useEffect(() => {
// Simulate downloads for demo purposes
if (visible) {
setTimeout(() => {
setHidden(true)
setVisible(false)
}, 3000)
}
}, [visible])
useEffect(() => {
if (Object.keys(downloads).length === 0) return
setVisible(true)
setHidden(false)
}, [downloads])
if (
!downloads ||
Object.keys(downloads).length === 0
)
return null
let downloadInProgress = false
if(Object.keys(downloads).find((key)=> (downloads[key]?.status?.status !== 'READY' && downloads[key]?.status?.status !== 'DOWNLOADED'))){
downloadInProgress = true
}
return (
<Box>
<Button onClick={(e: any) => {
handleClick(e);
setOpenDownload(true);
}}>
{downloadInProgress ? (
<DownloadingLight height='24px' width='24px' className='download-icon' />
) : (
<DownloadedLight height='24px' width='24px' />
)}
</Button>
<Popover
id={"download-popover"}
open={openDownload}
anchorEl={anchorEl}
onClose={handleCloseDownload}
anchorOrigin={{
vertical: "bottom",
horizontal: "left"
}}
>
<List
sx={{
maxHeight: '50vh',
overflow: 'auto',
width: '250px',
gap: '5px',
display: 'flex',
flexDirection: 'column',
}}
>
{Object.keys(downloads)
.map((download: any) => {
const downloadObj = downloads[download]
const progress = downloads[download]?.status?.percentLoaded || 0
const status = downloads[download]?.status?.status
const service = downloads[download]?.service
return (
<ListItem
key={downloadObj?.identifier}
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'center',
background: theme.palette.primary.main,
color: theme.palette.text.primary,
cursor: 'pointer',
padding: '2px',
}}
onClick={() => {
const id = downloadObj?.properties?.jsonId
if (!id) return
navigate(
`/share/${downloadObj?.properties?.name}/${id}`
)
}}
>
<Box
sx={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<ListItemIcon>
<AttachFileIcon sx={{ color: theme.palette.text.primary }} />
</ListItemIcon>
<Box
sx={{ width: '100px', marginLeft: 1, marginRight: 1 }}
>
<LinearProgress
variant="determinate"
value={progress}
sx={{
borderRadius: '5px',
color: theme.palette.secondary.main
}}
/>
</Box>
<Typography
sx={{
fontFamily: 'Arial',
color: theme.palette.text.primary
}}
variant="caption"
>
{`${progress?.toFixed(0)}%`}{' '}
{status && status === 'REFETCHING' && '- refetching'}
{status && status === 'DOWNLOADED' && '- building'}
</Typography>
</Box>
<Typography
sx={{
fontSize: '10px',
width: '100%',
textAlign: 'end',
fontFamily: 'Arial',
color: theme.palette.text.primary,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
}}
>
{downloadObj?.identifier}
</Typography>
</ListItem>
)
})}
</List>
</Popover>
</Box>
)
}

436
src/components/common/FileElement.tsx

@ -0,0 +1,436 @@
import * as React from "react";
import { styled, useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useDispatch, useSelector } from "react-redux";
import { CircularProgress } from "@mui/material";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import { MyContext } from "../../wrappers/DownloadWrapper";
import { RootState } from "../../state/store";
import { setNotification } from "../../state/features/notificationsSlice";
const Widget = styled("div")(({ theme }) => ({
padding: 8,
borderRadius: 10,
maxWidth: 350,
position: "relative",
zIndex: 1,
backdropFilter: "blur(40px)",
background: "skyblue",
transition: "0.2s all",
"&:hover": {
opacity: 0.75,
},
}));
const CoverImage = styled("div")({
width: 40,
height: 40,
objectFit: "cover",
overflow: "hidden",
flexShrink: 0,
borderRadius: 8,
backgroundColor: "rgba(0,0,0,0.08)",
"& > img": {
width: "100%",
},
});
interface IAudioElement {
title: string;
description?: string;
author?: string;
fileInfo?: any;
postId?: string;
user?: string;
children?: React.ReactNode;
mimeType?: string;
disable?: boolean;
mode?: string;
otherUser?: string;
customStyles?: any;
jsonId:string;
}
interface CustomWindow extends Window {
showSaveFilePicker: any; // Replace 'any' with the appropriate type if you know it
}
const customWindow = window as unknown as CustomWindow;
export default function FileElement({
title,
description,
author,
fileInfo,
children,
mimeType,
disable,
customStyles,
jsonId
}: IAudioElement) {
const { downloadVideo } = React.useContext(MyContext);
const [startedDownload, setStartedDownload] = React.useState<boolean>(false)
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [fileProperties, setFileProperties] = React.useState<any>(null);
const [downloadLoader, setDownloadLoader] = React.useState<any>(false);
const downloads = useSelector((state: RootState) => state.global?.downloads);
const hasCommencedDownload = React.useRef(false);
const dispatch = useDispatch();
const reDownload = React.useRef<boolean>(false)
const isFetchingProperties = React.useRef<boolean>(false)
const download = React.useMemo(() => {
if (!downloads || !fileInfo?.identifier) return {};
const findDownload = downloads[fileInfo?.identifier];
if (!findDownload) return {};
return findDownload;
}, [downloads, fileInfo]);
const resourceStatus = React.useMemo(() => {
return download?.status || {};
}, [download]);
const retryDownload = React.useRef(0);
const handlePlay = async () => {
if (disable) return;
hasCommencedDownload.current = true;
setStartedDownload(true)
if (
resourceStatus?.status === "READY"
) {
if (downloadLoader) return;
setDownloadLoader(true);
let filename = download?.properties?.filename
let mimeType = download?.properties?.type
try {
const { name, service, identifier } = fileInfo;
const res = await qortalRequest({
action: "GET_QDN_RESOURCE_PROPERTIES",
name: name,
service: service,
identifier: identifier,
});
filename = res?.filename || filename;
mimeType = res?.mimeType || mimeType;
} catch (error) {
}
try {
const { name, service, identifier } = fileInfo;
const url = `/arbitrary/${service}/${name}/${identifier}`;
fetch(url)
.then(response => response.blob())
.then(async blob => {
await qortalRequest({
action: "SAVE_FILE",
blob,
filename: filename,
mimeType,
});
})
.catch(error => {
console.error("Error fetching the video:", error);
});
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to send message",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to send message",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to send message",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
} finally {
setDownloadLoader(false);
}
return;
}
const { name, service, identifier } = fileInfo;
setIsLoading(true);
downloadVideo({
name,
service,
identifier,
properties: {
...fileInfo,
jsonId
},
});
};
const refetch = React.useCallback(async () => {
if (!fileInfo) return
try {
const { name, service, identifier } = fileInfo;
isFetchingProperties.current = true
await qortalRequest({
action: 'GET_QDN_RESOURCE_PROPERTIES',
name,
service,
identifier
})
} catch (error) {
} finally {
isFetchingProperties.current = false
}
}, [fileInfo])
const refetchInInterval = ()=> {
try {
const interval = setInterval(()=> {
if(resourceStatus?.current === 'DOWNLOADED'){
refetch()
}
if(resourceStatus?.current === 'READY'){
clearInterval(interval);
}
}, 7500)
} catch (error) {
}
}
React.useEffect(() => {
if (
resourceStatus?.status === "READY" &&
download?.url &&
download?.properties?.filename &&
hasCommencedDownload.current
) {
setIsLoading(false);
dispatch(
setNotification({
msg: "Download completed. Click to save file",
alertType: "info",
})
);
} else if (
resourceStatus?.status === 'DOWNLOADED' &&
reDownload?.current === false
) {
refetchInInterval()
reDownload.current = true
}
}, [resourceStatus, download]);
return (
<Box
onClick={handlePlay}
sx={{
width: "100%",
overflow: "hidden",
position: "relative",
cursor: "pointer",
...(customStyles || {}),
}}
>
{children && (
<Box
sx={{
display: "flex",
alignItems: "center",
position: "relative",
gap: "7px",
}}
>
{children}{" "}
{((resourceStatus.status && resourceStatus?.status !== "READY") ||
isLoading) && startedDownload ? (
<>
<CircularProgress color="secondary" size={14} />
<Typography variant="body2">{`${Math.round(
resourceStatus?.percentLoaded || 0
).toFixed(0)}% loaded`}</Typography>
</>
) : resourceStatus?.status === "READY" ? (
<>
<Typography
sx={{
fontSize: "14px",
}}
>
Ready to save: click here
</Typography>
{downloadLoader && (
<CircularProgress color="secondary" size={14} />
)}
</>
) : null}
</Box>
)}
{!children && (
<Widget>
<Box sx={{ display: "flex", alignItems: "center" }}>
<CoverImage>
<AttachFileIcon
sx={{
width: "90%",
height: "auto",
}}
/>
</CoverImage>
<Box sx={{ ml: 1.5, minWidth: 0 }}>
<Typography
variant="caption"
color="text.secondary"
fontWeight={500}
>
{author}
</Typography>
<Typography
noWrap
sx={{
fontSize: "16px",
}}
>
<b>{title}</b>
</Typography>
<Typography
noWrap
letterSpacing={-0.25}
sx={{
fontSize: "14px",
}}
>
{description}
</Typography>
{mimeType && (
<Typography
noWrap
letterSpacing={-0.25}
sx={{
fontSize: "12px",
}}
>
{mimeType}
</Typography>
)}
</Box>
</Box>
{((resourceStatus.status && resourceStatus?.status !== "READY") ||
isLoading) && startedDownload && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={4999}
bgcolor="rgba(0, 0, 0, 0.6)"
sx={{
display: "flex",
flexDirection: "column",
gap: "10px",
padding: "8px",
borderRadius: "10px",
}}
>
<CircularProgress color="secondary" />
{resourceStatus && (
<Typography
variant="subtitle2"
component="div"
sx={{
color: "white",
fontSize: "14px",
}}
>
{resourceStatus?.status === "REFETCHING" ? (
<>
<>
{(
(resourceStatus?.localChunkCount /
resourceStatus?.totalChunkCount) *
100
)?.toFixed(0)}
%
</>
<> Refetching in 2 minutes</>
</>
) : resourceStatus?.status === "DOWNLOADED" ? (
<>Download Completed: building file...</>
) : resourceStatus?.status !== "READY" ? (
<>
{(
(resourceStatus?.localChunkCount /
resourceStatus?.totalChunkCount) *
100
)?.toFixed(0)}
%
</>
) : (
<>Download Completed: fetching file...</>
)}
</Typography>
)}
</Box>
)}
{resourceStatus?.status === "READY" &&
download?.url &&
download?.properties?.filename && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={4999}
bgcolor="rgba(0, 0, 0, 0.6)"
sx={{
display: "flex",
flexDirection: "row",
gap: "10px",
padding: "8px",
borderRadius: "10px",
}}
>
<Typography
variant="subtitle2"
component="div"
sx={{
color: "white",
fontSize: "14px",
}}
>
Ready to save: click here
</Typography>
{downloadLoader && (
<CircularProgress color="secondary" size={14} />
)}
</Box>
)}
</Widget>
)}
</Box>
);
}

89
src/components/common/ImageUploader.tsx

@ -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

@ -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>
isLoading?: boolean
}
const LazyLoad: React.FC<Props> = ({ onLoadMore, isLoading }) => {
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'
}}
>
<div
style={{
visibility: (isFetching || isLoading) ? 'visible' : 'hidden'
}}
>
<CircularProgress />
</div>
</div>
)
}
export default LazyLoad

136
src/components/common/MultiplePublish/MultiplePublish.tsx

@ -0,0 +1,136 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Box,
Button,
CircularProgress,
Modal,
Typography,
useTheme,
} from "@mui/material";
import React, { useCallback, useEffect, useState, useRef } from "react";
import { ModalBody } from "../../UploadVideo/Upload-styles";
import { CircleSVG } from "../../../assets/svgs/CircleSVG";
import { EmptyCircleSVG } from "../../../assets/svgs/EmptyCircleSVG";
export const MultiplePublish = ({ publishes, isOpen, onSubmit }) => {
const theme = useTheme();
const listOfSuccessfulPublishesRef = useRef([])
const [listOfSuccessfulPublishes, setListOfSuccessfulPublishes] = useState<
any[]
>([]);
const [currentlyInPublish, setCurrentlyInPublish] = useState(null);
const hasStarted = useRef(false);
const publish = useCallback(async (pub: any) => {
await qortalRequest(pub);
}, []);
const [isPublishing, setIsPublishing] = useState(true)
const handlePublish = useCallback(
async (pub: any) => {
try {
setCurrentlyInPublish(pub?.identifier);
await publish(pub);
setListOfSuccessfulPublishes((prev: any) => [...prev, pub?.identifier]);
listOfSuccessfulPublishesRef.current = [...listOfSuccessfulPublishesRef.current, pub?.identifier]
} catch (error) {
console.log({ error });
await new Promise<void>((res) => {
setTimeout(() => {
res();
}, 5000);
});
// await handlePublish(pub);
}
},
[publish]
);
const startPublish = useCallback(
async (pubs: any) => {
setIsPublishing(true)
const filterPubs = pubs.filter((pub)=> !listOfSuccessfulPublishesRef.current.includes(pub.identifier))
for (const pub of filterPubs) {
await handlePublish(pub);
}
if(listOfSuccessfulPublishesRef.current.length === pubs.length){
onSubmit()
}
setIsPublishing(false)
},
[handlePublish, onSubmit, listOfSuccessfulPublishes, publishes]
);
useEffect(() => {
if (publishes && !hasStarted.current) {
hasStarted.current = true;
startPublish(publishes);
}
}, [startPublish, publishes, listOfSuccessfulPublishes]);
return (
<Modal
open={isOpen}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<ModalBody
sx={{
minHeight: "50vh",
}}
>
{publishes.map((publish: any) => {
return (
<Box
sx={{
display: "flex",
gap: "20px",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography>{publish?.title}</Typography>
{publish?.identifier === currentlyInPublish ? (
<CircularProgress
size={20}
thickness={2}
sx={{
color: theme.palette.secondary.main,
}}
/>
) : listOfSuccessfulPublishes.includes(publish.identifier) ? (
<CircleSVG
color={theme.palette.text.primary}
height="24px"
width="24px"
/>
) : (
<EmptyCircleSVG
color={theme.palette.text.primary}
height="24px"
width="24px"
/>
)}
</Box>
);
})}
{!isPublishing && listOfSuccessfulPublishes.length !== publishes.length && (
<>
<Typography sx={{
marginTop: '20px',
fontSize: '16px'
}}>Some files were not published. Please try again. It's important that all the files get published. Maybe wait a couple minutes if the error keeps occurring</Typography>
<Button onClick={()=> {
startPublish(publishes)
}}>Try again</Button>
</>
)}
</ModalBody>
</Modal>
);
};

86
src/components/common/Notification/Notification.tsx

@ -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

@ -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

@ -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

40
src/components/common/TextEditor/DisplayHtml.tsx

@ -0,0 +1,40 @@
import { useMemo } from "react";
import DOMPurify from "dompurify";
import "react-quill/dist/quill.snow.css";
import "react-quill/dist/quill.core.css";
import "react-quill/dist/quill.bubble.css";
import { convertQortalLinks } from "./utils";
import { Box, styled } from "@mui/material";
const CrowdfundInlineContent = styled(Box)(({ theme }) => ({
display: "flex",
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: 0,
color: theme.palette.text.primary,
width: '100%'
}));
export const DisplayHtml = ({ html }) => {
const cleanContent = useMemo(() => {
if (!html) return null;
const sanitize: string = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
const anchorQortal = convertQortalLinks(sanitize);
return anchorQortal;
}, [html]);
if (!cleanContent) return null;
return (
<CrowdfundInlineContent>
<div
className="ql-editor"
dangerouslySetInnerHTML={{ __html: cleanContent }}
/>
</CrowdfundInlineContent>
);
};

38
src/components/common/TextEditor/TextEditor.tsx

@ -0,0 +1,38 @@
import React from "react";
import ReactQuill, { Quill } from "react-quill";
import "react-quill/dist/quill.snow.css";
import ImageResize from "quill-image-resize-module-react";
Quill.register("modules/imageResize", ImageResize);
const modules = {
imageResize: {
parchment: Quill.import("parchment"),
modules: ["Resize", "DisplaySize"],
},
toolbar: [
["bold", "italic", "underline", "strike"], // styled text
["blockquote", "code-block"], // blocks
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: "ordered" }, { list: "bullet" }], // lists
[{ script: "sub" }, { script: "super" }], // superscript/subscript
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
[{ direction: "rtl" }], // text direction
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }], // custom button values
[{ color: [] }, { background: [] }], // dropdown with defaults
[{ font: [] }], // font family
[{ align: [] }], // text align
["clean"], // remove formatting
],
};
export const TextEditor = ({ inlineContent, setInlineContent }) => {
return (
<ReactQuill
theme="snow"
value={inlineContent}
onChange={setInlineContent}
modules={modules}
/>
);
};

26
src/components/common/TextEditor/utils.ts

@ -0,0 +1,26 @@
export function convertQortalLinks(inputHtml) {
// Regular expression to match 'qortal://...' URLs.
// This will stop at the first whitespace, comma, or HTML tag
var regex = /(qortal:\/\/[^\s,<]+)/g;
// Replace matches in inputHtml with formatted anchor tag
var outputHtml = inputHtml.replace(regex, function (match) {
return `<a href="${match}" className="qortal-link">${match}</a>`;
});
return outputHtml;
}
export function extractTextFromHTML(htmlString: any, length = 150) {
// Create a temporary DOM element
const tempDiv = document.createElement("div");
// Replace br tags and block-level tags with a space before setting the HTML content
const htmlWithSpaces = htmlString.replace(/<\/?(br|p|div|h[1-6]|ul|ol|li|blockquote)[^>]*>/gi, ' ');
tempDiv.innerHTML = htmlWithSpaces;
// Extract the text content
let text = tempDiv.textContent || tempDiv.innerText || "";
// Replace multiple spaces with a single space and trim
text = text.replace(/\s+/g, ' ').trim();
// Slice the text to the desired length
return text.slice(0, length);
}

857
src/components/common/VideoPlayer.tsx

@ -0,0 +1,857 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Box, IconButton, Slider } from '@mui/material'
import { CircularProgress, Typography } from '@mui/material'
import { Key } from 'ts-key-enum'
import {
PlayArrow,
Pause,
VolumeUp,
Fullscreen,
PictureInPicture, VolumeOff
} from '@mui/icons-material'
import { styled } from '@mui/system'
import { MyContext } from '../../wrappers/DownloadWrapper'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { Refresh } from '@mui/icons-material'
import { Menu, MenuItem } from '@mui/material'
import { MoreVert as MoreIcon } from '@mui/icons-material'
import { setVideoPlaying } from '../../state/features/globalSlice'
const VideoContainer = styled(Box)`
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin: 0px;
padding: 0px;
`
const VideoElement = styled('video')`
width: 100%;
height: auto;
max-height: calc(100vh - 150px);
background: rgb(33, 33, 33);
`
const ControlsContainer = styled(Box)`
position: absolute;
display: flex;
align-items: center;
justify-content: space-between;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background-color: rgba(0, 0, 0, 0.6);
`
interface VideoPlayerProps {
src?: string
poster?: string
name?: string
identifier?: string
service?: string
autoplay?: boolean
from?: string | null
customStyle?: any
user?: string
jsonId?: string
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
poster,
name,
identifier,
service,
autoplay = true,
from = null,
customStyle = {},
user = '',
jsonId = ''
}) => {
const dispatch = useDispatch()
const videoRef = useRef<HTMLVideoElement | null>(null)
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(1)
const [mutedVolume, setMutedVolume] = useState(1)
const [isMuted, setIsMuted] = useState(false)
const [progress, setProgress] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [canPlay, setCanPlay] = useState(false)
const [startPlay, setStartPlay] = useState(false)
const [isMobileView, setIsMobileView] = useState(false)
const [playbackRate, setPlaybackRate] = useState(1)
const [anchorEl, setAnchorEl] = useState(null)
const videoPlaying = useSelector((state: RootState) => state.global.videoPlaying);
const reDownload = useRef<boolean>(false)
const isFetchingProperties = useRef<boolean>(false)
const status = useRef<null | string>(null)
const { downloads } = useSelector((state: RootState) => state.global)
const download = useMemo(() => {
if (!downloads || !identifier) return {}
const findDownload = downloads[identifier]
if (!findDownload) return {}
return findDownload
}, [downloads, identifier])
const src = useMemo(() => {
return download?.url || ''
}, [download?.url])
const resourceStatus = useMemo(() => {
return download?.status || {}
}, [download])
const minSpeed = 0.25;
const maxSpeed = 4.0;
const speedChange = 0.25;
const updatePlaybackRate = (newSpeed: number) => {
if (videoRef.current) {
if (newSpeed > maxSpeed || newSpeed < minSpeed)
newSpeed = minSpeed
videoRef.current.playbackRate = newSpeed
setPlaybackRate(newSpeed)
}
}
const increaseSpeed = (wrapOverflow = true) => {
const changedSpeed = playbackRate + speedChange
let newSpeed = wrapOverflow ? changedSpeed : Math.min(changedSpeed, maxSpeed)
if (videoRef.current) {
updatePlaybackRate(newSpeed);
}
}
const decreaseSpeed = () => {
if (videoRef.current) {
updatePlaybackRate(playbackRate - speedChange);
}
}
const refetch = React.useCallback(async () => {
if (!name || !identifier || !service || isFetchingProperties.current) return
try {
isFetchingProperties.current = true
await qortalRequest({
action: 'GET_QDN_RESOURCE_PROPERTIES',
name,
service,
identifier
})
} catch (error) {
} finally {
isFetchingProperties.current = false
}
}, [identifier, name, service])
const toggleRef = useRef<any>(null)
const { downloadVideo } = useContext(MyContext)
const togglePlay = async () => {
if (!videoRef.current) return
setStartPlay(true)
if (!src || resourceStatus?.status !== 'READY') {
const el = document.getElementById('videoWrapper')
if (el) {
el?.parentElement?.removeChild(el)
}
ReactDOM.flushSync(() => {
setIsLoading(true)
})
getSrc()
}
if (playing) {
videoRef.current.pause()
} else {
videoRef.current.play()
}
setPlaying(!playing)
}
const onVolumeChange = (_: any, value: number | number[]) => {
if (!videoRef.current) return
videoRef.current.volume = value as number
setVolume(value as number)
setIsMuted(false)
}
const onProgressChange = (_: any, value: number | number[]) => {
if (!videoRef.current) return
videoRef.current.currentTime = value as number
setProgress(value as number)
if (!playing) {
videoRef.current.play()
setPlaying(true)
}
}
const handleEnded = () => {
setPlaying(false)
}
const updateProgress = () => {
if (!videoRef.current) return
setProgress(videoRef.current.currentTime)
}
const [isFullscreen, setIsFullscreen] = useState(false)
const enterFullscreen = () => {
if (!videoRef.current) return
if (videoRef.current.requestFullscreen) {
videoRef.current.requestFullscreen()
}
}
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
const toggleFullscreen = () => {
isFullscreen ? exitFullscreen() : enterFullscreen()
}
const togglePictureInPicture = async () => {
if (!videoRef.current) return
if (document.pictureInPictureElement === videoRef.current) {
await document.exitPictureInPicture()
} else {
await videoRef.current.requestPictureInPicture()
}
}
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, [])
useEffect(()=> {
if(videoPlaying && videoPlaying.id === identifier && src && videoRef?.current){
handleCanPlay()
videoRef.current.volume = videoPlaying.volume
videoRef.current.currentTime = videoPlaying.currentTime
videoRef.current.play()
setPlaying(true)
setStartPlay(true)
dispatch(setVideoPlaying(null))
}
}, [videoPlaying, identifier, src])
const handleCanPlay = () => {
setIsLoading(false)
setCanPlay(true)
}
const getSrc = React.useCallback(async () => {
if (!name || !identifier || !service || !jsonId || !user) return
try {
downloadVideo({
name,
service,
identifier,
properties: {
jsonId,
user
}
})
} catch (error) {
console.error(error)
}
}, [identifier, name, service, jsonId, user])
useEffect(() => {
const videoElement = videoRef.current
const handleLeavePictureInPicture = async (event: any) => {
const target = event?.target
if (target) {
target.pause()
if (setPlaying) {
setPlaying(false)
}
}
}
if (videoElement) {
videoElement.addEventListener(
'leavepictureinpicture',
handleLeavePictureInPicture
)
}
return () => {
if (videoElement) {
videoElement.removeEventListener(
'leavepictureinpicture',
handleLeavePictureInPicture
)
}
}
}, [])
useEffect(() => {
const videoElement = videoRef.current
const minimizeVideo = async () => {
if (!videoElement) return
dispatch(setVideoPlaying(videoElement))
// const handleClose = () => {
// if (videoElement && videoElement.parentElement) {
// const el = document.getElementById('videoWrapper')
// if (el) {
// el?.parentElement?.removeChild(el)
// }
// }
// }
// const createCloseButton = (): HTMLButtonElement => {
// const closeButton = document.createElement('button')
// closeButton.textContent = 'X'
// closeButton.style.position = 'absolute'
// closeButton.style.top = '0'
// closeButton.style.right = '0'
// closeButton.style.backgroundColor = 'rgba(255, 255, 255, 0.7)'
// closeButton.style.border = 'none'
// closeButton.style.fontWeight = 'bold'
// closeButton.style.fontSize = '1.2rem'
// closeButton.style.cursor = 'pointer'
// closeButton.style.padding = '2px 8px'
// closeButton.style.borderRadius = '0 0 0 4px'
// closeButton.addEventListener('click', handleClose)
// return closeButton
// }
// const buttonClose = createCloseButton()
// const videoWrapper = document.createElement('div')
// videoWrapper.id = 'videoWrapper'
// videoWrapper.style.position = 'fixed'
// videoWrapper.style.zIndex = '900000009'
// videoWrapper.style.bottom = '0px'
// videoWrapper.style.right = '0px'
// videoElement.parentElement?.insertBefore(videoWrapper, videoElement)
// videoWrapper.appendChild(videoElement)
// videoWrapper.appendChild(buttonClose)
// videoElement.controls = true
// videoElement.style.height = 'auto'
// videoElement.style.width = '300px'
// document.body.appendChild(videoWrapper)
}
return () => {
if (videoElement) {
if (videoElement && !videoElement.paused && !videoElement.ended) {
minimizeVideo()
}
}
}
}, [])
function formatTime(seconds: number): string {
seconds = Math.floor(seconds)
let minutes: number | string = Math.floor(seconds / 60)
let hours: number | string = Math.floor(minutes / 60)
let remainingSeconds: number | string = seconds % 60
let remainingMinutes: number | string = minutes % 60
if (remainingSeconds < 10) {
remainingSeconds = '0' + remainingSeconds
}
if (remainingMinutes < 10) {
remainingMinutes = '0' + remainingMinutes
}
if (hours === 0) {
hours = ''
}
else {
hours = hours + ':'
}
return hours + remainingMinutes + ':' + remainingSeconds
}
const reloadVideo = () => {
if (!videoRef.current) return
const currentTime = videoRef.current.currentTime
videoRef.current.src = src
videoRef.current.load()
videoRef.current.currentTime = currentTime
if (playing) {
videoRef.current.play()
}
}
const refetchInInterval = ()=> {
try {
const interval = setInterval(()=> {
if(status?.current === 'DOWNLOADED'){
refetch()
}
if(status?.current === 'READY'){
clearInterval(interval);
}
}, 7500)
} catch (error) {
}
}
useEffect(() => {
if(resourceStatus?.status){
status.current = resourceStatus?.status
}
if (
resourceStatus?.status === 'DOWNLOADED' &&
reDownload?.current === false
) {
refetchInInterval()
reDownload.current = true
}
}, [getSrc, resourceStatus])
const handleMenuOpen = (event: any) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
useEffect(() => {
const videoWidth = videoRef?.current?.offsetWidth
if (videoWidth && videoWidth <= 600) {
setIsMobileView(true)
}
}, [canPlay])
const getDownloadProgress = (current: number, total: number) => {
const progress = current / total * 100;
return Number.isNaN(progress) ? '' : progress.toFixed(0) + '%'
}
const mute = () => {
setIsMuted(true)
setMutedVolume(volume)
setVolume(0)
if (videoRef.current) videoRef.current.volume = 0
}
const unMute = () => {
setIsMuted(false)
setVolume(mutedVolume)
if (videoRef.current) videoRef.current.volume = mutedVolume
}
const toggleMute = () => {
isMuted ? unMute() : mute();
}
const changeVolume = (volumeChange: number) => {
if (videoRef.current) {
const minVolume = 0;
const maxVolume = 1;
let newVolume = volumeChange + volume
newVolume = Math.max(newVolume, minVolume)
newVolume = Math.min(newVolume, maxVolume)
setIsMuted(false)
setMutedVolume(newVolume)
videoRef.current.volume = newVolume
setVolume(newVolume);
}
}
const setProgressRelative = (secondsChange: number) => {
if (videoRef.current) {
const currentTime = videoRef.current?.currentTime
const minTime = 0
const maxTime = videoRef.current?.duration || 100
let newTime = currentTime + secondsChange;
newTime = Math.max(newTime, minTime)
newTime = Math.min(newTime, maxTime)
videoRef.current.currentTime = newTime;
setProgress(newTime);
}
}
const setProgressAbsolute = (videoPercent: number) => {
if (videoRef.current) {
videoPercent = Math.min(videoPercent, 100)
videoPercent = Math.max(videoPercent, 0)
const finalTime = videoRef.current?.duration * videoPercent / 100
videoRef.current.currentTime = finalTime
setProgress(finalTime);
}
}
const keyboardShortcutsDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault()
switch (e.key) {
case Key.Add: increaseSpeed(false); break;
case '+': increaseSpeed(false); break;
case '>': increaseSpeed(false); break;
case Key.Subtract: decreaseSpeed(); break;
case '-': decreaseSpeed(); break;
case '<': decreaseSpeed(); break;
case Key.ArrowLeft: {
if (e.shiftKey) setProgressRelative(-300);
else if (e.ctrlKey) setProgressRelative(-60);
else if (e.altKey) setProgressRelative(-10);
else setProgressRelative(-5);
} break;
case Key.ArrowRight: {
if (e.shiftKey) setProgressRelative(300);
else if (e.ctrlKey) setProgressRelative(60);
else if (e.altKey) setProgressRelative(10);
else setProgressRelative(5);
} break;
case Key.ArrowDown: changeVolume(-0.05); break;
case Key.ArrowUp: changeVolume(0.05); break;
}
}
const keyboardShortcutsUp = (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault()
switch (e.key) {
case ' ': togglePlay(); break;
case 'm': toggleMute(); break;
case 'f': enterFullscreen(); break;
case Key.Escape: exitFullscreen(); break;
case '0': setProgressAbsolute(0); break;
case '1': setProgressAbsolute(10); break;
case '2': setProgressAbsolute(20); break;
case '3': setProgressAbsolute(30); break;
case '4': setProgressAbsolute(40); break;
case '5': setProgressAbsolute(50); break;
case '6': setProgressAbsolute(60); break;
case '7': setProgressAbsolute(70); break;
case '8': setProgressAbsolute(80); break;
case '9': setProgressAbsolute(90); break;
}
}
return (
<VideoContainer
tabIndex={0}
onKeyUp={keyboardShortcutsUp}
onKeyDown={keyboardShortcutsDown}
style={{
padding: from === 'create' ? '8px' : 0
}}
>
{isLoading && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={resourceStatus?.status === 'READY' ? '55px ' : 0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={25}
bgcolor="rgba(0, 0, 0, 0.6)"
sx={{
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}
>
<CircularProgress color="secondary" />
{resourceStatus && (
<Typography
variant="subtitle2"
component="div"
sx={{
color: 'white',
fontSize: '15px',
textAlign: 'center'
}}
>
{resourceStatus?.status === 'NOT_PUBLISHED' && (
<>Video file was not published. Please inform the publisher!</>
)}
{resourceStatus?.status === 'REFETCHING' ? (
<>
<>
{getDownloadProgress(resourceStatus?.localChunkCount, resourceStatus?.totalChunkCount)}
</>
<> Refetching in 25 seconds</>
</>
) : resourceStatus?.status === 'DOWNLOADED' ? (
<>Download Completed: building video...</>
) : resourceStatus?.status !== 'READY' ? (
<>
{getDownloadProgress(resourceStatus?.localChunkCount, resourceStatus?.totalChunkCount)}
</>
) : (
<>Fetching video...</>
)}
</Typography>
)}
</Box>
)}
{((!src && !isLoading) || !startPlay) && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={500}
bgcolor="rgba(0, 0, 0, 0.6)"
onClick={() => {
if (from === 'create') return
dispatch(setVideoPlaying(null))
togglePlay()
}}
sx={{
cursor: 'pointer'
}}
>
<PlayArrow
sx={{
width: '50px',
height: '50px',
color: 'white'
}}
/>
</Box>
)}
<VideoElement
id={identifier}
ref={videoRef}
src={!startPlay ? '' : resourceStatus?.status === 'READY' ? src : ''}
poster={!startPlay ? poster : ""}
onTimeUpdate={updateProgress}
autoPlay={autoplay}
onClick={togglePlay}
onEnded={handleEnded}
// onLoadedMetadata={handleLoadedMetadata}
onCanPlay={handleCanPlay}
preload="metadata"
style={{
...customStyle
}}
/>
<ControlsContainer
style={{
bottom: from === 'create' ? '15px' : 0
}}
>
{isMobileView && canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<IconButton
edge="end"
color="inherit"
aria-label="menu"
onClick={handleMenuOpen}
>
<MoreIcon />
</IconButton>
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
style: {
width: '250px'
}
}}
>
<MenuItem>
<VolumeUp />
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01} />
</MenuItem>
<MenuItem onClick={() => increaseSpeed()}>
<Typography
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px'
}}
>
Speed: {playbackRate}x
</Typography>
</MenuItem>
<MenuItem onClick={togglePictureInPicture}>
<PictureInPicture />
</MenuItem>
<MenuItem onClick={toggleFullscreen}>
<Fullscreen />
</MenuItem>
</Menu>
</>
) : canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<Typography
sx={{
fontSize: '14px',
marginRight: '5px',
color: 'rgba(255, 255, 255, 0.7)',
visibility:
!videoRef.current?.duration || !progress
? 'hidden'
: 'visible'
}}
>
{progress && videoRef.current?.duration && formatTime(progress)}/
{progress &&
videoRef.current?.duration &&
formatTime(videoRef.current?.duration)}
</Typography>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginRight: '10px'
}}
onClick={toggleMute}
>
{isMuted ? <VolumeOff /> : <VolumeUp />}
</IconButton>
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
sx={{
maxWidth: '100px'
}}
/>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px',
marginLeft: '5px'
}}
onClick={(e) => increaseSpeed()}
>
Speed: {playbackRate}x
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
ref={toggleRef}
onClick={togglePictureInPicture}
>
<PictureInPicture />
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={toggleFullscreen}
>
<Fullscreen />
</IconButton>
</>
) : null}
</ControlsContainer>
</VideoContainer>
)
}

648
src/components/common/VideoPlayerGlobal.tsx

@ -0,0 +1,648 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Box, IconButton, Slider, useTheme } from '@mui/material'
import { CircularProgress, Typography } from '@mui/material'
import { Key } from 'ts-key-enum'
import {
PlayArrow,
Pause,
VolumeUp,
Fullscreen,
PictureInPicture, VolumeOff
} from '@mui/icons-material'
import { styled } from '@mui/system'
import { MyContext } from '../../wrappers/DownloadWrapper'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { Refresh } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close';
import { Menu, MenuItem } from '@mui/material'
import { MoreVert as MoreIcon } from '@mui/icons-material'
import { setVideoPlaying } from '../../state/features/globalSlice'
const VideoContainer = styled(Box)`
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin: 0px;
padding: 0px;
`
const VideoElement = styled('video')`
width: 100%;
height: auto;
max-height: calc(100vh - 150px);
background: rgb(33, 33, 33);
`
const ControlsContainer = styled(Box)`
position: absolute;
display: flex;
align-items: center;
justify-content: space-between;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background-color: rgba(0, 0, 0, 0.6);
`
interface VideoPlayerProps {
src?: string
poster?: string
name?: string
identifier?: string
service?: string
autoplay?: boolean
from?: string | null
customStyle?: any
user?: string
jsonId?: string
element?: null | any
checkIfDrag?: ()=> boolean;
}
export const VideoPlayerGlobal: React.FC<VideoPlayerProps> = ({
poster,
name,
identifier,
service,
autoplay = true,
from = null,
customStyle = {},
user = '',
jsonId = '',
element,
checkIfDrag
}) => {
const theme = useTheme()
const videoRef = useRef<HTMLVideoElement | null>(null)
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(1)
const [mutedVolume, setMutedVolume] = useState(1)
const [isMuted, setIsMuted] = useState(false)
const [progress, setProgress] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [canPlay, setCanPlay] = useState(false)
const [startPlay, setStartPlay] = useState(false)
const [isMobileView, setIsMobileView] = useState(false)
const [playbackRate, setPlaybackRate] = useState(1)
const [anchorEl, setAnchorEl] = useState(null)
const dispatch = useDispatch()
const reDownload = useRef<boolean>(false)
const { downloads } = useSelector((state: RootState) => state.global)
const download = useMemo(() => {
if (!downloads || !identifier) return {}
const findDownload = downloads[identifier]
if (!findDownload) return {}
return findDownload
}, [downloads, identifier])
const resourceStatus = useMemo(() => {
return download?.status || {}
}, [download])
const minSpeed = 0.25;
const maxSpeed = 4.0;
const speedChange = 0.25;
const updatePlaybackRate = (newSpeed: number) => {
if (videoRef.current) {
if (newSpeed > maxSpeed || newSpeed < minSpeed)
newSpeed = minSpeed
videoRef.current.playbackRate = newSpeed
setPlaybackRate(newSpeed)
}
}
const increaseSpeed = (wrapOverflow = true) => {
const changedSpeed = playbackRate + speedChange
let newSpeed = wrapOverflow ? changedSpeed : Math.min(changedSpeed, maxSpeed)
if (videoRef.current) {
updatePlaybackRate(newSpeed);
}
}
const decreaseSpeed = () => {
if (videoRef.current) {
updatePlaybackRate(playbackRate - speedChange);
}
}
const toggleRef = useRef<any>(null)
const { downloadVideo } = useContext(MyContext)
const togglePlay = async () => {
if(checkIfDrag && checkIfDrag()) return
if (!videoRef.current) return
if (playing) {
videoRef.current.pause()
} else {
videoRef.current.play()
}
setPlaying((prev)=> !prev)
}
const onVolumeChange = (_: any, value: number | number[]) => {
if (!videoRef.current) return
videoRef.current.volume = value as number
setVolume(value as number)
setIsMuted(false)
}
const onProgressChange = (_: any, value: number | number[]) => {
if (!videoRef.current) return
videoRef.current.currentTime = value as number
setProgress(value as number)
if (!playing) {
videoRef.current.play()
setPlaying(true)
}
}
const handleEnded = () => {
setPlaying(false)
}
const updateProgress = () => {
if (!videoRef.current) return
setProgress(videoRef.current.currentTime)
}
const [isFullscreen, setIsFullscreen] = useState(false)
const enterFullscreen = () => {
if (!videoRef.current) return
if (videoRef.current.requestFullscreen) {
videoRef.current.requestFullscreen()
}
}
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
const toggleFullscreen = () => {
isFullscreen ? exitFullscreen() : enterFullscreen()
}
const togglePictureInPicture = async () => {
if (!videoRef.current) return
if (document.pictureInPictureElement === videoRef.current) {
await document.exitPictureInPicture()
} else {
await videoRef.current.requestPictureInPicture()
}
}
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, [])
const handleCanPlay = () => {
setIsLoading(false)
setCanPlay(true)
}
useEffect(() => {
const videoElement = videoRef.current
const handleLeavePictureInPicture = async (event: any) => {
const target = event?.target
if (target) {
target.pause()
if (setPlaying) {
setPlaying(false)
}
}
}
if (videoElement) {
videoElement.addEventListener(
'leavepictureinpicture',
handleLeavePictureInPicture
)
}
return () => {
if (videoElement) {
videoElement.removeEventListener(
'leavepictureinpicture',
handleLeavePictureInPicture
)
}
}
}, [])
function formatTime(seconds: number): string {
seconds = Math.floor(seconds)
let minutes: number | string = Math.floor(seconds / 60)
let hours: number | string = Math.floor(minutes / 60)
let remainingSeconds: number | string = seconds % 60
let remainingMinutes: number | string = minutes % 60
if (remainingSeconds < 10) {
remainingSeconds = '0' + remainingSeconds
}
if (remainingMinutes < 10) {
remainingMinutes = '0' + remainingMinutes
}
if (hours === 0) {
hours = ''
}
else {
hours = hours + ':'
}
return hours + remainingMinutes + ':' + remainingSeconds
}
const reloadVideo = () => {
if (!videoRef.current) return
const src = videoRef.current.src
const currentTime = videoRef.current.currentTime
videoRef.current.src = src
videoRef.current.load()
videoRef.current.currentTime = currentTime
if (playing) {
videoRef.current.play()
}
}
const handleMenuOpen = (event: any) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
useEffect(() => {
const videoWidth = videoRef?.current?.offsetWidth
if (videoWidth && videoWidth <= 600) {
setIsMobileView(true)
}
}, [canPlay])
const getDownloadProgress = (current: number, total: number) => {
const progress = current / total * 100;
return Number.isNaN(progress) ? '' : progress.toFixed(0) + '%'
}
const mute = () => {
setIsMuted(true)
setMutedVolume(volume)
setVolume(0)
if (videoRef.current) videoRef.current.volume = 0
}
const unMute = () => {
setIsMuted(false)
setVolume(mutedVolume)
if (videoRef.current) videoRef.current.volume = mutedVolume
}
const toggleMute = () => {
isMuted ? unMute() : mute();
}
const changeVolume = (volumeChange: number) => {
if (videoRef.current) {
const minVolume = 0;
const maxVolume = 1;
let newVolume = volumeChange + volume
newVolume = Math.max(newVolume, minVolume)
newVolume = Math.min(newVolume, maxVolume)
setIsMuted(false)
setMutedVolume(newVolume)
videoRef.current.volume = newVolume
setVolume(newVolume);
}
}
const setProgressRelative = (secondsChange: number) => {
if (videoRef.current) {
const currentTime = videoRef.current?.currentTime
const minTime = 0
const maxTime = videoRef.current?.duration || 100
let newTime = currentTime + secondsChange;
newTime = Math.max(newTime, minTime)
newTime = Math.min(newTime, maxTime)
videoRef.current.currentTime = newTime;
setProgress(newTime);
}
}
const setProgressAbsolute = (videoPercent: number) => {
if (videoRef.current) {
videoPercent = Math.min(videoPercent, 100)
videoPercent = Math.max(videoPercent, 0)
const finalTime = videoRef.current?.duration * videoPercent / 100
videoRef.current.currentTime = finalTime
setProgress(finalTime);
}
}
const keyboardShortcutsDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault()
switch (e.key) {
case Key.Add: increaseSpeed(false); break;
case '+': increaseSpeed(false); break;
case '>': increaseSpeed(false); break;
case Key.Subtract: decreaseSpeed(); break;
case '-': decreaseSpeed(); break;
case '<': decreaseSpeed(); break;
case Key.ArrowLeft: {
if (e.shiftKey) setProgressRelative(-300);
else if (e.ctrlKey) setProgressRelative(-60);
else if (e.altKey) setProgressRelative(-10);
else setProgressRelative(-5);
} break;
case Key.ArrowRight: {
if (e.shiftKey) setProgressRelative(300);
else if (e.ctrlKey) setProgressRelative(60);
else if (e.altKey) setProgressRelative(10);
else setProgressRelative(5);
} break;
case Key.ArrowDown: changeVolume(-0.05); break;
case Key.ArrowUp: changeVolume(0.05); break;
}
}
const keyboardShortcutsUp = (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault()
switch (e.key) {
case ' ': togglePlay(); break;
case 'm': toggleMute(); break;
case 'f': enterFullscreen(); break;
case Key.Escape: exitFullscreen(); break;
case '0': setProgressAbsolute(0); break;
case '1': setProgressAbsolute(10); break;
case '2': setProgressAbsolute(20); break;
case '3': setProgressAbsolute(30); break;
case '4': setProgressAbsolute(40); break;
case '5': setProgressAbsolute(50); break;
case '6': setProgressAbsolute(60); break;
case '7': setProgressAbsolute(70); break;
case '8': setProgressAbsolute(80); break;
case '9': setProgressAbsolute(90); break;
}
}
useEffect(()=> {
if(element){
let oldElement = document.getElementById('videoPlayer');
if(oldElement && oldElement?.parentNode){
oldElement?.parentNode.replaceChild(element, oldElement);
videoRef.current = element
setPlaying(true)
setCanPlay(true)
setStartPlay(true)
videoRef?.current?.addEventListener('click', ()=> {})
videoRef?.current?.addEventListener('timeupdate', updateProgress)
videoRef?.current?.addEventListener('ended', handleEnded)
}
}
}, [element])
return (
<VideoContainer
tabIndex={0}
onKeyUp={keyboardShortcutsUp}
onKeyDown={keyboardShortcutsDown}
style={{
padding: from === 'create' ? '8px' : 0,
zIndex: 1000,
backgroundColor: theme.palette.background.default,
}}
>
<div className="closePlayer">
<CloseIcon onClick={()=> {
dispatch(setVideoPlaying(null))
}} sx={{
cursor: 'pointer',
backgroundColor: 'rgba(0,0,0,.5)'
}}></CloseIcon>
</div>
<div onClick={togglePlay}>
<VideoElement
id="videoPlayer"
/>
</div>
<ControlsContainer
style={{
bottom: from === 'create' ? '15px' : 0
}}
>
{isMobileView && canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<IconButton
edge="end"
color="inherit"
aria-label="menu"
onClick={handleMenuOpen}
>
<MoreIcon />
</IconButton>
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
style: {
width: '250px'
}
}}
>
<MenuItem>
<VolumeUp />
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01} />
</MenuItem>
<MenuItem onClick={() => increaseSpeed()}>
<Typography
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px'
}}
>
Speed: {playbackRate}x
</Typography>
</MenuItem>
<MenuItem onClick={togglePictureInPicture}>
<PictureInPicture />
</MenuItem>
<MenuItem onClick={toggleFullscreen}>
<Fullscreen />
</MenuItem>
</Menu>
</>
) : canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<Typography
sx={{
fontSize: '14px',
marginRight: '5px',
color: 'rgba(255, 255, 255, 0.7)',
visibility:
!videoRef.current?.duration || !progress
? 'hidden'
: 'visible'
}}
>
{progress && videoRef.current?.duration && formatTime(progress)}/
{progress &&
videoRef.current?.duration &&
formatTime(videoRef.current?.duration)}
</Typography>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginRight: '10px'
}}
onClick={toggleMute}
>
{isMuted ? <VolumeOff /> : <VolumeUp />}
</IconButton>
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
sx={{
maxWidth: '100px'
}}
/>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px',
marginLeft: '5px'
}}
onClick={(e) => increaseSpeed()}
>
Speed: {playbackRate}x
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
ref={toggleRef}
onClick={togglePictureInPicture}
>
<PictureInPicture />
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={toggleFullscreen}
>
<Fullscreen />
</IconButton>
</>
) : null}
</ControlsContainer>
</VideoContainer>
)
}

125
src/components/layout/Navbar/Navbar-styles.tsx

@ -0,0 +1,125 @@
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"
},
height: '55px'
}));
export const LogoContainer = styled("div")({
cursor: 'pointer',
height: '100%',
display: 'flex',
alignItems: 'center'
});
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,
height: '100%'
});
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))"
}
}));

434
src/components/layout/Navbar/Navbar.tsx

@ -0,0 +1,434 @@
import React, { useState, useRef } from "react";
import { Box, Button, Input, Popover, useTheme } from "@mui/material";
import ExitToAppIcon from "@mui/icons-material/ExitToApp";
import { BlockedNamesModal } from "../../common/BlockedNamesModal/BlockedNamesModal";
import AddBoxIcon from "@mui/icons-material/AddBox";
import {
AvatarContainer,
CustomAppBar,
DropdownContainer,
DropdownText,
AuthenticateButton,
NavbarName,
LightModeIcon,
DarkModeIcon,
ThemeSelectRow,
LogoContainer,
} from "./Navbar-styles";
import { AccountCircleSVG } from "../../../assets/svgs/AccountCircleSVG";
import BackspaceIcon from "@mui/icons-material/Backspace";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import PersonOffIcon from "@mui/icons-material/PersonOff";
import { useNavigate } from "react-router-dom";
import SearchIcon from "@mui/icons-material/Search";
import { DownloadTaskManager } from "../../common/DownloadTaskManager";
import QShareLogo from "../../../assets/img/q-share-icon.webp";
import { useDispatch, useSelector } from "react-redux";
import {
addFilteredVideos,
setEditPlaylist,
setFilterValue,
setIsFiltering,
} from "../../../state/features/videoSlice";
import { RootState } from "../../../state/store";
import { useWindowSize } from "../../../hooks/useWindowSize";
import { UploadVideo } from "../../UploadVideo/UploadVideo";
import { StyledButton } from "../../UploadVideo/Upload-styles";
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 windowSize = useWindowSize();
const searchValRef = useRef("");
const inputRef = useRef<HTMLInputElement>(null);
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
null
);
const [openUserDropdown, setOpenUserDropdown] = useState<boolean>(false);
const [isOpenBlockedNamesModal, setIsOpenBlockedNamesModal] =
useState<boolean>(false);
const [anchorElNotification, setAnchorElNotification] =
React.useState<HTMLButtonElement | null>(null);
const filterValue = useSelector(
(state: RootState) => state.video.filterValue
);
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.currentTarget as unknown as HTMLButtonElement | null;
setAnchorEl(target);
};
const openNotificationPopover = (event: any) => {
const target = event.currentTarget as unknown as HTMLButtonElement | null;
setAnchorElNotification(target);
};
const closeNotificationPopover = () => {
setAnchorElNotification(null);
};
const openPopover = Boolean(anchorElNotification);
const idNotification = openPopover
? "simple-popover-notification"
: undefined;
const handleCloseUserDropdown = () => {
setAnchorEl(null);
setOpenUserDropdown(false);
};
const onCloseBlockedNames = () => {
setIsOpenBlockedNamesModal(false);
};
return (
<CustomAppBar position="sticky" elevation={2}>
<ThemeSelectRow>
<LogoContainer
onClick={() => {
navigate("/");
dispatch(setIsFiltering(false));
dispatch(setFilterValue(""));
dispatch(addFilteredVideos([]));
searchValRef.current = "";
if (!inputRef.current) return;
inputRef.current.value = "";
}}
>
<img
src={QShareLogo}
style={{
width: "auto",
height: "55px",
padding: "2px",
}}
/>
</LogoContainer>
</ThemeSelectRow>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{/* {windowSize.width <= 600 ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1
}}
className="myClassOver600"
>
<Box onClick={openNotificationPopover}>
<SearchIcon
sx={{
cursor: 'pointer',
display: 'flex'
}}
/>
</Box>
{filterValue && (
<BackspaceIcon
sx={{
cursor: 'pointer'
}}
onClick={() => {
dispatch(setIsFiltering(false))
dispatch(setFilterValue(''))
dispatch(addFilteredVideos([]))
searchValRef.current = ''
if (!inputRef.current) return
inputRef.current.value = ''
}}
/>
)}
</Box>
): (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1
}}
className="myClassUnder600"
>
<Input
id="standard-adornment-name"
inputRef={inputRef}
onChange={(e) => {
searchValRef.current = e.target.value
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.keyCode === 13) {
if (!searchValRef.current) {
dispatch(setIsFiltering(false))
dispatch(setFilterValue(''))
dispatch(addFilteredVideos([]))
searchValRef.current = ''
if (!inputRef.current) return
inputRef.current.value = ''
return
}
navigate('/')
dispatch(setIsFiltering(true))
dispatch(addFilteredVideos([]))
dispatch(setFilterValue(searchValRef.current))
}
}}
placeholder="Search"
sx={{
'&&:before': {
borderBottom: 'none'
},
'&&:after': {
borderBottom: 'none'
},
'&&:hover:before': {
borderBottom: 'none'
},
'&&.Mui-focused:before': {
borderBottom: 'none'
},
'&&.Mui-focused': {
outline: 'none'
},
fontSize: '18px'
}}
/>
<SearchIcon
sx={{
cursor: 'pointer'
}}
onClick={() => {
if (!searchValRef.current) {
dispatch(setIsFiltering(false))
dispatch(setFilterValue(''))
dispatch(addFilteredVideos([]))
searchValRef.current = ''
if (!inputRef.current) return
inputRef.current.value = ''
return
}
navigate('/')
dispatch(setIsFiltering(true))
dispatch(addFilteredVideos([]))
dispatch(setFilterValue(searchValRef.current))
}}
/>
{filterValue && (
<BackspaceIcon
sx={{
cursor: 'pointer'
}}
onClick={() => {
dispatch(setIsFiltering(false))
dispatch(setFilterValue(''))
dispatch(addFilteredVideos([]))
searchValRef.current = ''
if (!inputRef.current) return
inputRef.current.value = ''
}}
/>
)}
</Box>
)} */}
<Popover
id={idNotification}
open={openPopover}
anchorEl={anchorElNotification}
onClose={closeNotificationPopover}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
padding: "5px",
}}
>
<Input
id="standard-adornment-name"
inputRef={inputRef}
onChange={(e) => {
searchValRef.current = e.target.value;
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.keyCode === 13) {
if (!searchValRef.current) {
dispatch(setIsFiltering(false));
dispatch(setFilterValue(""));
dispatch(addFilteredVideos([]));
searchValRef.current = "";
if (!inputRef.current) return;
inputRef.current.value = "";
return;
}
navigate("/");
dispatch(setIsFiltering(true));
dispatch(addFilteredVideos([]));
dispatch(setFilterValue(searchValRef.current));
}
}}
placeholder="Search"
sx={{
"&&:before": {
borderBottom: "none",
},
"&&:after": {
borderBottom: "none",
},
"&&:hover:before": {
borderBottom: "none",
},
"&&.Mui-focused:before": {
borderBottom: "none",
},
"&&.Mui-focused": {
outline: "none",
},
fontSize: "18px",
}}
/>
<SearchIcon
sx={{
cursor: "pointer",
}}
onClick={() => {
if (!searchValRef.current) {
dispatch(setIsFiltering(false));
dispatch(setFilterValue(""));
dispatch(addFilteredVideos([]));
searchValRef.current = "";
if (!inputRef.current) return;
inputRef.current.value = "";
return;
}
navigate("/");
dispatch(setIsFiltering(true));
dispatch(addFilteredVideos([]));
dispatch(setFilterValue(searchValRef.current));
}}
/>
<BackspaceIcon
sx={{
cursor: "pointer",
}}
onClick={() => {
dispatch(setIsFiltering(false));
dispatch(setFilterValue(""));
dispatch(addFilteredVideos([]));
searchValRef.current = "";
if (!inputRef.current) return;
inputRef.current.value = "";
}}
/>
</Box>
</Popover>
<DownloadTaskManager />
{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>
</>
)}
<AvatarContainer>
{isAuthenticated && userName && (
<>
<UploadVideo />
</>
)}
</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;

220
src/constants/index.ts

@ -0,0 +1,220 @@
const useTestIdentifiers = true;
export const QTUBE_VIDEO_BASE = useTestIdentifiers
? "MYTEST_share_vid_"
: "qshare_file_";
export const QTUBE_PLAYLIST_BASE = useTestIdentifiers
? "MYTEST_share_playlist_"
: "qshare_playlist_";
export const COMMENT_BASE = useTestIdentifiers
? "qcomment_v1_MYTEST_"
: "qcomment_v1_qshare_";
interface SubCategory {
id: number;
name: string;
}
interface CategoryMap {
[key: number]: SubCategory[];
}
export const categories = [
{"id": 1, "name": "Software"},
{"id": 2, "name": "Gaming"},
{"id": 3, "name": "Media"}
];
export const subCategories: CategoryMap = {
1: [
{"id": 101, "name": "OS"},
{"id": 102, "name": "Application"},
{"id": 103, "name": "Source Code"},
{"id": 104, "name": "Other"}
],
2: [
{"id": 201, "name": "NES"},
{"id": 202, "name": "SNES"},
{"id": 203, "name": "PC"},
{"id": 204, "name": "Other Gaming Systems"}
],
3: [
{"id": 301, "name": "Audio"},
{"id": 302, "name": "Video"},
{"id": 303, "name": "Image"},
{"id": 304, "name": "Document"},
{"id": 305, "name": "Other Media Formats"}
]
};
export const subCategories2: CategoryMap = {
201: [ // NES
{"id": 20101, "name": "ROM"},
{"id": 20102, "name": "Romhack"},
{"id": 20103, "name": "Emulator"},
],
202: [ // SNES
{"id": 20201, "name": "ROM"},
{"id": 20202, "name": "Romhack"},
{"id": 20203, "name": "Emulator"},
],
301: [ // Audio
{"id": 30101, "name": "Music"},
{"id": 30102, "name": "Podcasts"},
{"id": 30103, "name": "Audiobooks"},
{"id": 30104, "name": "Sound Effects"},
{"id": 30105, "name": "Lectures & Speeches"},
{"id": 30106, "name": "Radio Shows"},
{"id": 30107, "name": "Ambient Sounds"},
{"id": 30108, "name": "Language Learning Material"},
{"id": 30109, "name": "Comedy & Satire"},
{"id": 30110, "name": "Documentaries"},
{"id": 30111, "name": "Guided Meditations & Yoga"},
{"id": 30112, "name": "Live Performances"},
{"id": 30113, "name": "Nature Sounds"},
{"id": 30114, "name": "Soundtracks"},
{"id": 30115, "name": "Interviews"}
],
302: [ // Under Video
{"id": 30201, "name": "Movies"},
{"id": 30202, "name": "Series"},
{"id": 30203, "name": "Music"},
{"id": 30204, "name": "Education"},
{"id": 30205, "name": "Lifestyle"},
{"id": 30206, "name": "Gaming"},
{"id": 30207, "name": "Technology"},
{"id": 30208, "name": "Sports"},
{"id": 30209, "name": "News & Politics"},
{"id": 30210, "name": "Cooking & Food"},
{"id": 30211, "name": "Animation"},
{"id": 30212, "name": "Science"},
{"id": 30213, "name": "Health & Wellness"},
{"id": 30214, "name": "DIY & Crafts"},
{"id": 30215, "name": "Kids & Family"},
{"id": 30216, "name": "Comedy"},
{"id": 30217, "name": "Travel & Adventure"},
{"id": 30218, "name": "Art & Design"},
{"id": 30219, "name": "Nature & Environment"},
{"id": 30220, "name": "Business & Finance"},
{"id": 30221, "name": "Personal Development"},
{"id": 30222, "name": "Other"},
{"id": 30223, "name": "History"}
],
303: [ // Image
{"id": 30301, "name": "Nature"},
{"id": 30302, "name": "Urban & Cityscapes"},
{"id": 30303, "name": "People & Portraits"},
{"id": 30304, "name": "Art & Abstract"},
{"id": 30305, "name": "Travel & Adventure"},
{"id": 30306, "name": "Animals & Wildlife"},
{"id": 30307, "name": "Sports & Action"},
{"id": 30308, "name": "Food & Cuisine"},
{"id": 30309, "name": "Fashion & Beauty"},
{"id": 30310, "name": "Technology & Science"},
{"id": 30311, "name": "Historical & Cultural"},
{"id": 30312, "name": "Aerial & Drone"},
{"id": 30313, "name": "Black & White"},
{"id": 30314, "name": "Events & Celebrations"},
{"id": 30315, "name": "Business & Corporate"},
{"id": 30316, "name": "Health & Wellness"},
{"id": 30317, "name": "Transportation & Vehicles"},
{"id": 30318, "name": "Still Life & Objects"},
{"id": 30319, "name": "Architecture & Buildings"},
{"id": 30320, "name": "Landscapes & Seascapes"}
],
304: [ // Document
{"id": 30401, "name": "PDF"},
{"id": 30402, "name": "Word Document"},
{"id": 30403, "name": "Spreadsheet"},
{"id": 30404, "name": "Powerpoint"},
{"id": 30405, "name": "Books"}
]
};
export const subCategories3: CategoryMap = {
30201: [ // Under Movies
{"id": 3020101, "name": "Action & Adventure"},
{"id": 3020102, "name": "Comedy"},
{"id": 3020103, "name": "Drama"},
{"id": 3020104, "name": "Fantasy & Science Fiction"},
{"id": 3020105, "name": "Horror & Thriller"},
{"id": 3020106, "name": "Documentaries"},
{"id": 3020107, "name": "Animated"},
{"id": 3020108, "name": "Family & Kids"},
{"id": 3020109, "name": "Romance"},
{"id": 3020110, "name": "Mystery & Crime"},
{"id": 3020111, "name": "Historical & War"},
{"id": 3020112, "name": "Musicals & Music Films"},
{"id": 3020113, "name": "Indie Films"},
{"id": 3020114, "name": "International Films"},
{"id": 3020115, "name": "Biographies & True Stories"},
{"id": 3020116, "name": "Other"}
],
30202: [ // Under Series
{"id": 3020201, "name": "Dramas"},
{"id": 3020202, "name": "Comedies"},
{"id": 3020203, "name": "Reality & Competition"},
{"id": 3020204, "name": "Documentaries & Docuseries"},
{"id": 3020205, "name": "Sci-Fi & Fantasy"},
{"id": 3020206, "name": "Crime & Mystery"},
{"id": 3020207, "name": "Animated Series"},
{"id": 3020208, "name": "Kids & Family"},
{"id": 3020209, "name": "Historical & Period Pieces"},
{"id": 3020210, "name": "Action & Adventure"},
{"id": 3020211, "name": "Horror & Thriller"},
{"id": 3020212, "name": "Romance"},
{"id": 3020213, "name": "Anthologies"},
{"id": 3020214, "name": "International Series"},
{"id": 3020215, "name": "Miniseries"},
{"id": 3020216, "name": "Other"}
],
30405: [ // Under Books
{"id": 3040501, "name": "Fiction"},
{"id": 3040502, "name": "Non-Fiction"},
{"id": 3040503, "name": "Science Fiction & Fantasy"},
{"id": 3040504, "name": "Biographies & Memoirs"},
{"id": 3040505, "name": "Children's Books"},
{"id": 3040506, "name": "Educational"},
{"id": 3040507, "name": "Self-Help"},
{"id": 3040508, "name": "Cookbooks, Food & Wine"},
{"id": 3040509, "name": "Mystery & Thriller"},
{"id": 3040510, "name": "History"},
{"id": 3040511, "name": "Poetry"},
{"id": 3040512, "name": "Art & Photography"},
{"id": 3040513, "name": "Religion & Spirituality"},
{"id": 3040514, "name": "Travel"},
{"id": 3040515, "name": "Comics & Graphic Novels"},
],
30101: [ // Under Music
{"id": 3010101, "name": "Rock"},
{"id": 3010102, "name": "Pop"},
{"id": 3010103, "name": "Classical"},
{"id": 3010104, "name": "Jazz"},
{"id": 3010105, "name": "Electronic"},
{"id": 3010106, "name": "Country"},
{"id": 3010107, "name": "Hip Hop/Rap"},
{"id": 3010108, "name": "Blues"},
{"id": 3010109, "name": "R&B/Soul"},
{"id": 3010110, "name": "Reggae"},
{"id": 3010111, "name": "Folk"},
{"id": 3010112, "name": "Metal"},
{"id": 3010113, "name": "World Music"},
{"id": 3010114, "name": "Latin"},
{"id": 3010115, "name": "Indie"},
{"id": 3010116, "name": "Punk"},
{"id": 3010117, "name": "Soundtracks"},
{"id": 3010118, "name": "Children's Music"},
{"id": 3010119, "name": "New Age"},
{"id": 3010120, "name": "Classical Crossover"}
]
};

62
src/global.d.ts vendored

@ -0,0 +1,62 @@
// 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
name?: string
mode?: string
query?: string
excludeBlocked?: boolean
exactMatchNames?: boolean
}
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>
}
}

393
src/hooks/useFetchVideos.tsx

@ -0,0 +1,393 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
addVideos,
addToHashMap,
setCountNewVideos,
upsertVideos,
upsertVideosBeginning,
Video,
upsertFilteredVideos
} from '../state/features/videoSlice'
import {
setIsLoadingGlobal, setUserAvatarHash
} from '../state/features/globalSlice'
import { RootState } from '../state/store'
import { fetchAndEvaluateVideos } from '../utils/fetchVideos'
import { QTUBE_PLAYLIST_BASE, QTUBE_VIDEO_BASE } from '../constants'
import { RequestQueue } from '../utils/queue'
import { queue } from '../wrappers/GlobalWrapper'
export const useFetchVideos = () => {
const dispatch = useDispatch()
const hashMapVideos = useSelector(
(state: RootState) => state.video.hashMapVideos
)
const videos = useSelector((state: RootState) => state.video.videos)
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
)
const filteredVideos = useSelector(
(state: RootState) => state.video.filteredVideos
)
const checkAndUpdateVideo = React.useCallback(
(video: Video) => {
const existingVideo = hashMapVideos[video.id]
if (!existingVideo) {
return true
} else if (
video?.updated &&
existingVideo?.updated &&
(!existingVideo?.updated || video?.updated) > existingVideo?.updated
) {
return true
} else {
return false
}
},
[hashMapVideos]
)
const getAvatar = React.useCallback(async (author: string) => {
try {
let url = await qortalRequest({
action: 'GET_QDN_RESOURCE_URL',
name: author,
service: 'THUMBNAIL',
identifier: 'qortal_avatar'
})
dispatch(setUserAvatarHash({
name: author,
url
}))
} catch (error) { }
}, [])
const getVideo = async (user: string, videoId: string, content: any, retries: number = 0) => {
try {
const res = await fetchAndEvaluateVideos({
user,
videoId,
content
})
dispatch(addToHashMap(res))
} catch (error) {
retries= retries + 1
if (retries < 2) { // 3 is the maximum number of retries here, you can adjust it to your needs
queue.push(() => getVideo(user, videoId, content, retries + 1));
} else {
console.error('Failed to get video after 3 attempts', error);
}
}
}
const getNewVideos = React.useCallback(async () => {
try {
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${QTUBE_VIDEO_BASE}&limit=20&includemetadata=false&reverse=true&excludeblocked=true&exactmatchnames=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
// const responseData = await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// mode: "ALL",
// service: "DOCUMENT",
// query: "${QTUBE_VIDEO_BASE}",
// limit: 20,
// includeMetadata: true,
// reverse: true,
// excludeBlocked: true,
// exactMatchNames: true,
// name: names
// })
const latestVideo = videos[0]
if (!latestVideo) return
const findVideo = responseData?.findIndex(
(item: any) => item?.identifier === latestVideo?.id
)
let fetchAll = responseData
let willFetchAll = true
if (findVideo !== -1) {
willFetchAll = false
fetchAll = responseData.slice(0, findVideo)
}
const structureData = fetchAll.map((video: any): Video => {
return {
title: video?.metadata?.title,
category: video?.metadata?.category,
categoryName: video?.metadata?.categoryName,
tags: video?.metadata?.tags || [],
description: video?.metadata?.description,
created: video?.created,
updated: video?.updated,
user: video.name,
videoImage: '',
id: video.identifier
}
})
if (!willFetchAll) {
dispatch(upsertVideosBeginning(structureData))
}
if (willFetchAll) {
dispatch(addVideos(structureData))
}
setTimeout(()=> {
dispatch(setCountNewVideos(0))
}, 1000)
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdateVideo(content)
if (res) {
queue.push(() => getVideo(content.user, content.id, content));
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [videos, hashMapVideos])
const getVideos = React.useCallback(async (filters = {}, reset?:boolean, resetFilers?: boolean,limit?: number) => {
try {
const {name = '',
category = '',
subcategory = '',
subcategory2 = '',
subcategory3 = '',
keywords = '',
type = '' }: any = resetFilers ? {} : filters
let offset = videos.length
if(reset){
offset = 0
}
const videoLimit = limit || 20
let defaultUrl = `/arbitrary/resources/search?mode=ALL&includemetadata=false&reverse=true&excludeblocked=true&exactmatchnames=true&offset=${offset}&limit=${videoLimit}`;
if (name) {
defaultUrl += `&name=${name}`;
}
if (category) {
// Start with the category
let description = `cat:${category}`;
// Check and append subcategory
if (subcategory) {
description += `;sub:${subcategory}`;
}
// Check and append subcategory2
if (subcategory2) {
description += `;sub2:${subcategory2}`;
}
// Check and append subcategory3
if (subcategory3) {
description += `;sub3:${subcategory3}`;
}
// Append the description to the URL
defaultUrl += `&description=${description}`;
}
if(keywords){
defaultUrl = defaultUrl + `&query=${keywords}`
}
if(type === 'playlists'){
defaultUrl = defaultUrl + `&service=PLAYLIST`
defaultUrl = defaultUrl + `&identifier=${QTUBE_PLAYLIST_BASE}`
} else {
defaultUrl = defaultUrl + `&service=DOCUMENT`
defaultUrl = defaultUrl + `&identifier=${QTUBE_VIDEO_BASE}`
}
// const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${QTUBE_VIDEO_BASE}&limit=${videoLimit}&includemetadata=false&reverse=true&excludeblocked=true&exactmatchnames=true&offset=${offset}`
const url = defaultUrl
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
// const responseData = await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// mode: "ALL",
// service: "DOCUMENT",
// query: "${QTUBE_VIDEO_BASE}",
// limit: 20,
// includeMetadata: true,
// offset: offset,
// reverse: true,
// excludeBlocked: true,
// exactMatchNames: true,
// name: names
// })
const structureData = responseData.map((video: any): Video => {
return {
title: video?.metadata?.title,
service: video?.service,
category: video?.metadata?.category,
categoryName: video?.metadata?.categoryName,
tags: video?.metadata?.tags || [],
description: video?.metadata?.description,
created: video?.created,
updated: video?.updated,
user: video.name,
videoImage: '',
id: video.identifier
}
})
if(reset){
dispatch(addVideos(structureData))
} else {
dispatch(upsertVideos(structureData))
}
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdateVideo(content)
if (res) {
queue.push(() => getVideo(content.user, content.id, content));
}
}
}
} catch (error) {
console.log({error})
} finally {
}
}, [videos, hashMapVideos])
const getVideosFiltered = React.useCallback(async (filterValue: string) => {
try {
const offset = filteredVideos.length
const replaceSpacesWithUnderscore = filterValue.replace(/ /g, '_');
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${replaceSpacesWithUnderscore}&identifier=${QTUBE_VIDEO_BASE}&limit=10&includemetadata=false&reverse=true&excludeblocked=true&exactmatchnames=true&offset=${offset}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
// const responseData = await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// mode: "ALL",
// service: "DOCUMENT",
// query: replaceSpacesWithUnderscore,
// identifier: "${QTUBE_VIDEO_BASE}",
// limit: 20,
// includeMetadata: true,
// offset: offset,
// reverse: true,
// excludeBlocked: true,
// exactMatchNames: true,
// name: names
// })
const structureData = responseData.map((video: any): Video => {
return {
title: video?.metadata?.title,
category: video?.metadata?.category,
categoryName: video?.metadata?.categoryName,
tags: video?.metadata?.tags || [],
description: video?.metadata?.description,
created: video?.created,
updated: video?.updated,
user: video.name,
videoImage: '',
id: video.identifier
}
})
dispatch(upsertFilteredVideos(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdateVideo(content)
if (res) {
queue.push(() => getVideo(content.user, content.id, content));
}
}
}
} catch (error) {
} finally {
}
}, [filteredVideos, hashMapVideos])
const checkNewVideos = React.useCallback(async () => {
try {
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${QTUBE_VIDEO_BASE}&limit=20&includemetadata=false&reverse=true&excludeblocked=true&exactmatchnames=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
// const responseData = await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// mode: "ALL",
// service: "DOCUMENT",
// query: "${QTUBE_VIDEO_BASE}",
// limit: 20,
// includeMetadata: true,
// reverse: true,
// excludeBlocked: true,
// exactMatchNames: true,
// name: names
// })
const latestVideo = videos[0]
if (!latestVideo) return
const findVideo = responseData?.findIndex(
(item: any) => item?.identifier === latestVideo?.id
)
if (findVideo === -1) {
dispatch(setCountNewVideos(responseData.length))
return
}
const newArray = responseData.slice(0, findVideo)
dispatch(setCountNewVideos(newArray.length))
return
} catch (error) {}
}, [videos])
return {
getVideos,
checkAndUpdateVideo,
getVideo,
hashMapVideos,
getNewVideos,
checkNewVideos,
getVideosFiltered
}
}

25
src/hooks/useWindowSize.tsx

@ -0,0 +1,25 @@
import { useState, useEffect } from 'react';
export function useWindowSize() {
const [windowSize, setWindowSize] = useState<any>({
width: undefined,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
});
}
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array means that effect doesn't depend on any values from props or state, so it runs once when the component mounts, and never re-runs.
return windowSize;
}

229
src/index.css

@ -0,0 +1,229 @@
@font-face {
font-family: 'Cambon Light';
src: url("./styles/fonts/Cambon-Light.ttf") format("truetype");
}
@font-face {
font-family: 'Merriweather Sans';
src: url("./styles/fonts/Merriweather Sans.ttf") format("truetype");
}
@font-face {
font-family: 'Karla';
src: url("./styles/fonts/Karla.ttf") format("truetype");
}
@font-face {
font-family: 'Proxima Nova';
src: url("./styles/fonts/ProximaNova.otf") format("opentype");
}
@font-face {
font-family: 'Raleway';
src: url("./styles/fonts/Raleway.ttf") format("truetype");
}
@font-face {
font-family: 'Catamaran';
src: url("./styles/fonts/Catamaran.ttf") format("truetype");
}
@font-face {
font-family: 'Oxygen';
src: url("./styles/fonts/Oxygen.ttf") format("truetype");
}
@font-face {
font-family: 'Cairo';
src: url("./styles/fonts/Cairo.ttf") format("truetype");
}
:root {
padding: 0px;
margin: 0px;
box-sizing: border-box;
}
.line-clamp {
height: 100px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 5; /* number of lines to show */
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
.edit-btn:hover {
opacity: .75;
transition: .2s all;
}
.post-image {
max-width: 100%;
border-radius: 5px;
width: 100%;
height: 100%;
}
.test-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
min-height: 25px;
}
.test-grid-item {
border: 1px solid powderblue;
}
body::-webkit-scrollbar-track {
background-color: transparent;
}
body::-webkit-scrollbar-track:hover {
background-color: transparent;
}
body::-webkit-scrollbar {
width: 16px;
height: 10px;
background-color: white;
}
body::-webkit-scrollbar-thumb {
background-color: #838eee;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}
body::-webkit-scrollbar-thumb:hover {
background-color: #6270f0;
}
.MuiList-root::-webkit-scrollbar-track {
background-color: transparent;
}
.MuiList-root::-webkit-scrollbar-track:hover {
background-color: transparent;
}
.MuiList-root::-webkit-scrollbar {
width: 14px;
height: 10px;
background-color: white;
}
.MuiList-root::-webkit-scrollbar-thumb {
background-color: lightgray;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}
.MuiList-root::-webkit-scrollbar-thumb:hover {
background-color: lightslategray;
}
.my-masonry-grid {
display: -webkit-box; /* Not needed if autoprefixing */
display: -ms-flexbox; /* Not needed if autoprefixing */
display: flex;
margin-left: -20px; /* gutter size offset */
width: auto;
padding: 15px 20px;
}
.my-masonry-grid_column {
padding-left: 20px; /* gutter size */
background-clip: padding-box;
}
/* Style your items */
.my-masonry-grid_column > li { /* change div to reference your elements you put in <Masonry> */
margin-bottom: 30px;
}
.my-svg path {
fill: red
}
.qortal-link {
text-decoration: none; /* Removes the underline */
color: inherit; /* Inherits the color of the parent element */
}
.qortal-link:hover, a:focus {
text-decoration: underline; /* Adds underline on hover and focus for accessibility */
}
.download-icon {
transition: all 0.5s ease-in-out;
animation: downloadIconAnimation 2s infinite;
}
@keyframes downloadIconAnimation {
0% { transform: scale(1); fill: #fff; }
50% { transform: scale(1.2); fill: #3498db; }
100% { transform: scale(1); fill: #fff; }
}
.closePlayer {
position: absolute;
top: 0px;
width: 100%;
transition: all 0.3s;
display: flex;
justify-content: flex-end;
z-index: 8000;
}
/* When the screen is 600px or less, display .myClassUnder600 and hide .myClassOver600 */
@media screen and (max-width: 600px) {
.myClassUnder600 {
display: none !important;
}
}
@media screen and (min-width: 601px) {
.myClassOver600 {
display: none !important;
}
}
.ql-editor {
min-height: 100px;
width: 100%
}
.ql-editor img {
cursor: default;
}
.ql-container {
font-size: 16px
}
.hover-click {
transition: opacity 0.2s;
}
.hover-click:hover {
opacity: 0.7;
}

17
src/main.tsx

@ -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: string
}
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>
)

77
src/pages/Home/Channels.tsx

@ -0,0 +1,77 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import {
Avatar,
Box,
Button,
Typography,
useTheme
} from '@mui/material'
import { useFetchVideos } from '../../hooks/useFetchVideos'
import LazyLoad from '../../components/common/LazyLoad'
import { BottomParent, NameContainer, VideoCard, VideoCardName, VideoCardTitle, VideoContainer, VideoUploadDate } from './VideoList-styles'
import ResponsiveImage from '../../components/ResponsiveImage'
import { formatDate, formatTimestampSeconds } from '../../utils/time'
import { ChannelCard, ChannelTitle } from './Home-styles'
interface VideoListProps {
mode?: string
}
export const Channels = ({ mode }: VideoListProps) => {
const theme = useTheme()
const navigate = useNavigate()
const publishNames = useSelector((state: RootState)=> state.global.publishNames)
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
)
return (
<Box sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minHeight: '50vh'
}}>
<VideoContainer>
{publishNames && publishNames?.slice(0, 10).map((name)=> {
let avatarUrl = ''
if(userAvatarHash[name]){
avatarUrl = userAvatarHash[name]
}
return (
<Box
sx={{
display: 'flex',
flex: 0,
alignItems: 'center',
width: 'auto',
position: 'relative',
' @media (max-width: 450px)': {
width: '100%'
}
}}
key={name}
>
<ChannelCard
onClick={() => {
navigate(`/channel/${name}`)
}}
>
<ChannelTitle>{name}</ChannelTitle>
<ResponsiveImage src={avatarUrl} width={50} height={50}/>
</ChannelCard>
</Box>
)
})}
</VideoContainer>
</Box>
)
}

87
src/pages/Home/Home-styles.tsx

@ -0,0 +1,87 @@
import { styled } from "@mui/system";
import { Box, Grid, Typography, Checkbox } from "@mui/material";
export const SubtitleContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: 'center',
margin: '10px 0px',
width: '100%'
}));
export const Subtitle = styled(Typography)(({ theme }) => ({
textAlign: 'center',
fontSize: '20px'
}));
const DoubleLine = styled(Typography)`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
`
export const ChannelTitle = styled(DoubleLine)(({ theme }) => ({
fontFamily: "Cairo",
fontSize: "20px",
letterSpacing: "0.4px",
color: theme.palette.text.primary,
userSelect: "none",
marginBottom: 'auto',
textAlign: 'center'
}));
export const WelcomeTitle = styled(DoubleLine)(({ theme }) => ({
fontFamily: "Cairo",
fontSize: "24px",
letterSpacing: "0.4px",
color: theme.palette.text.primary,
userSelect: "none",
textAlign: 'center'
}));
export const WelcomeContainer = styled(Box)(({ theme }) => ({
position: 'fixed',
width: '90%',
height: '90%',
backgroundColor: theme.palette.background.paper,
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 500,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}));
export const ChannelCard = styled(Grid)(({ theme }) => ({
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: 'center',
height: "auto",
width: '300px',
minHeight: '130px',
backgroundColor: theme.palette.background.paper,
borderRadius: "8px",
padding: "10px 15px",
gap: "20px",
border:
theme.palette.mode === "dark"
? "none"
: `1px solid ${theme.palette.primary.light}`,
boxShadow:
theme.palette.mode === "dark"
? "0px 4px 5px 0px hsla(0,0%,0%,0.14), 0px 1px 10px 0px hsla(0,0%,0%,0.12), 0px 2px 4px -1px hsla(0,0%,0%,0.2)"
: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
boxShadow:
theme.palette.mode === "dark"
? "0px 8px 10px 1px hsla(0,0%,0%,0.14), 0px 3px 14px 2px hsla(0,0%,0%,0.12), 0px 5px 5px -3px hsla(0,0%,0%,0.2)"
: "rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;"
}
}));

15
src/pages/Home/Home.tsx

@ -0,0 +1,15 @@
import React from 'react'
import { VideoList } from './VideoList'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
export const Home = () => {
return (
<>
<VideoList />
</>
)
}

283
src/pages/Home/VideoList-styles.tsx

@ -0,0 +1,283 @@
import { styled } from "@mui/system";
import { Box, Grid, Typography, Checkbox, TextField, InputLabel, Autocomplete } from "@mui/material";
export const VideoContainer = styled(Box)(({ theme }) => ({
position: "relative",
display: "flex",
padding: "15px",
flexDirection: "row",
gap: "20px",
flexWrap: "wrap",
justifyContent: "flex-start",
width: '100%'
}));
export const StoresRow = styled(Grid)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
gap: "15px",
width: "auto",
position: "relative",
"@media (max-width: 450px)": {
width: "100%"
}
}));
export const VideoCard = styled(Grid)(({ theme }) => ({
position: "relative",
display: "flex",
flexDirection: "column",
height: "320px",
width: '300px',
backgroundColor: theme.palette.background.paper,
borderRadius: "8px",
padding: "10px 15px",
gap: "20px",
cursor: "pointer",
border:
theme.palette.mode === "dark"
? "none"
: `1px solid ${theme.palette.primary.light}`,
boxShadow:
theme.palette.mode === "dark"
? "0px 4px 5px 0px hsla(0,0%,0%,0.14), 0px 1px 10px 0px hsla(0,0%,0%,0.12), 0px 2px 4px -1px hsla(0,0%,0%,0.2)"
: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px",
transition: "all 0.3s ease-in-out",
"&:hover": {
boxShadow:
theme.palette.mode === "dark"
? "0px 8px 10px 1px hsla(0,0%,0%,0.14), 0px 3px 14px 2px hsla(0,0%,0%,0.12), 0px 5px 5px -3px hsla(0,0%,0%,0.2)"
: "rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;"
}
}));
export const StoreCardInfo = styled(Grid)(({ theme }) => ({
display: "flex",
flexDirection: "column",
gap: "10px",
padding: "5px",
marginTop: "15px"
}));
export const VideoImageContainer = styled(Grid)(({ theme }) => ({}));
export const VideoCardImage = styled("img")(({ theme }) => ({
maxWidth: "300px",
minWidth: "150px",
borderRadius: "5px",
height: '150px',
objectFit: 'fill',
width: '266px',
}));
const DoubleLine = styled(Typography)`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
`
export const VideoCardTitle = styled(DoubleLine)(({ theme }) => ({
fontFamily: "Cairo",
fontSize: "16px",
letterSpacing: "0.4px",
color: theme.palette.text.primary,
userSelect: "none"
}));
export const VideoCardName = styled(Typography)(({ theme }) => ({
fontFamily: "Cairo",
fontSize: "14px",
letterSpacing: "0.4px",
color: theme.palette.text.primary,
userSelect: "none",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
width: "100%",
}));
export const VideoUploadDate = styled(Typography)(({ theme }) => ({
fontFamily: "Cairo",
fontSize: "12px",
letterSpacing: "0.4px",
color: theme.palette.text.primary,
userSelect: "none"
}));
export const BottomParent = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'flex-start',
flexDirection: 'column'
}));
export const VideoCardDescription = styled(Typography)(({ theme }) => ({
fontFamily: "Karla",
fontSize: "20px",
letterSpacing: "0px",
color: theme.palette.text.primary,
userSelect: "none"
}));
export const StoreCardOwner = styled(Typography)(({ theme }) => ({
fontFamily: "Livvic",
color: theme.palette.text.primary,
fontSize: "17px",
position: "absolute",
bottom: "5px",
right: "10px",
userSelect: "none"
}));
export const StoreCardYouOwn = styled(Box)(({ theme }) => ({
position: "absolute",
top: "5px",
right: "10px",
display: "flex",
alignItems: "center",
gap: "5px",
fontFamily: "Livvic",
fontSize: "15px",
color: theme.palette.text.primary
}));
export const MyStoresRow = styled(Grid)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "flex-end",
padding: "5px",
width: "100%"
}));
export const NameContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
alignItems: 'center',
gap: '10px',
marginBottom: '10px'
}));
export const MyStoresCard = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
alignItems: "center",
width: "auto",
borderRadius: "4px",
backgroundColor: theme.palette.background.paper,
padding: "5px 10px",
fontFamily: "Raleway",
fontSize: "18px",
color: theme.palette.text.primary
}));
export const MyStoresCheckbox = styled(Checkbox)(({ theme }) => ({
color: "#c0d4ff",
"&.Mui-checked": {
color: "#6596ff"
}
}));
export const FiltersCol = styled(Grid)(({ theme }) => ({
display: "flex",
flexDirection: "column",
height: "100%",
padding: "20px 15px",
backgroundColor: theme.palette.background.default,
borderTop: `1px solid ${theme.palette.background.paper}`,
borderRight: `1px solid ${theme.palette.background.paper}`
}));
export const FiltersContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
justifyContent: "space-between"
}));
export const FiltersRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
padding: "0 15px",
fontSize: "16px",
userSelect: "none"
}));
export const FiltersTitle = styled(Typography)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "5px",
margin: "20px 0",
fontFamily: "Raleway",
fontSize: "17px",
color: theme.palette.text.primary,
userSelect: "none"
}));
export const FiltersCheckbox = styled(Checkbox)(({ theme }) => ({
color: "#c0d4ff",
"&.Mui-checked": {
color: "#6596ff"
}
}));
export const FilterSelect = styled(Autocomplete)(({ theme }) => ({
"& #categories-select": {
padding: "7px"
},
"& .MuiSelect-placeholder": {
fontFamily: "Raleway",
fontSize: "17px",
color: theme.palette.text.primary,
userSelect: "none"
},
"& MuiFormLabel-root": {
fontFamily: "Raleway",
fontSize: "17px",
color: theme.palette.text.primary,
userSelect: "none"
}
}));
export const FilterSelectMenuItems = styled(TextField)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "17px",
color: theme.palette.text.primary,
userSelect: "none"
}));
export const FiltersSubContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
flexDirection: "column",
gap: "5px"
}));
export const FilterDropdownLabel = styled(InputLabel)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "16px",
color: theme.palette.text.primary
}));
export const IconsBox = styled(Box)({
display: 'flex',
gap: "3px",
position: 'absolute',
top: '-20px',
right: '-5px',
transition: 'all 0.3s ease-in-out',
});
export const BlockIconContainer = styled(Box)({
display: 'flex',
boxShadow: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;",
backgroundColor: '#fbfbfb',
color: "#c25252",
padding: '2px',
borderRadius: '3px',
transition: 'all 0.3s ease-in-out',
"&:hover": {
cursor: 'pointer',
transform: "scale(1.1)",
}
})

826
src/pages/Home/VideoList.tsx

@ -0,0 +1,826 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import ReactDOM from "react-dom";
import { useSelector, useDispatch } from "react-redux";
import { RootState } from "../../state/store";
import AttachFileIcon from '@mui/icons-material/AttachFile';
import {
Avatar,
Box,
Button,
FormControl,
Grid,
Input,
InputLabel,
MenuItem,
OutlinedInput,
Select,
SelectChangeEvent,
Skeleton,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import { useFetchVideos } from "../../hooks/useFetchVideos";
import LazyLoad from "../../components/common/LazyLoad";
import {
BlockIconContainer,
BottomParent,
FilterSelect,
FiltersCheckbox,
FiltersCol,
FiltersContainer,
FiltersRow,
FiltersSubContainer,
FiltersTitle,
IconsBox,
NameContainer,
VideoCard,
VideoCardName,
VideoCardTitle,
VideoContainer,
VideoUploadDate,
} from "./VideoList-styles";
import ResponsiveImage from "../../components/ResponsiveImage";
import { formatDate, formatTimestampSeconds } from "../../utils/time";
import { Subtitle, SubtitleContainer } from "./Home-styles";
import { ExpandMoreSVG } from "../../assets/svgs/ExpandMoreSVG";
import {
addVideos,
blockUser,
changeFilterType,
changeSelectedCategoryVideos,
changeSelectedSubCategoryVideos,
changeSelectedSubCategoryVideos2,
changeSelectedSubCategoryVideos3,
changefilterName,
changefilterSearch,
clearVideoList,
setEditPlaylist,
setEditVideo,
} from "../../state/features/videoSlice";
import { categories, subCategories, subCategories2, subCategories3 } from "../../constants";
import { Playlists } from "../../components/Playlists/Playlists";
import { PlaylistSVG } from "../../assets/svgs/PlaylistSVG";
import BlockIcon from "@mui/icons-material/Block";
import EditIcon from '@mui/icons-material/Edit';
import { formatBytes } from "../VideoContent/VideoContent";
interface VideoListProps {
mode?: string;
}
export const VideoList = ({ mode }: VideoListProps) => {
const theme = useTheme();
const prevVal = useRef("");
const isFiltering = useSelector(
(state: RootState) => state.video.isFiltering
);
const filterValue = useSelector(
(state: RootState) => state.video.filterValue
);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [showIcons, setShowIcons] = useState(null);
const filterType = useSelector((state: RootState) => state.video.filterType);
const setFilterType = (payload) => {
dispatch(changeFilterType(payload));
};
const filterSearch = useSelector(
(state: RootState) => state.video.filterSearch
);
const setFilterSearch = (payload) => {
dispatch(changefilterSearch(payload));
};
const filterName = useSelector((state: RootState) => state.video.filterName);
const setFilterName = (payload) => {
dispatch(changefilterName(payload));
};
const selectedCategoryVideos = useSelector(
(state: RootState) => state.video.selectedCategoryVideos
);
const setSelectedCategoryVideos = (payload) => {
dispatch(changeSelectedCategoryVideos(payload));
};
const selectedSubCategoryVideos = useSelector(
(state: RootState) => state.video.selectedSubCategoryVideos
);
const selectedSubCategoryVideos2 = useSelector(
(state: RootState) => state.video.selectedSubCategoryVideos2
);
const selectedSubCategoryVideos3 = useSelector(
(state: RootState) => state.video.selectedSubCategoryVideos3
);
const setSelectedSubCategoryVideos = (payload) => {
dispatch(changeSelectedSubCategoryVideos(payload));
};
const setSelectedSubCategoryVideos2 = (payload) => {
dispatch(changeSelectedSubCategoryVideos2(payload));
};
const setSelectedSubCategoryVideos3 = (payload) => {
dispatch(changeSelectedSubCategoryVideos3(payload));
};
const dispatch = useDispatch();
const filteredVideos = useSelector(
(state: RootState) => state.video.filteredVideos
);
const username = useSelector((state: RootState) => state.auth?.user?.name);
const isFilterMode = useRef(false);
const firstFetch = useRef(false);
const afterFetch = useRef(false);
const isFetchingFiltered = useRef(false);
const isFetching = useRef(false);
const hashMapVideos = useSelector(
(state: RootState) => state.video.hashMapVideos
);
const countNewVideos = useSelector(
(state: RootState) => state.video.countNewVideos
);
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
);
const { videos: globalVideos } = useSelector(
(state: RootState) => state.video
);
const navigate = useNavigate();
const { getVideos, getNewVideos, checkNewVideos, getVideosFiltered } =
useFetchVideos();
const getVideosHandler = React.useCallback(
async (reset?: boolean, resetFilers?: boolean) => {
if (!firstFetch.current || !afterFetch.current) return;
if (isFetching.current) return;
isFetching.current = true;
console.log({
category: selectedCategoryVideos?.id,
subcategory: selectedSubCategoryVideos?.id,
subcategory2: selectedSubCategoryVideos2?.id,
subcategory3: selectedSubCategoryVideos3?.id,
})
await getVideos(
{
name: filterName,
category: selectedCategoryVideos?.id,
subcategory: selectedSubCategoryVideos?.id,
subcategory2: selectedSubCategoryVideos2?.id,
subcategory3: selectedSubCategoryVideos3?.id,
keywords: filterSearch,
type: filterType,
},
reset ? true : false,
resetFilers
);
isFetching.current = false;
},
[
getVideos,
filterValue,
getVideosFiltered,
isFiltering,
filterName,
selectedCategoryVideos,
selectedSubCategoryVideos,
selectedSubCategoryVideos2,
selectedSubCategoryVideos3,
filterSearch,
filterType,
]
);
useEffect(() => {
if (isFiltering && filterValue !== prevVal?.current) {
prevVal.current = filterValue;
getVideosHandler();
}
}, [filterValue, isFiltering, filteredVideos]);
const getVideosHandlerMount = React.useCallback(async () => {
if (firstFetch.current) return;
firstFetch.current = true;
setIsLoading(true);
await getVideos();
afterFetch.current = true;
isFetching.current = false;
setIsLoading(false);
}, [getVideos]);
let videos = globalVideos;
if (isFiltering) {
videos = filteredVideos;
isFilterMode.current = true;
} else {
isFilterMode.current = false;
}
// const interval = useRef<any>(null);
// const checkNewVideosFunc = useCallback(() => {
// let isCalling = false;
// interval.current = setInterval(async () => {
// if (isCalling || !firstFetch.current) return;
// isCalling = true;
// await checkNewVideos();
// isCalling = false;
// }, 30000); // 1 second interval
// }, [checkNewVideos]);
// useEffect(() => {
// if (isFiltering && interval.current) {
// clearInterval(interval.current);
// return;
// }
// checkNewVideosFunc();
// return () => {
// if (interval?.current) {
// clearInterval(interval.current);
// }
// };
// }, [mode, checkNewVideosFunc, isFiltering]);
useEffect(() => {
if (
!firstFetch.current &&
!isFilterMode.current &&
globalVideos.length === 0
) {
isFetching.current = true;
getVideosHandlerMount();
} else {
firstFetch.current = true;
afterFetch.current = true;
}
}, [getVideosHandlerMount, globalVideos]);
const filtersToDefault = async () => {
setFilterType("videos");
setFilterSearch("");
setFilterName("");
setSelectedCategoryVideos(null);
setSelectedSubCategoryVideos(null);
ReactDOM.flushSync(() => {
getVideosHandler(true, true);
});
};
const handleOptionCategoryChangeVideos = (
event: SelectChangeEvent<string>
) => {
const optionId = event.target.value;
const selectedOption = categories.find((option) => option.id === +optionId);
setSelectedCategoryVideos(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos2 = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos2(selectedOption || null);
};
const handleOptionSubCategoryChangeVideos3 = (
event: SelectChangeEvent<string>,
subcategories: any[]
) => {
const optionId = event.target.value;
const selectedOption = subcategories.find(
(option) => option.id === +optionId
);
setSelectedSubCategoryVideos3(selectedOption || null);
};
const blockUserFunc = async (user: string) => {
if (user === "Q-Tube") return;
try {
const response = await qortalRequest({
action: "ADD_LIST_ITEMS",
list_name: "blockedNames",
items: [user],
});
if (response === true) {
dispatch(blockUser(user))
}
} catch (error) {}
};
return (
<Grid container sx={{ width: "100%" }}>
<FiltersCol item xs={12} md={2} sm={3}>
<FiltersContainer>
<Input
id="standard-adornment-name"
onChange={(e) => {
setFilterSearch(e.target.value);
}}
value={filterSearch}
placeholder="Search"
sx={{
borderBottom: "1px solid white",
"&&:before": {
borderBottom: "none",
},
"&&:after": {
borderBottom: "none",
},
"&&:hover:before": {
borderBottom: "none",
},
"&&.Mui-focused:before": {
borderBottom: "none",
},
"&&.Mui-focused": {
outline: "none",
},
fontSize: "18px",
}}
/>
<Input
id="standard-adornment-name"
onChange={(e) => {
setFilterName(e.target.value);
}}
value={filterName}
placeholder="User's name"
sx={{
marginTop: "20px",
borderBottom: "1px solid white",
"&&:before": {
borderBottom: "none",
},
"&&:after": {
borderBottom: "none",
},
"&&:hover:before": {
borderBottom: "none",
},
"&&.Mui-focused:before": {
borderBottom: "none",
},
"&&.Mui-focused": {
outline: "none",
},
fontSize: "18px",
}}
/>
<FiltersTitle>
Categories
<ExpandMoreSVG
color={theme.palette.text.primary}
height={"22"}
width={"22"}
/>
</FiltersTitle>
<FiltersSubContainer>
<FormControl sx={{ width: "100%" }}>
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
flexDirection: "column",
}}
>
<FormControl fullWidth sx={{ marginBottom: 1 }}>
<InputLabel
sx={{
fontSize: "16px",
}}
id="Category"
>
Category
</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Category" />}
value={selectedCategoryVideos?.id || ""}
onChange={handleOptionCategoryChangeVideos}
sx={{
// Target the input field
".MuiSelect-select": {
fontSize: "16px", // Change font size for the selected value
padding: "10px 5px 15px 15px;",
},
// Target the dropdown icon
".MuiSelect-icon": {
fontSize: "20px", // Adjust if needed
},
// Target the dropdown menu
"& .MuiMenu-paper": {
".MuiMenuItem-root": {
fontSize: "14px", // Change font size for the menu items
},
},
}}
>
{categories.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
{selectedCategoryVideos &&
subCategories[selectedCategoryVideos?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel
sx={{
fontSize: "16px",
}}
id="Sub-Category"
>
Sub-Category
</InputLabel>
<Select
labelId="Sub-Category"
input={<OutlinedInput label="Sub-Category" />}
value={selectedSubCategoryVideos?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos(
e,
subCategories[selectedCategoryVideos?.id]
)
}
sx={{
// Target the input field
".MuiSelect-select": {
fontSize: "16px", // Change font size for the selected value
padding: "10px 5px 15px 15px;",
},
// Target the dropdown icon
".MuiSelect-icon": {
fontSize: "20px", // Adjust if needed
},
// Target the dropdown menu
"& .MuiMenu-paper": {
".MuiMenuItem-root": {
fontSize: "14px", // Change font size for the menu items
},
},
}}
>
{subCategories[selectedCategoryVideos.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
{selectedSubCategoryVideos &&
subCategories2[selectedSubCategoryVideos?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel
sx={{
fontSize: "16px",
}}
id="Sub-2x-Category"
>
Sub-2x-Category
</InputLabel>
<Select
labelId="Sub-2x-Category"
input={<OutlinedInput label="Sub-2x-Category" />}
value={selectedSubCategoryVideos2?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos2(
e,
subCategories2[selectedSubCategoryVideos?.id]
)
}
sx={{
// Target the input field
".MuiSelect-select": {
fontSize: "16px", // Change font size for the selected value
padding: "10px 5px 15px 15px;",
},
// Target the dropdown icon
".MuiSelect-icon": {
fontSize: "20px", // Adjust if needed
},
// Target the dropdown menu
"& .MuiMenu-paper": {
".MuiMenuItem-root": {
fontSize: "14px", // Change font size for the menu items
},
},
}}
>
{subCategories2[selectedSubCategoryVideos.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
{selectedSubCategoryVideos2 &&
subCategories3[selectedSubCategoryVideos2?.id] && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel
sx={{
fontSize: "16px",
}}
id="Sub-2x-Category"
>
Sub-3x-Category
</InputLabel>
<Select
labelId="Sub-3x-Category"
input={<OutlinedInput label="Sub-sx-Category" />}
value={selectedSubCategoryVideos3?.id || ""}
onChange={(e) =>
handleOptionSubCategoryChangeVideos3(
e,
subCategories3[selectedSubCategoryVideos2?.id]
)
}
sx={{
// Target the input field
".MuiSelect-select": {
fontSize: "16px", // Change font size for the selected value
padding: "10px 5px 15px 15px;",
},
// Target the dropdown icon
".MuiSelect-icon": {
fontSize: "20px", // Adjust if needed
},
// Target the dropdown menu
"& .MuiMenu-paper": {
".MuiMenuItem-root": {
fontSize: "14px", // Change font size for the menu items
},
},
}}
>
{subCategories3[selectedSubCategoryVideos2.id].map(
(option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
)
)}
</Select>
</FormControl>
)}
</Box>
</FormControl>
</FiltersSubContainer>
{/* <FiltersTitle>
Type
<ExpandMoreSVG
color={theme.palette.text.primary}
height={"22"}
width={"22"}
/>
</FiltersTitle>
<FiltersSubContainer>
<FiltersRow>
Videos
<FiltersCheckbox
checked={filterType === "videos"}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFilterType("videos");
}}
inputProps={{ "aria-label": "controlled" }}
/>
</FiltersRow>
<FiltersRow>
Playlists
<FiltersCheckbox
checked={filterType === "playlists"}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFilterType("playlists");
}}
inputProps={{ "aria-label": "controlled" }}
/>
</FiltersRow>
</FiltersSubContainer> */}
<Button
onClick={() => {
filtersToDefault();
}}
sx={{
marginTop: "20px",
}}
variant="contained"
>
reset
</Button>
<Button
onClick={() => {
getVideosHandler(true);
}}
sx={{
marginTop: "20px",
}}
variant="contained"
>
Search
</Button>
</FiltersContainer>
</FiltersCol>
<Grid item xs={12} md={10} sm={9}>
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
marginTop: "20px",
}}
>
<SubtitleContainer
sx={{
justifyContent: "flex-start",
paddingLeft: "15px",
width: "100%",
maxWidth: "1400px",
}}
>
</SubtitleContainer>
<VideoContainer>
{videos.map((video: any, index: number) => {
const existingVideo = hashMapVideos[video?.id];
let hasHash = false;
let videoObj = video;
if (existingVideo) {
videoObj = existingVideo;
hasHash = true;
}
return (
<Box
sx={{
display: "flex",
alignItems: "center",
width: "100%",
height: "75px",
position:"relative"
}}
key={videoObj.id}
onMouseEnter={() => setShowIcons(videoObj.id)}
onMouseLeave={() => setShowIcons(null)}
>
{hasHash ? (
<>
<IconsBox
sx={{
opacity: showIcons === videoObj.id ? 1 : 0,
zIndex: 2,
}}
>
{videoObj?.user === username && (
<Tooltip title="Edit video properties" placement="top">
<BlockIconContainer>
<EditIcon
onClick={() => {
dispatch(setEditVideo(videoObj));
}}
/>
</BlockIconContainer>
</Tooltip>
)}
<Tooltip title="Block user content" placement="top">
<BlockIconContainer>
<BlockIcon
onClick={() => {
blockUserFunc(videoObj?.user);
}}
/>
</BlockIconContainer>
</Tooltip>
</IconsBox>
<VideoCard
onClick={() => {
navigate(`/share/${videoObj?.user}/${videoObj?.id}`);
}}
sx={{
height: '100%',
width: '100%',
display: 'flex',
gap: '25px',
flexDirection: 'row',
justifyContent: 'space-between'
}}
>
<Box sx={{
display: 'flex',
gap: '25px',
alignItems: 'center'
}}>
<AttachFileIcon />
<VideoCardTitle sx={{
width: '100px'
}}>
{formatBytes(videoObj?.files.reduce((acc, cur) => acc + (cur?.size || 0), 0))}
</VideoCardTitle>
<VideoCardTitle>{videoObj.title}</VideoCardTitle>
</Box>
<BottomParent>
<NameContainer
onClick={(e) => {
e.stopPropagation();
navigate(`/channel/${videoObj?.user}`);
}}
>
<Avatar
sx={{ height: 24, width: 24 }}
src={`/arbitrary/THUMBNAIL/${videoObj?.user}/qortal_avatar`}
alt={`${videoObj?.user}'s avatar`}
/>
<VideoCardName
sx={{
":hover": {
textDecoration: "underline",
},
}}
>
{videoObj?.user}
</VideoCardName>
</NameContainer>
{videoObj?.created && (
<VideoUploadDate>
{formatDate(videoObj.created)}
</VideoUploadDate>
)}
</BottomParent>
</VideoCard>
</>
) : (
<Skeleton
variant="rectangular"
style={{
width: "100%",
height: "100%",
paddingBottom: "10px",
objectFit: "contain",
visibility: "visible",
borderRadius: "8px",
}}
/>
)}
</Box>
);
})}
</VideoContainer>
<LazyLoad
onLoadMore={getVideosHandler}
isLoading={isLoading}
></LazyLoad>
</Box>
</Grid>
</Grid>
);
};

261
src/pages/Home/VideoListComponentLevel.tsx

@ -0,0 +1,261 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import AttachFileIcon from '@mui/icons-material/AttachFile';
import {
Avatar,
Box,
Button,
Skeleton,
Typography,
useTheme
} from '@mui/material'
import { useFetchVideos } from '../../hooks/useFetchVideos'
import LazyLoad from '../../components/common/LazyLoad'
import { BottomParent, NameContainer, VideoCard, VideoCardName, VideoCardTitle, VideoContainer, VideoUploadDate } from './VideoList-styles'
import ResponsiveImage from '../../components/ResponsiveImage'
import { formatDate, formatTimestampSeconds } from '../../utils/time'
import { Video } from '../../state/features/videoSlice'
import { queue } from '../../wrappers/GlobalWrapper'
import { QTUBE_VIDEO_BASE } from '../../constants'
import { formatBytes } from '../VideoContent/VideoContent'
interface VideoListProps {
mode?: string
}
export const VideoListComponentLevel = ({ mode }: VideoListProps) => {
const { name: paramName } = useParams()
const theme = useTheme()
const [isLoading, setIsLoading] = useState<boolean>(true)
const firstFetch = useRef(false)
const afterFetch = useRef(false)
const hashMapVideos = useSelector(
(state: RootState) => state.video.hashMapVideos
)
const countNewVideos = useSelector(
(state: RootState) => state.video.countNewVideos
)
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
)
const [videos, setVideos] = React.useState<Video[]>([])
const navigate = useNavigate()
const {
getVideo,
getNewVideos,
checkNewVideos,
checkAndUpdateVideo
} = useFetchVideos()
const getVideos = React.useCallback(async () => {
try {
const offset = videos.length
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${QTUBE_VIDEO_BASE}_&limit=20&includemetadata=false&reverse=true&excludeblocked=true&name=${paramName}&exactmatchnames=true&offset=${offset}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((video: any): Video => {
return {
title: video?.metadata?.title,
category: video?.metadata?.category,
categoryName: video?.metadata?.categoryName,
tags: video?.metadata?.tags || [],
description: video?.metadata?.description,
created: video?.created,
updated: video?.updated,
user: video.name,
videoImage: '',
id: video.identifier
}
})
const copiedVideos: Video[] = [...videos]
structureData.forEach((video: Video) => {
const index = videos.findIndex((p) => p.id === video.id)
if (index !== -1) {
copiedVideos[index] = video
} else {
copiedVideos.push(video)
}
})
setVideos(copiedVideos)
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdateVideo(content)
if (res) {
queue.push(() => getVideo(content.user, content.id, content));
}
}
}
} catch (error) {
} finally {
}
}, [videos, hashMapVideos])
const getVideosHandler = React.useCallback(async () => {
if(!firstFetch.current || !afterFetch.current) return
await getVideos()
}, [getVideos])
const getVideosHandlerMount = React.useCallback(async () => {
if(firstFetch.current) return
firstFetch.current = true
await getVideos()
afterFetch.current = true
setIsLoading(false)
}, [getVideos])
useEffect(()=> {
if(!firstFetch.current){
getVideosHandlerMount()
}
}, [getVideosHandlerMount ])
return (
<Box sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}>
<VideoContainer>
{videos.map((video: any, index: number) => {
const existingVideo = hashMapVideos[video?.id];
let hasHash = false;
let videoObj = video;
if (existingVideo) {
videoObj = existingVideo;
hasHash = true;
}
return (
<Box
sx={{
display: "flex",
alignItems: "center",
width: "100%",
height: "75px",
position:"relative"
}}
key={videoObj.id}
>
{hasHash ? (
<>
<VideoCard
onClick={() => {
navigate(`/share/${videoObj?.user}/${videoObj?.id}`);
}}
sx={{
height: '100%',
width: '100%',
display: 'flex',
gap: '25px',
flexDirection: 'row',
justifyContent: 'space-between'
}}
>
<Box sx={{
display: 'flex',
gap: '25px',
alignItems: 'center'
}}>
<AttachFileIcon />
<VideoCardTitle sx={{
width: '100px'
}}>
{formatBytes(videoObj?.files.reduce((acc, cur) => acc + (cur?.size || 0), 0))}
</VideoCardTitle>
<VideoCardTitle>{videoObj.title}</VideoCardTitle>
</Box>
<BottomParent>
<NameContainer
onClick={(e) => {
e.stopPropagation();
navigate(`/channel/${videoObj?.user}`);
}}
>
<Avatar
sx={{ height: 24, width: 24 }}
src={`/arbitrary/THUMBNAIL/${videoObj?.user}/qortal_avatar`}
alt={`${videoObj?.user}'s avatar`}
/>
<VideoCardName
sx={{
":hover": {
textDecoration: "underline",
},
}}
>
{videoObj?.user}
</VideoCardName>
</NameContainer>
{videoObj?.created && (
<VideoUploadDate>
{formatDate(videoObj.created)}
</VideoUploadDate>
)}
</BottomParent>
</VideoCard>
</>
) : (
<Skeleton
variant="rectangular"
style={{
width: "100%",
height: "100%",
paddingBottom: "10px",
objectFit: "contain",
visibility: "visible",
borderRadius: "8px",
}}
/>
)}
</Box>
);
})}
</VideoContainer>
<LazyLoad onLoadMore={getVideosHandler} isLoading={isLoading}></LazyLoad>
</Box>
)
}

64
src/pages/IndividualProfile/IndividualProfile.tsx

@ -0,0 +1,64 @@
import React, { useMemo } from 'react'
import { VideoListComponentLevel } from '../Home/VideoListComponentLevel'
import { HeaderContainer, ProfileContainer } from './Profile-styles'
import { AuthorTextComment, StyledCardColComment, StyledCardHeaderComment } from '../VideoContent/VideoContent-styles'
import { Avatar, Box, useTheme } from '@mui/material'
import { useParams } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { setUserAvatarHash } from '../../state/features/globalSlice'
import { RootState } from '../../state/store'
export const IndividualProfile = () => {
const { name: paramName } = useParams()
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
)
const theme = useTheme()
const avatarUrl = useMemo(()=> {
let url = ''
if(paramName && userAvatarHash[paramName]){
url = userAvatarHash[paramName]
}
return url
}, [userAvatarHash, paramName])
return (
<ProfileContainer>
<HeaderContainer>
<Box sx={{
cursor: 'pointer'
}} >
<StyledCardHeaderComment
sx={{
'& .MuiCardHeader-content': {
overflow: 'hidden'
}
}}
>
<Box>
<Avatar src={`/arbitrary/THUMBNAIL/${paramName}/qortal_avatar`} alt={`${paramName}'s avatar`} />
</Box>
<StyledCardColComment>
<AuthorTextComment
color={
theme.palette.mode === 'light'
? theme.palette.text.secondary
: '#d6e8ff'
}
>
{paramName}
</AuthorTextComment>
</StyledCardColComment>
</StyledCardHeaderComment>
</Box>
</HeaderContainer>
<VideoListComponentLevel />
</ProfileContainer>
)
}

16
src/pages/IndividualProfile/Profile-styles.tsx

@ -0,0 +1,16 @@
import { styled } from "@mui/system";
import { Box, Grid, Typography, Checkbox } from "@mui/material";
export const ProfileContainer = styled(Box)(({ theme }) => ({
position: "relative",
display: "flex",
width: "100%",
flexDirection: "column"
}));
export const HeaderContainer = styled(Box)(({ theme }) => ({
position: "relative",
display: "flex",
width: "100%",
justifyContent: "center"
}));

81
src/pages/VideoContent/VideoContent-styles.tsx

@ -0,0 +1,81 @@
import { styled } from "@mui/system";
import { Box, Grid, Typography, Checkbox } from "@mui/material";
export const VideoPlayerContainer = styled(Box)(({ theme }) => ({
maxWidth: '95%',
width: '1000px',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}));
export const VideoTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "20px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word"
}));
export const VideoDescription = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "16px",
color: theme.palette.text.primary,
userSelect: "none",
wordBreak: "break-word"
}));
export const Spacer = ({height}: any)=> {
return <Box sx={{
height: height
}} />
}
export const StyledCardHeaderComment = styled(Box)({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: '5px',
padding: '7px 0px'
})
export const StyledCardCol = styled(Box)({
display: 'flex',
overflow: 'hidden',
flexDirection: 'column',
gap: '2px',
alignItems: 'flex-start',
width: '100%'
})
export const StyledCardColComment = styled(Box)({
display: 'flex',
overflow: 'hidden',
flexDirection: 'column',
gap: '2px',
alignItems: 'flex-start',
width: '100%'
})
export const AuthorTextComment = styled(Typography)({
fontFamily: 'Raleway, sans-serif',
fontSize: '16px',
lineHeight: '1.2'
})
export const FileAttachmentContainer = styled(Box)(({ theme }) =>({
display: "flex",
alignItems: "center",
gap: "20px",
padding: "5px 10px",
border: `1px solid ${theme.palette.text.primary}`,
}));
export const FileAttachmentFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
color: theme.palette.text.primary,
fontSize: "16px",
letterSpacing: 0,
fontWeight: 400,
userSelect: "none",
whiteSpace: 'nowrap'
}));

486
src/pages/VideoContent/VideoContent.tsx

@ -0,0 +1,486 @@
import React, { useState, useMemo, useRef, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { setIsLoadingGlobal } from "../../state/features/globalSlice";
import { Avatar, Box, Typography, useTheme } from "@mui/material";
import { VideoPlayer } from "../../components/common/VideoPlayer";
import { RootState } from "../../state/store";
import { addToHashMap } from "../../state/features/videoSlice";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import DownloadIcon from "@mui/icons-material/Download";
import mockImg from "../../test/mockimg.jpg";
import {
AuthorTextComment,
FileAttachmentContainer,
FileAttachmentFont,
Spacer,
StyledCardColComment,
StyledCardHeaderComment,
VideoDescription,
VideoPlayerContainer,
VideoTitle,
} from "./VideoContent-styles";
import { setUserAvatarHash } from "../../state/features/globalSlice";
import {
formatDate,
formatDateSeconds,
formatTimestampSeconds,
} from "../../utils/time";
import { NavbarName } from "../../components/layout/Navbar/Navbar-styles";
import { CommentSection } from "../../components/common/Comments/CommentSection";
import {
CrowdfundSubTitle,
CrowdfundSubTitleRow,
} from "../../components/UploadVideo/Upload-styles";
import { QTUBE_VIDEO_BASE } from "../../constants";
import { Playlists } from "../../components/Playlists/Playlists";
import { DisplayHtml } from "../../components/common/TextEditor/DisplayHtml";
import FileElement from "../../components/common/FileElement";
export function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export const VideoContent = () => {
const { name, id } = useParams();
const [isExpandedDescription, setIsExpandedDescription] =
useState<boolean>(false);
const [descriptionHeight, setDescriptionHeight] =
useState<null | number>(null);
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
);
const contentRef = useRef(null);
const avatarUrl = useMemo(() => {
let url = "";
if (name && userAvatarHash[name]) {
url = userAvatarHash[name];
}
return url;
}, [userAvatarHash, name]);
const navigate = useNavigate();
const theme = useTheme();
const [videoData, setVideoData] = useState<any>(null);
const [playlistData, setPlaylistData] = useState<any>(null);
const hashMapVideos = useSelector(
(state: RootState) => state.video.hashMapVideos
);
const videoReference = useMemo(() => {
if (!videoData) return null;
const { videoReference } = videoData;
if (
videoReference?.identifier &&
videoReference?.name &&
videoReference?.service
) {
return videoReference;
} else {
return null;
}
}, [videoData]);
const videoCover = useMemo(() => {
if (!videoData) return null;
const { videoImage } = videoData;
return videoImage || null;
}, [videoData]);
const dispatch = useDispatch();
const getVideoData = React.useCallback(async (name: string, id: string) => {
try {
if (!name || !id) return;
dispatch(setIsLoadingGlobal(true));
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${QTUBE_VIDEO_BASE}&limit=1&includemetadata=true&reverse=true&excludeblocked=true&name=${name}&exactmatchnames=true&offset=0&identifier=${id}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearch = await response.json();
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: "DOCUMENT",
identifier: id,
});
if (responseData && !responseData.error) {
const combinedData = {
...resourceData,
...responseData,
};
setVideoData(combinedData);
dispatch(addToHashMap(combinedData));
checkforPlaylist(name, id, combinedData?.code);
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false));
}
}, []);
const checkforPlaylist = React.useCallback(async (name, id, code) => {
try {
if (!name || !id || !code) return;
const url = `/arbitrary/resources/search?mode=ALL&service=PLAYLIST&description=c:${code}&limit=1&includemetadata=true&reverse=true&excludeblocked=true&name=${name}&exactmatchnames=true&offset=0`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearch = await response.json();
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,
name: resourceData.name,
videoImage: "",
identifier: resourceData.identifier,
service: resourceData.service,
};
const responseData = await qortalRequest({
action: "FETCH_QDN_RESOURCE",
name: resourceData.name,
service: resourceData.service,
identifier: resourceData.identifier,
});
if (responseData && !responseData.error) {
const combinedData = {
...resourceData,
...responseData,
};
const videos = [];
if (combinedData?.videos) {
for (const vid of combinedData.videos) {
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${vid.identifier}&limit=1&includemetadata=true&reverse=true&name=${vid.name}&exactmatchnames=true&offset=0`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearchVid = await response.json();
if (responseDataSearchVid?.length > 0) {
let resourceData2 = responseDataSearchVid[0];
videos.push(resourceData2);
}
}
}
combinedData.videos = videos;
setPlaylistData(combinedData);
}
}
} catch (error) {}
}, []);
React.useEffect(() => {
if (name && id) {
const existingVideo = hashMapVideos[id];
if (existingVideo) {
setVideoData(existingVideo);
checkforPlaylist(name, id, existingVideo?.code);
} else {
getVideoData(name, id);
}
}
}, [id, name]);
// const getAvatar = React.useCallback(async (author: string) => {
// try {
// let url = await qortalRequest({
// action: 'GET_QDN_RESOURCE_URL',
// name: author,
// service: 'THUMBNAIL',
// identifier: 'qortal_avatar'
// })
// setAvatarUrl(url)
// dispatch(setUserAvatarHash({
// name: author,
// url
// }))
// } catch (error) { }
// }, [])
// React.useEffect(() => {
// if (name && !avatarUrl) {
// const existingAvatar = userAvatarHash[name]
// if (existingAvatar) {
// setAvatarUrl(existingAvatar)
// } else {
// getAvatar(name)
// }
// }
// }, [name, userAvatarHash])
useEffect(() => {
if (contentRef.current) {
const height = contentRef.current.offsetHeight;
if (height > 100) { // Assuming 100px is your threshold
setDescriptionHeight(100)
}
}
}, [videoData]);
return (
<Box
sx={{
display: "flex",
alignItems: "center",
flexDirection: "column",
padding: "20px 10px",
}}
>
<VideoPlayerContainer
sx={{
marginBottom: "30px",
}}
>
<Spacer height="15px" />
<VideoTitle
variant="h1"
color="textPrimary"
sx={{
textAlign: "center",
}}
>
{videoData?.title}
</VideoTitle>
{videoData?.created && (
<Typography
variant="h6"
sx={{
fontSize: "12px",
}}
color={theme.palette.text.primary}
>
{formatDate(videoData.created)}
</Typography>
)}
<Spacer height="15px" />
<Box
sx={{
cursor: "pointer",
}}
onClick={() => {
navigate(`/channel/${name}`);
}}
>
<StyledCardHeaderComment
sx={{
"& .MuiCardHeader-content": {
overflow: "hidden",
},
}}
>
<Box>
<Avatar
src={`/arbitrary/THUMBNAIL/${name}/qortal_avatar`}
alt={`${name}'s avatar`}
/>
</Box>
<StyledCardColComment>
<AuthorTextComment
color={
theme.palette.mode === "light"
? theme.palette.text.secondary
: "#d6e8ff"
}
>
{name}
</AuthorTextComment>
</StyledCardColComment>
</StyledCardHeaderComment>
</Box>
<Spacer height="15px" />
<Box
sx={{
background: "#333333",
borderRadius: "5px",
padding: "5px",
width: "100%",
cursor: !descriptionHeight ? "default" : isExpandedDescription ? "default" : "pointer",
position: "relative",
}}
className={!descriptionHeight ? "": isExpandedDescription ? "" : "hover-click"}
>
{descriptionHeight && !isExpandedDescription && (
<Box
sx={{
position: "absolute",
top: "0px",
right: "0px",
left: "0px",
bottom: "0px",
cursor: "pointer",
}}
onClick={() => {
if (isExpandedDescription) return;
setIsExpandedDescription(true);
}}
/>
)}
<Box
ref={contentRef}
sx={{
height: !descriptionHeight ? 'auto' : isExpandedDescription ? "auto" : "100px",
overflow: "hidden",
}}
>
{videoData?.htmlDescription ? (
<DisplayHtml html={videoData?.htmlDescription} />
) : (
<VideoDescription variant="body1" color="textPrimary" sx={{
cursor: 'default'
}}>
{videoData?.fullDescription}
</VideoDescription>
)}
</Box>
{descriptionHeight && (
<Typography
onClick={() => {
setIsExpandedDescription((prev) => !prev);
}}
sx={{
fontWeight: "bold",
fontSize: "16px",
cursor: "pointer",
paddingLeft: "15px",
paddingTop: "15px",
}}
>
{isExpandedDescription ? "Show less" : "...more"}
</Typography>
)}
</Box>
<Box sx={{
width: '100%',
display: 'flex',
alignItems: 'flex-start',
flexDirection: 'column',
gap: '25px',
marginTop: '25px'
}}>
{videoData?.files?.map((file)=> {
return (
<FileAttachmentContainer sx={{
width: '100%',
display: 'flex',
justifyContent: 'space-between'
}}>
<FileAttachmentFont>
{file.filename}
</FileAttachmentFont>
<Box sx={{
display: 'flex',
gap: '25px',
alignItems: 'center',
}}>
<FileAttachmentFont>
{formatBytes(file?.size || 0)}
</FileAttachmentFont>
<FileElement
fileInfo={{...file,
filename: file?.filename,
mimeType: file?.mimetype
}}
jsonId={id}
title={file?.filename}
customStyles={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<DownloadIcon />
</FileElement>
</Box>
</FileAttachmentContainer>
)
})}
</Box>
</VideoPlayerContainer>
<Box
sx={{
display: "flex",
gap: "20px",
width: "100%",
maxWidth: "1200px",
}}
>
<CommentSection postId={id || ""} postName={name || ""} />
</Box>
</Box>
);
};

27
src/state/features/authSlice.ts

@ -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;

62
src/state/features/globalSlice.ts

@ -0,0 +1,62 @@
import { createSlice } from '@reduxjs/toolkit'
interface GlobalState {
isLoadingGlobal: boolean
downloads: any
userAvatarHash: Record<string, string>
publishNames: string[] | null
videoPlaying: any | null
}
const initialState: GlobalState = {
isLoadingGlobal: false,
downloads: {},
userAvatarHash: {},
publishNames: null,
videoPlaying: 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
}
},
addPublishNames: (state, action) => {
state.publishNames = action.payload
},
setVideoPlaying: (state, action) => {
state.videoPlaying = action.payload
},
}
})
export const {
setIsLoadingGlobal,
setAddToDownloads,
updateDownloads,
setUserAvatarHash,
addPublishNames,
setVideoPlaying
} = globalSlice.actions
export default globalSlice.reducer

73
src/state/features/notificationsSlice.ts

@ -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;

216
src/state/features/videoSlice.ts

@ -0,0 +1,216 @@
import { createSlice } from '@reduxjs/toolkit';
import { RootState } from '../store'
interface GlobalState {
videos: Video[]
filteredVideos: Video[]
hashMapVideos: Record<string, Video>
countNewVideos: number
isFiltering: boolean
filterValue: string
filterType: string
filterSearch: string
filterName: string
selectedCategoryVideos: any
selectedSubCategoryVideos: any
selectedSubCategoryVideos2: any
selectedSubCategoryVideos3: any
editVideoProperties: any
editPlaylistProperties: any
}
const initialState: GlobalState = {
videos: [],
filteredVideos: [],
hashMapVideos: {},
countNewVideos: 0,
isFiltering: false,
filterValue: '',
filterType: 'videos',
filterSearch: '',
filterName: '',
selectedCategoryVideos: null,
selectedSubCategoryVideos: null,
selectedSubCategoryVideos2: null,
selectedSubCategoryVideos3: null,
editVideoProperties: null,
editPlaylistProperties: null
}
export interface Video {
title: string
description: string
created: number | string
user: string
service?: string
videoImage?: string
id: string
category?: string
categoryName?: string
tags?: string[]
updated?: number | string
isValid?: boolean
code?: string
}
export const videoSlice = createSlice({
name: 'video',
initialState,
reducers: {
setEditVideo: (state, action) => {
state.editVideoProperties = action.payload
},
setEditPlaylist: (state, action) => {
state.editPlaylistProperties = action.payload
},
changeFilterType: (state, action) => {
state.filterType = action.payload
},
changefilterSearch: (state, action) => {
state.filterSearch = action.payload
},
changefilterName: (state, action) => {
state.filterName = action.payload
},
changeSelectedCategoryVideos: (state, action) => {
state.selectedCategoryVideos = action.payload
},
changeSelectedSubCategoryVideos: (state, action) => {
state.selectedSubCategoryVideos = action.payload
},
changeSelectedSubCategoryVideos2: (state, action) => {
state.selectedSubCategoryVideos2 = action.payload
},
changeSelectedSubCategoryVideos3: (state, action) => {
state.selectedSubCategoryVideos3 = action.payload
},
setCountNewVideos: (state, action) => {
state.countNewVideos = action.payload
},
addVideos: (state, action) => {
state.videos = action.payload
},
addFilteredVideos: (state, action) => {
state.filteredVideos = action.payload
},
removeVideo: (state, action) => {
const idToDelete = action.payload
state.videos = state.videos.filter((item) => item.id !== idToDelete)
state.filteredVideos = state.filteredVideos.filter(
(item) => item.id !== idToDelete
)
},
addVideoToBeginning: (state, action) => {
state.videos.unshift(action.payload)
},
clearVideoList: (state) => {
state.videos = []
},
updateVideo: (state, action) => {
const { id } = action.payload
const index = state.videos.findIndex((video) => video.id === id)
if (index !== -1) {
state.videos[index] = { ...action.payload }
}
const index2 = state.filteredVideos.findIndex((video) => video.id === id)
if (index2 !== -1) {
state.filteredVideos[index2] = { ...action.payload }
}
},
addToHashMap: (state, action) => {
const video = action.payload
state.hashMapVideos[video.id] = video
},
updateInHashMap: (state, action) => {
const { id } = action.payload
const video = action.payload
state.hashMapVideos[id] = { ...video }
},
removeFromHashMap: (state, action) => {
const idToDelete = action.payload
delete state.hashMapVideos[idToDelete]
},
addArrayToHashMap: (state, action) => {
const videos = action.payload
videos.forEach((video: Video) => {
state.hashMapVideos[video.id] = video
})
},
upsertVideos: (state, action) => {
action.payload.forEach((video: Video) => {
const index = state.videos.findIndex((p) => p.id === video.id)
if (index !== -1) {
state.videos[index] = video
} else {
state.videos.push(video)
}
})
},
upsertFilteredVideos: (state, action) => {
action.payload.forEach((video: Video) => {
const index = state.filteredVideos.findIndex((p) => p.id === video.id)
if (index !== -1) {
state.filteredVideos[index] = video
} else {
state.filteredVideos.push(video)
}
})
},
upsertVideosBeginning: (state, action) => {
action.payload.reverse().forEach((video: Video) => {
const index = state.videos.findIndex((p) => p.id === video.id)
if (index !== -1) {
state.videos[index] = video
} else {
state.videos.unshift(video)
}
})
},
setIsFiltering: (state, action) => {
state.isFiltering = action.payload
},
setFilterValue: (state, action) => {
state.filterValue = action.payload
},
blockUser: (state, action) => {
const username = action.payload
state.videos = state.videos.filter((item) => item.user !== username)
}
}
})
export const {
setCountNewVideos,
addVideos,
addFilteredVideos,
removeVideo,
addVideoToBeginning,
updateVideo,
addToHashMap,
updateInHashMap,
removeFromHashMap,
addArrayToHashMap,
upsertVideos,
upsertFilteredVideos,
upsertVideosBeginning,
setIsFiltering,
setFilterValue,
clearVideoList,
changeFilterType,
changefilterSearch,
changefilterName,
changeSelectedCategoryVideos,
changeSelectedSubCategoryVideos,
changeSelectedSubCategoryVideos2,
changeSelectedSubCategoryVideos3,
blockUser,
setEditVideo,
setEditPlaylist
} = videoSlice.actions
export default videoSlice.reducer

27
src/state/store.ts

@ -0,0 +1,27 @@
import { configureStore } from '@reduxjs/toolkit'
import notificationsReducer from './features/notificationsSlice'
import authReducer from './features/authSlice'
import globalReducer from './features/globalSlice'
import videoReducer from './features/videoSlice'
export const store = configureStore({
reducer: {
notifications: notificationsReducer,
auth: authReducer,
global: globalReducer,
video: videoReducer,
},
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

Binary file not shown.

BIN
src/styles/fonts/Cambon-Light.ttf

Binary file not shown.

BIN
src/styles/fonts/Catamaran.ttf

Binary file not shown.

BIN
src/styles/fonts/Karla.ttf

Binary file not shown.

BIN
src/styles/fonts/Livvic.ttf

Binary file not shown.

BIN
src/styles/fonts/Merriweather Sans.ttf

Binary file not shown.

BIN
src/styles/fonts/Oxygen.ttf

Binary file not shown.

BIN
src/styles/fonts/ProximaNova.otf

Binary file not shown.

BIN
src/styles/fonts/Raleway.ttf

Binary file not shown.

184
src/styles/theme.tsx

@ -0,0 +1,184 @@
import { createTheme } from "@mui/material/styles";
const commonThemeOptions = {
typography: {
fontFamily: [
"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: "Raleway",
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: "0.5px"
},
body2: {
fontSize: "18px",
fontFamily: "Raleway, 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: "#FF1493", // Neon pink
dark: "#C6127A", // Darker shade of neon pink
light: "#FF5EC4" // Lighter shade of neon pink
},
secondary: {
main: "#007FFF", // Electric blue
dark: "#0059B2", // Darker shade of electric blue
light: "#3399FF" // Lighter shade of electric blue
},
background: {
default: "#1C1C1C", // Deep space black
paper: "#342F41" // Dark cyberpunk-style purple
},
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

BIN
src/test/mockimg.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

7
src/utils/checkStructure.ts

@ -0,0 +1,7 @@
export const checkStructure = (content: any) => {
let isValid = true
return isValid
}

14
src/utils/extractTextFromSlate.ts

@ -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;
}

36
src/utils/fetchVideos.ts

@ -0,0 +1,36 @@
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 responseData = await qortalRequest({
action: 'FETCH_QDN_RESOURCE',
name: user,
service: content?.service || 'DOCUMENT',
identifier: videoId
})
if (checkStructure(responseData)) {
obj = {
...content,
...responseData,
isValid: true
}
}
return obj
} catch (error: any) {
throw new Error(error?.message || 'error')
}
}
const res = await getVideo()
return res
}

43
src/utils/queue.ts

@ -0,0 +1,43 @@
type QueueItem = {
request: () => Promise<any>;
resolve: (value: any | PromiseLike<any>) => void;
reject: (reason?: any) => void;
};
export class RequestQueue {
private queue: QueueItem[];
private maxConcurrent: number;
private currentConcurrent: number;
constructor(maxConcurrent = 5) {
this.queue = [];
this.maxConcurrent = maxConcurrent;
this.currentConcurrent = 0;
}
async push(request: () => Promise<any>): Promise<any> {
return new Promise((resolve, reject) => {
this.queue.push({
request,
resolve,
reject,
});
this.checkQueue();
});
}
private checkQueue(): void {
if (this.queue.length === 0 || this.currentConcurrent >= this.maxConcurrent) return;
const { request, resolve, reject } = this.queue.shift() as QueueItem;
this.currentConcurrent++;
request()
.then(resolve)
.catch(reject)
.finally(() => {
this.currentConcurrent--;
this.checkQueue();
});
}
}

46
src/utils/time.ts

@ -0,0 +1,46 @@
import moment from 'moment'
export function formatTimestamp(timestamp: number): string {
const now = moment()
const timestampMoment = moment(timestamp)
const elapsedTime = now.diff(timestampMoment, 'minutes')
if (elapsedTime < 1) {
return 'Just now'
} else if (elapsedTime < 60) {
return `${elapsedTime}m`
} else if (elapsedTime < 1440) {
return `${Math.floor(elapsedTime / 60)}h`
} else {
return timestampMoment.format('MMM D')
}
}
export function formatTimestampSeconds(timestamp: number): string {
const now = moment()
const timestampMoment = moment.unix(timestamp)
const elapsedTime = now.diff(timestampMoment, 'minutes')
if (elapsedTime < 1) {
return 'Just now'
} else if (elapsedTime < 60) {
return `${elapsedTime}m`
} else if (elapsedTime < 1440) {
return `${Math.floor(elapsedTime / 60)}h`
} else {
return timestampMoment.format('MMM D')
}
}
export const formatDate = (unixTimestamp: number): string => {
const date = moment(unixTimestamp, 'x').fromNow()
return date
}
export const formatDateSeconds = (unixTimestamp: number): string => {
const date = moment.unix(unixTimestamp).fromNow();
return date
}

174
src/utils/toBase64.ts

@ -0,0 +1,174 @@
export const toBase64 = (file: File): Promise<string | ArrayBuffer | null> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
const result = reader.result
reader.onload = null // remove onload handler
reader.onerror = null // remove onerror handler
resolve(result)
}
reader.onerror = (error) => {
reader.onload = null // remove onload handler
reader.onerror = null // remove onerror handler
reject(error)
}
})
export function objectToBase64(obj: any) {
// Step 1: Convert the object to a JSON string
const jsonString = JSON.stringify(obj)
// Step 2: Create a Blob from the JSON string
const blob = new Blob([jsonString], { type: 'application/json' })
// Step 3: Create a FileReader to read the Blob as a base64-encoded string
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
// Remove 'data:application/json;base64,' prefix
const base64 = reader.result.replace(
'data:application/json;base64,',
''
)
resolve(base64)
} else {
reject(new Error('Failed to read the Blob as a base64-encoded string'))
}
}
reader.onerror = () => {
reject(reader.error)
}
reader.readAsDataURL(blob)
})
}
export function objectToUint8Array(obj: any) {
// Convert the object to a JSON string
const jsonString = JSON.stringify(obj)
// Encode the JSON string as a byte array using TextEncoder
const encoder = new TextEncoder()
const byteArray = encoder.encode(jsonString)
// Create a new Uint8Array and set its content to the encoded byte array
const uint8Array = new Uint8Array(byteArray)
return uint8Array
}
export function uint8ArrayToBase64(uint8Array: Uint8Array): string {
const length = uint8Array.length
let binaryString = ''
const chunkSize = 1024 * 1024 // Process 1MB at a time
for (let i = 0; i < length; i += chunkSize) {
const chunkEnd = Math.min(i + chunkSize, length)
const chunk = uint8Array.subarray(i, chunkEnd)
binaryString += Array.from(chunk, (byte) => String.fromCharCode(byte)).join(
''
)
}
return btoa(binaryString)
}
export function objectToUint8ArrayFromResponse(obj: any) {
const len = Object.keys(obj).length
const result = new Uint8Array(len)
for (let i = 0; i < len; i++) {
result[i] = obj[i]
}
return result
}
// export function uint8ArrayToBase64(arrayBuffer: Uint8Array): string {
// let binary = ''
// const bytes = new Uint8Array(arrayBuffer)
// const len = bytes.length
// for (let i = 0; i < len; i++) {
// binary += String.fromCharCode(bytes[i])
// }
// return btoa(binary)
// }
export function base64ToUint8Array(base64: string) {
const binaryString = atob(base64)
const len = binaryString.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes
}
export function uint8ArrayToObject(uint8Array: Uint8Array) {
// Decode the byte array using TextDecoder
const decoder = new TextDecoder()
const jsonString = decoder.decode(uint8Array)
// Convert the JSON string back into an object
const obj = JSON.parse(jsonString)
return obj
}
export function processFileInChunks(file: File): Promise<Uint8Array> {
return new Promise(
(resolve: (value: Uint8Array) => void, reject: (reason?: any) => void) => {
const reader = new FileReader()
reader.onload = function (event: ProgressEvent<FileReader>) {
const arrayBuffer = event.target?.result as ArrayBuffer
const uint8Array = new Uint8Array(arrayBuffer)
resolve(uint8Array)
}
reader.onerror = function (error: ProgressEvent<FileReader>) {
reject(error)
}
reader.readAsArrayBuffer(file)
}
)
}
// export async function processFileInChunks(file: File, chunkSize = 1024 * 1024): Promise<Uint8Array> {
// const fileStream = file.stream();
// const reader = fileStream.getReader();
// const totalLength = file.size;
// if (totalLength <= 0 || isNaN(totalLength)) {
// throw new Error('Invalid file size');
// }
// const combinedArray = new Uint8Array(totalLength);
// let offset = 0;
// while (offset < totalLength) {
// const { value, done } = await reader.read();
// if (done) {
// break;
// }
// const chunk = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
// // Set elements one by one instead of using combinedArray.set(chunk, offset)
// for (let i = 0; i < chunk.length; i++) {
// combinedArray[offset + i] = chunk[i];
// }
// offset += chunk.length;
// }
// return combinedArray;
// }

1
src/vite-env.d.ts vendored

@ -0,0 +1 @@
/// <reference types="vite/client" />

213
src/wrappers/DownloadWrapper.tsx

@ -0,0 +1,213 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
setAddToDownloads,
updateDownloads
} from '../state/features/globalSlice'
import { DownloadTaskManager } from '../components/common/DownloadTaskManager'
import { RootState } from '../state/store'
interface Props {
children: React.ReactNode
}
const defaultValues: MyContextInterface = {
downloadVideo: () => {}
}
interface IDownloadVideoParams {
name: string
service: string
identifier: string
properties: any
}
interface MyContextInterface {
downloadVideo: ({
name,
service,
identifier,
properties
}: IDownloadVideoParams) => void
}
export const MyContext = React.createContext<MyContextInterface>(defaultValues)
const DownloadWrapper: React.FC<Props> = ({ children }) => {
const dispatch = useDispatch()
const downloads = useSelector((state: RootState) => state.global?.downloads);
const fetchResource = async ({ name, service, identifier }: any) => {
try {
await qortalRequest({
action: 'GET_QDN_RESOURCE_PROPERTIES',
name,
service,
identifier
})
} catch (error) {}
}
const fetchVideoUrl = async ({ name, service, identifier }: any) => {
try {
fetchResource({ name, service, identifier })
let url = await qortalRequest({
action: 'GET_QDN_RESOURCE_URL',
service: service,
name: name,
identifier: identifier
})
if (url) {
dispatch(
updateDownloads({
name,
service,
identifier,
url
})
)
}
} catch (error) {}
}
const performDownload = ({
name,
service,
identifier,
properties
}: IDownloadVideoParams) => {
if(downloads[identifier]) return
dispatch(
setAddToDownloads({
name,
service,
identifier,
properties
})
)
let isCalling = false
let percentLoaded = 0
let timer = 24
const intervalId = setInterval(async () => {
if (isCalling) return
isCalling = true
const res = await qortalRequest({
action: 'GET_QDN_RESOURCE_STATUS',
name: name,
service: service,
identifier: identifier
})
if(res?.status === 'NOT_PUBLISHED'){
dispatch(
updateDownloads({
name,
service,
identifier,
status: res
})
)
clearInterval(intervalId)
}
isCalling = false
if (res.localChunkCount) {
if (res.percentLoaded) {
if (
res.percentLoaded === percentLoaded &&
res.percentLoaded !== 100
) {
timer = timer - 5
} else {
timer = 24
}
if (timer < 0) {
timer = 24
isCalling = true
dispatch(
updateDownloads({
name,
service,
identifier,
status: {
...res,
status: 'REFETCHING'
}
})
)
setTimeout(() => {
isCalling = false
fetchResource({
name,
service,
identifier
})
}, 25000)
return
}
percentLoaded = res.percentLoaded
}
dispatch(
updateDownloads({
name,
service,
identifier,
status: res
})
)
}
// check if progress is 100% and clear interval if true
if (res?.status === 'READY') {
clearInterval(intervalId)
dispatch(
updateDownloads({
name,
service,
identifier,
status: res
})
)
}
}, 5000) // 1 second interval
fetchVideoUrl({
name,
service,
identifier
})
}
const downloadVideo = async ({
name,
service,
identifier,
properties
}: IDownloadVideoParams) => {
try {
performDownload({
name,
service,
identifier,
properties
})
return 'addedToList'
} catch (error) {
console.error(error)
}
}
return (
<>
<MyContext.Provider value={{ downloadVideo }}>
{/* <DownloadTaskManager /> */}
{children}
</MyContext.Provider>
</>
)
}
export default DownloadWrapper

173
src/wrappers/GlobalWrapper.tsx

@ -0,0 +1,173 @@
import React, {
useEffect,
useState,
useCallback,
useRef,
useMemo,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import { addUser } from "../state/features/authSlice";
import NavBar from "../components/layout/Navbar/Navbar";
import PageLoader from "../components/common/PageLoader";
import { RootState } from "../state/store";
import { setUserAvatarHash } from "../state/features/globalSlice";
import { VideoPlayerGlobal } from "../components/common/VideoPlayerGlobal";
import { Rnd } from "react-rnd";
import { RequestQueue } from "../utils/queue";
import { EditVideo } from "../components/EditVideo/EditVideo";
import { EditPlaylist } from "../components/EditPlaylist/EditPlaylist";
import ConsentModal from "../components/common/ConsentModal";
interface Props {
children: React.ReactNode;
setTheme: (val: string) => void;
}
let timer: number | null = null;
export const queue = new RequestQueue();
const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
const dispatch = useDispatch();
const isDragging = useRef(false);
const [userAvatar, setUserAvatar] = useState<string>("");
const user = useSelector((state: RootState) => state.auth.user);
const videoPlaying = useSelector(
(state: RootState) => state.global.videoPlaying
);
const username = useMemo(() => {
if (!user?.name) return "";
return user.name;
}, [user]);
const getAvatar = React.useCallback(
async (author: string) => {
try {
const url = await qortalRequest({
action: "GET_QDN_RESOURCE_URL",
name: author,
service: "THUMBNAIL",
identifier: "qortal_avatar",
});
if (url) {
setUserAvatar(url);
dispatch(
setUserAvatarHash({
name: author,
url,
})
);
}
} catch (error) {
/* empty */
}
},
[dispatch]
);
useEffect(() => {
if (!username) return;
getAvatar(username);
}, [username, getAvatar]);
const { isLoadingGlobal } = useSelector((state: RootState) => state.global);
async function getNameInfo(address: string) {
const response = await qortalRequest({
action: "GET_ACCOUNT_NAMES",
address: address,
});
const nameData = response;
if (nameData?.length > 0) {
return nameData[0].name;
} else {
return "";
}
}
const askForAccountInformation = React.useCallback(async () => {
try {
const account = await qortalRequest({
action: "GET_USER_ACCOUNT",
});
const name = await getNameInfo(account.address);
dispatch(addUser({ ...account, name }));
} catch (error) {
console.error(error);
}
}, [dispatch]);
React.useEffect(() => {
askForAccountInformation();
}, [askForAccountInformation]);
const onDragStart = () => {
timer = Date.now();
isDragging.current = true;
};
const handleStopDrag = async () => {
const time = Date.now();
if (timer && time - timer < 300) {
isDragging.current = false;
} else {
isDragging.current = true;
}
};
const onDragStop = () => {
handleStopDrag();
};
const checkIfDrag = useCallback(() => {
return isDragging.current;
}, []);
return (
<>
{isLoadingGlobal && <PageLoader />}
<ConsentModal />
<NavBar
setTheme={(val: string) => setTheme(val)}
isAuthenticated={!!user?.name}
userName={user?.name || ""}
userAvatar={userAvatar}
authenticate={askForAccountInformation}
/>
<EditVideo />
<EditPlaylist />
<Rnd
onDragStart={onDragStart}
onDragStop={onDragStop}
style={{
display: videoPlaying ? "block" : "none",
position: "fixed",
height: "auto",
width: 350,
zIndex: 1000,
maxWidth: 800,
}}
default={{
x: 0,
y: 60,
width: 350,
height: "auto",
}}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onDrag={() => {}}
>
{videoPlaying && (
<VideoPlayerGlobal checkIfDrag={checkIfDrag} element={videoPlaying} />
)}
</Rnd>
{children}
</>
);
};
export default GlobalWrapper;

26
tsconfig.json

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"noImplicitAny": false,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"strictNullChecks": false,
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: ""
})
Loading…
Cancel
Save