Browse Source

Initial q-mail commit for in its own repo

main
Justin Ferrari 10 months ago
commit
3e71890f7b
  1. 24
      .gitignore
  2. 10
      .prettierrc.json
  3. 13
      index.html
  4. 10999
      package-lock.json
  5. 61
      package.json
  6. 1
      public/vite.svg
  7. 37
      src/App.tsx
  8. BIN
      src/assets/img/arrr.png
  9. BIN
      src/assets/img/btc.png
  10. BIN
      src/assets/img/dgb.png
  11. BIN
      src/assets/img/doge.png
  12. BIN
      src/assets/img/ltc.png
  13. BIN
      src/assets/img/q-mail-icon.png
  14. BIN
      src/assets/img/qBlogLogo.png
  15. BIN
      src/assets/img/qmaillogo.png
  16. BIN
      src/assets/img/qort.png
  17. BIN
      src/assets/img/rvn.png
  18. 1
      src/assets/react.svg
  19. 25
      src/assets/svgs/AccountCircleSVG.tsx
  20. 21
      src/assets/svgs/AlignCenterSVG.tsx
  21. 17
      src/assets/svgs/AlignLeftSVG.tsx
  22. 17
      src/assets/svgs/AlignRightSVG.tsx
  23. 17
      src/assets/svgs/BoldSVG.tsx
  24. 17
      src/assets/svgs/CodeBlockSVG.tsx
  25. 17
      src/assets/svgs/H2SVG.tsx
  26. 17
      src/assets/svgs/H3SVG.tsx
  27. 17
      src/assets/svgs/ItalicSVG.tsx
  28. 17
      src/assets/svgs/LinkSVG.tsx
  29. 25
      src/assets/svgs/NewWindowSVG.tsx
  30. 17
      src/assets/svgs/UnderlineSVG.tsx
  31. 1
      src/assets/svgs/accountCircle.svg
  32. 5
      src/assets/svgs/interfaces.ts
  33. 218
      src/components/AudioElement.tsx
  34. 96
      src/components/DynamicHeightItem.tsx
  35. 39
      src/components/DynamicHeightItemMinimal.tsx
  36. 503
      src/components/FileElement.tsx
  37. 230
      src/components/common/AudioPanel.tsx
  38. 192
      src/components/common/AudioPlayer.tsx
  39. 358
      src/components/common/AudioPublishModal.tsx
  40. 28
      src/components/common/BlockedNamesModal/BlockedNamesModal-styles.ts
  41. 100
      src/components/common/BlockedNamesModal/BlockedNamesModal.tsx
  42. 279
      src/components/common/Comments/Comment.tsx
  43. 172
      src/components/common/Comments/CommentEditor.tsx
  44. 307
      src/components/common/Comments/CommentSection.tsx
  45. 56
      src/components/common/ConfirmationModal.tsx
  46. 16
      src/components/common/CustomIcon.tsx
  47. 289
      src/components/common/DownloadTaskManager.tsx
  48. 55
      src/components/common/DraggableResizableGrid.tsx
  49. 36
      src/components/common/ErrorBoundary.tsx
  50. 232
      src/components/common/FilePanel.tsx
  51. 308
      src/components/common/GenericPublishModal.tsx
  52. 74
      src/components/common/ImageUploader.tsx
  53. 47
      src/components/common/LazyLoad.tsx
  54. 37
      src/components/common/LoaderBar.tsx
  55. 86
      src/components/common/Notification/Notification.tsx
  56. 43
      src/components/common/PageLoader.tsx
  57. 25
      src/components/common/Portal.tsx
  58. 281
      src/components/common/PostPublishModal.tsx
  59. 106
      src/components/common/PublishAudio.tsx
  60. 113
      src/components/common/PublishGeneric.tsx
  61. 106
      src/components/common/PublishVideo.tsx
  62. 289
      src/components/common/Tipping/Tipping.tsx
  63. 55
      src/components/common/UserNavbar/UserNavbar-styles.ts
  64. 135
      src/components/common/UserNavbar/UserNavbar.tsx
  65. 51
      src/components/common/VideoContent.tsx
  66. 226
      src/components/common/VideoPanel.tsx
  67. 492
      src/components/common/VideoPlayer.tsx
  68. 279
      src/components/common/VideoPublishModal.tsx
  69. 78
      src/components/editor/BlogEditor.css
  70. 579
      src/components/editor/BlogEditor.tsx
  71. 25
      src/components/editor/ReadOnlySlate.tsx
  72. 47
      src/components/editor/customTypes.ts
  73. 112
      src/components/layout/Navbar/Navbar-styles.ts
  74. 169
      src/components/layout/Navbar/Navbar.tsx
  75. 70
      src/components/modals/ConsentModal.tsx
  76. 247
      src/components/modals/EditBlogModal.tsx
  77. 281
      src/components/modals/PublishBlogModal.tsx
  78. 47
      src/components/modals/ReusableModal.tsx
  79. 3
      src/constants/mail.ts
  80. 61
      src/global.d.ts
  81. 36
      src/hooks/useConfirmModal.tsx
  82. 469
      src/hooks/useFetchMail.tsx
  83. 362
      src/hooks/useFetchPosts.tsx
  84. 168
      src/index.css
  85. 9
      src/index.d.ts
  86. 8
      src/interfaces/interfaces.ts
  87. 28
      src/main.tsx
  88. 855
      src/pages/BlogIndividualPost/BlogIndividualPost.tsx
  89. 298
      src/pages/BlogIndividualProfile/BlogIndividualProfile.tsx
  90. 230
      src/pages/BlogList/BlogList.tsx
  91. 134
      src/pages/BlogList/PostPreview-styles.ts
  92. 304
      src/pages/BlogList/PostPreview.tsx
  93. 7
      src/pages/CreateEditProfile/CreatEditProfile.tsx
  94. 14
      src/pages/CreatePost/CreatePost-styles.ts
  95. 194
      src/pages/CreatePost/CreatePost.tsx
  96. 1408
      src/pages/CreatePost/CreatePostBuilder.tsx
  97. 1389
      src/pages/CreatePost/CreatePostMinimal.tsx
  98. 261
      src/pages/CreatePost/components/Navbar/NavbarBuilder.tsx
  99. 157
      src/pages/CreatePost/components/Toolbar/EditorToolbar.tsx
  100. 562
      src/pages/EditPost/EditPost.tsx
  101. Some files were not shown because too many files have changed in this diff Show More

24
.gitignore vendored

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

10
.prettierrc.json

@ -0,0 +1,10 @@
{
"printWidth": 80,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "avoid",
"tabWidth": 2,
"semi": true
}

13
index.html

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

10999
package-lock.json generated

File diff suppressed because it is too large Load Diff

61
package.json

@ -0,0 +1,61 @@
{
"name": "q-mail",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"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",
"@tiptap/core": "^2.0.4",
"@tiptap/extension-highlight": "^2.0.4",
"@tiptap/extension-underline": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@types/react-grid-layout": "^1.3.2",
"axios": "^1.3.4",
"compressorjs": "^1.2.1",
"dompurify": "^3.0.3",
"flexlayout-react": "^0.7.9",
"localforage": "^1.10.0",
"moment": "^2.29.4",
"philliplm-react-modern-audio-player": "^1.4.6",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-grid-layout": "^1.3.4",
"react-intersection-observer": "^9.4.3",
"react-joyride": "^2.5.4",
"react-masonry-css": "^1.0.16",
"react-redux": "^8.0.5",
"react-resize-detector": "^8.0.4",
"react-router-dom": "^6.9.0",
"react-toastify": "^9.1.2",
"react-virtuoso": "^4.3.3",
"short-unique-id": "^4.4.4",
"slate": "^0.91.4",
"slate-history": "^0.86.0",
"slate-react": "^0.91.11"
},
"devDependencies": {
"@mui/types": "^7.2.3",
"@types/dompurify": "^3.0.2",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-legacy": "^4.0.3",
"@vitejs/plugin-react-swc": "^3.2.0",
"core-js": "^3.30.2",
"prettier": "^2.8.6",
"typescript": "^4.9.3",
"vite": "^4.2.0",
"worker-loader": "^3.0.8"
}
}

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

After

Width:  |  Height:  |  Size: 1.5 KiB

37
src/App.tsx

@ -0,0 +1,37 @@
// @ts-nocheck
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 DownloadWrapper from './wrappers/DownloadWrapper'
import Notification from './components/common/Notification/Notification'
import { Mail } from './pages/Mail/Mail'
function App() {
const themeColor = window._qdnTheme
return (
<Provider store={store}>
<ThemeProvider theme={darkTheme}>
<Notification />
<DownloadWrapper>
<GlobalWrapper>
<CssBaseline />
<Routes>
<Route path="/" element={<Mail />} />
<Route path="/to/:name" element={<Mail isFromTo />} />
</Routes>
</GlobalWrapper>
</DownloadWrapper>
</ThemeProvider>
</Provider>
)
}
export default App

BIN
src/assets/img/arrr.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/assets/img/btc.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/assets/img/dgb.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
src/assets/img/doge.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
src/assets/img/ltc.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/assets/img/q-mail-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
src/assets/img/qBlogLogo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
src/assets/img/qmaillogo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
src/assets/img/qort.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/assets/img/rvn.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 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>
)
}

21
src/assets/svgs/AlignCenterSVG.tsx

@ -0,0 +1,21 @@
import { SVGProps } from './interfaces'
export const AlignCenterSVG: React.FC<SVGProps> = ({
color,
height,
width
}) => {
return (
<svg
height={height}
width={width}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 96 960 960"
>
<path
fill={color}
d="M150 936q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 876h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 936H150Zm164-165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T314 711h333q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T647 771H314ZM150 606q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 546h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 606H150Zm164-165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T314 381h333q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T647 441H314ZM150 276q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 216h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 276H150Z"
/>
</svg>
)
}

17
src/assets/svgs/AlignLeftSVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const AlignLeftSVG: React.FC<SVGProps> = ({ 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="M150 771q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 711h412q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T562 771H150Zm0-330q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 381h412q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T562 441H150Zm0 165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 546h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 606H150Zm0 330q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 876h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 936H150Zm0-660q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 216h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 276H150Z"
/>
</svg>
)
}

17
src/assets/svgs/AlignRightSVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const AlignRightSVG: React.FC<SVGProps> = ({ 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="M150 936q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 876h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 936H150Zm249-165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T399 711h411q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 771H399ZM150 606q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 546h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 606H150Zm249-165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T399 381h411q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 441H399ZM150 276q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 216h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 276H150Z"
/>
</svg>
)
}

17
src/assets/svgs/BoldSVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const BoldSVG: React.FC<SVGProps> = ({ 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="M335 856q-25 0-42.5-17.5T275 796V356q0-25 17.5-42.5T335 296h168q66 0 114.5 42T666 444q0 38-21 70t-56 49v6q43 14 69.5 50t26.5 81q0 68-52.5 112T510 856H335Zm26-76h144q38 0 66-25t28-63q0-37-28-62t-66-25H361v175Zm0-247h136q35 0 60.5-23t25.5-58q0-35-25.5-58.5T497 370H361v163Z"
/>
</svg>
)
}

17
src/assets/svgs/CodeBlockSVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const CodeBlockSVG: React.FC<SVGProps> = ({ 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="m330 576 70-70q9-9 9-22t-9-22q-9-9-21.833-9-12.834 0-22.167 9l-93 93q-5 5-7 10.133-2 5.134-2 11Q254 582 256 587q2 5 7 10l94 94q9.333 9 22.167 9Q392 700 401 691q9-9 9-22t-9-22l-71-71Zm300 0-71 71q-9 9-9 22t9 22q9 9 21.833 9 12.834 0 22.167-9l94-94q5-5 7-10.133 2-5.134 2-11Q706 570 704 565q-2-5-7-10l-94-94q-4-5-10-7t-12-2q-6 0-11.5 2t-10.167 6.8Q550 470.4 550 483.2q0 12.8 9 21.8l71 71ZM180 936q-24 0-42-18t-18-42V276q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600V276H180v600Zm0-600v600-600Z"
/>
</svg>
)
}

17
src/assets/svgs/H2SVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const H2SVG: React.FC<SVGProps> = ({ 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="M149.825 776Q137 776 128.5 767.375T120 746V406q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T180 406v140h180V406q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T420 406v340q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625-12.825 0-21.325-8.625T360 746V606H180v140q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625ZM570 776q-12.75 0-21.375-8.625T540 746V606q0-24.75 17.625-42.375T600 546h180V436H570q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T570 376h210q24.75 0 42.375 17.625T840 436v110q0 24.75-17.625 42.375T780 606H600v110h210q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 776H570Z"
/>
</svg>
)
}

17
src/assets/svgs/H3SVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const H3SVG: React.FC<SVGProps> = ({ 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="M149.825 776Q137 776 128.5 767.375T120 746V406q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T180 406v140h180V406q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T420 406v340q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625-12.825 0-21.325-8.625T360 746V606H180v140q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625ZM570 776q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T570 716h210V606H650q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T650 546h130V436H570q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T570 376h210q24.75 0 42.375 17.625T840 436v280q0 24.75-17.625 42.375T780 776H570Z"
/>
</svg>
)
}

17
src/assets/svgs/ItalicSVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const ItalicSVG: React.FC<SVGProps> = ({ 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="M264 857q-16.8 0-28.4-11.641-11.6-11.641-11.6-28.5t11.6-28.359Q247.2 777 264 777h94l139-409H378q-16.8 0-28.4-11.641-11.6-11.641-11.6-28.5t11.6-28.359Q361.2 288 378 288h300q16.8 0 28.4 11.641 11.6 11.641 11.6 28.5T706.4 356.5Q694.8 368 678 368h-94L445 777h119q16.8 0 28.4 11.641 11.6 11.641 11.6 28.5T592.4 845.5Q580.8 857 564 857H264Z"
/>
</svg>
)
}

17
src/assets/svgs/LinkSVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const LinkSVG: React.FC<SVGProps> = ({ 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="M280 776q-85 0-142.5-57.5T80 576q0-85 57.5-142.5T280 376h140q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T420 436H280q-60 0-100 40t-40 100q0 60 40 100t100 40h140q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T420 776H280Zm75-170q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T355 546h250q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T605 606H355Zm185 170q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T540 716h140q60 0 100-40t40-100q0-60-40-100t-100-40H540q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T540 376h140q85 0 142.5 57.5T880 576q0 85-57.5 142.5T680 776H540Z"
/>
</svg>
)
}

25
src/assets/svgs/NewWindowSVG.tsx

@ -0,0 +1,25 @@
interface NewWindowSVGProps {
color: string
height: string
width: string
}
export const NewWindowSVG: React.FC<NewWindowSVGProps> = ({
color,
height,
width
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={height}
width={width}
viewBox="0 96 960 960"
>
<path
d="M180 936q-24 0-42-18t-18-42V276q0-24 18-42t42-18h300v60H180v600h600V576h60v300q0 24-18 42t-42 18H180Zm480-420V396H540v-60h120V216h60v120h120v60H720v120h-60Z"
fill={color}
/>
</svg>
)
}

17
src/assets/svgs/UnderlineSVG.tsx

@ -0,0 +1,17 @@
import { SVGProps } from './interfaces'
export const UnderlineSVG: React.FC<SVGProps> = ({ 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="M230 916q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T230 856h500q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T730 916H230Zm250-140q-100 0-156.5-58.5T267 559V257q0-16.882 12.527-28.941Q292.055 216 309.027 216 326 216 338 228.059T350 257v302q0 63 34 101t96 38q62 0 96-38t34-101V257q0-16.882 12.527-28.941Q635.055 216 652.027 216 669 216 681 228.059T693 257v302q0 100-56.5 158.5T480 776Z"
/>
</svg>
)
}

1
src/assets/svgs/accountCircle.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path 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>

After

Width:  |  Height:  |  Size: 896 B

5
src/assets/svgs/interfaces.ts

@ -0,0 +1,5 @@
export interface SVGProps {
color: string
height: string
width: string
}

218
src/components/AudioElement.tsx

@ -0,0 +1,218 @@
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 AudiotrackIcon from '@mui/icons-material/Audiotrack'
import { MyContext } from '../wrappers/DownloadWrapper'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../state/store'
import { CircularProgress } from '@mui/material'
import {
setCurrAudio,
setShowingAudioPlayer
} from '../state/features/globalSlice'
const Widget = styled('div')(({ theme }) => ({
padding: 16,
borderRadius: 16,
maxWidth: '100%',
position: 'relative',
zIndex: 1,
// backgroundColor:
// theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.4)',
backdropFilter: 'blur(40px)',
background: 'skyblue',
transition: '0.2s all',
'&:hover': {
opacity: 0.75
}
}))
const CoverImage = styled('div')({
width: 100,
height: 100,
objectFit: 'cover',
overflow: 'hidden',
flexShrink: 0,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.08)',
'& > img': {
width: '100%'
}
})
const TinyText = styled(Typography)({
fontSize: '0.75rem',
opacity: 0.38,
fontWeight: 500,
letterSpacing: 0.2
})
interface IAudioElement {
onClick: () => void
title: string
description: string
author: string
audioInfo?: any
postId?: string
user?: string
}
export default function AudioElement({
onClick,
title,
description,
author,
audioInfo,
postId,
user
}: IAudioElement) {
const { downloadVideo } = React.useContext(MyContext)
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const { downloads } = useSelector((state: RootState) => state.global)
const dispatch = useDispatch()
const download = React.useMemo(() => {
if (!downloads || !audioInfo?.identifier) return {}
const findDownload = downloads[audioInfo?.identifier]
if (!findDownload) return {}
return findDownload
}, [downloads, audioInfo])
const resourceStatus = React.useMemo(() => {
return download?.status || {}
}, [download])
const handlePlay = () => {
if (!postId) return
const { name, service, identifier } = audioInfo
if (download && resourceStatus?.status === 'READY') {
dispatch(setShowingAudioPlayer(true))
dispatch(setCurrAudio(identifier))
return
}
setIsLoading(true)
downloadVideo({
name,
service,
identifier,
blogPost: {
postId,
user,
audioTitle: title,
audioDescription: description,
audioAuthor: author
}
})
dispatch(setCurrAudio(identifier))
dispatch(setShowingAudioPlayer(true))
}
React.useEffect(() => {
if (resourceStatus?.status === 'READY') {
setIsLoading(false)
}
}, [resourceStatus])
return (
<Box
onClick={handlePlay}
sx={{
width: '100%',
overflow: 'hidden',
position: 'relative',
cursor: 'pointer'
}}
>
<Widget>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CoverImage>
<AudiotrackIcon
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>
<b>{title}</b>
</Typography>
<Typography noWrap letterSpacing={-0.25}>
{description}
</Typography>
</Box>
</Box>
{((resourceStatus.status && resourceStatus?.status !== 'READY') ||
isLoading) && (
<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: '16px',
borderRadius: '16px'
}}
>
<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 audio...</>
) : resourceStatus?.status !== 'READY' ? (
<>
{(
(resourceStatus?.localChunkCount /
resourceStatus?.totalChunkCount) *
100
)?.toFixed(0)}
%
</>
) : (
<>Download Completed: fetching audio...</>
)}
</Typography>
)}
</Box>
)}
</Widget>
</Box>
)
}

96
src/components/DynamicHeightItem.tsx

@ -0,0 +1,96 @@
import React, { useRef, useState, useEffect } from 'react'
import ReactResizeDetector from 'react-resize-detector'
import { Layouts, Layout } from 'react-grid-layout'
interface DynamicHeightItemProps {
children: React.ReactNode
layouts: Layouts
setLayouts: (layouts: any) => void
i: string
breakpoint: keyof Layouts
rows?: number
count?: number
type?: string
padding?: number
}
const DynamicHeightItem: React.FC<DynamicHeightItemProps> = ({
children,
layouts,
setLayouts,
i,
breakpoint,
rows = 1,
count,
type,
padding
}) => {
const [height, setHeight] = useState<number>(rows * 150)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
setHeight(ref.current.clientHeight)
}
}, [ref.current])
const onResize = () => {
if (ref.current) {
setHeight(ref.current.clientHeight)
}
}
const getBreakpoint = (screenWidth: number) => {
if (screenWidth >= 996) {
return 'md'
} else if (screenWidth >= 768) {
return 'sm'
} else {
return 'xs'
}
}
useEffect(() => {
const widthWin = window.innerWidth
let newBreakpoint = breakpoint
// if (!newBreakpoint) {
// newBreakpoint = getBreakpoint(widthWin)
// }
setLayouts((prev: any) => {
const newLayouts: any = { ...prev }
newLayouts[newBreakpoint] = newLayouts[newBreakpoint]?.map(
(item: Layout) => {
if (item.i === i) {
let constantNum = 25
return {
...item,
h: Math.ceil(height / (rows * constantNum)) // Adjust this value based on your rowHeight and the number of rows the element spans
}
}
return item
}
)
return newLayouts
})
}, [height, breakpoint, count, setLayouts])
return (
<div ref={ref} style={{ width: '100%', height: 'auto' }}>
<ReactResizeDetector handleHeight onResize={onResize}>
<div
style={{
padding: `${padding ? padding : 0}px`
}}
>
{children}
</div>
</ReactResizeDetector>
</div>
)
}
export default DynamicHeightItem

39
src/components/DynamicHeightItemMinimal.tsx

@ -0,0 +1,39 @@
import React, { useRef, useState, useEffect } from 'react'
import ReactResizeDetector from 'react-resize-detector'
import { Layouts, Layout } from 'react-grid-layout'
interface DynamicHeightItemProps {
children: React.ReactNode
layouts: Layouts
setLayouts: (layouts: any) => void
i: string
breakpoint: keyof Layouts
rows?: number
count?: number
type?: string
padding?: number
}
export const DynamicHeightItemMinimal: React.FC<DynamicHeightItemProps> = ({
children,
layouts,
setLayouts,
i,
breakpoint,
rows = 1,
count,
type,
padding
}) => {
return (
<div style={{ width: '100%', height: 'auto' }}>
<div
style={{
padding: `${padding ? padding : 0}px`
}}
>
{children}
</div>
</div>
)
}

503
src/components/FileElement.tsx

@ -0,0 +1,503 @@
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 AudiotrackIcon from '@mui/icons-material/Audiotrack'
import { MyContext } from '../wrappers/DownloadWrapper'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../state/store'
import { CircularProgress } from '@mui/material'
import AttachFileIcon from '@mui/icons-material/AttachFile'
import {
setCurrAudio,
setShowingAudioPlayer
} from '../state/features/globalSlice'
import {
base64ToUint8Array,
objectToUint8ArrayFromResponse
} from '../utils/toBase64'
import { setNotification } from '../state/features/notificationsSlice'
const Widget = styled('div')(({ theme }) => ({
padding: 8,
borderRadius: 10,
maxWidth: 350,
position: 'relative',
zIndex: 1,
// backgroundColor:
// theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.4)',
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%'
}
})
const TinyText = styled(Typography)({
fontSize: '0.75rem',
opacity: 0.38,
fontWeight: 500,
letterSpacing: 0.2
})
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
}
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,
postId = '',
user,
children,
mimeType,
disable,
mode,
otherUser
}: IAudioElement) {
const { downloadVideo } = React.useContext(MyContext)
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [fileProperties, setFileProperties] = React.useState<any>(null)
const [downloadLoader, setDownloadLoader] = React.useState<any>(false)
const [pdfSrc, setPdfSrc] = React.useState('')
const { downloads } = useSelector((state: RootState) => state.global)
const { user: username } = useSelector((state: RootState) => state.auth)
const hasCommencedDownload = React.useRef(false)
const dispatch = useDispatch()
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 saveFileToDisk = async (blob: any, fileName: any) => {
try {
const fileHandle = await customWindow.showSaveFilePicker({
suggestedName: fileName,
types: [
{
description: 'File'
}
]
})
const writeFile = async (fileHandle: any, contents: any) => {
const writable = await fileHandle.createWritable()
await writable.write(contents)
await writable.close()
}
writeFile(fileHandle, blob).then(() => console.log('FILE SAVED'))
} catch (error) {
console.log(error)
}
}
const handlePlay = async () => {
if (disable) return
hasCommencedDownload.current = true
if (
resourceStatus?.status === 'READY' &&
download?.url &&
download?.blogPost?.filename
) {
if (downloadLoader) return
dispatch(
setNotification({
msg: 'Saving file... please wait',
alertType: 'info'
})
)
setDownloadLoader(true)
try {
const { name, service, identifier } = fileInfo
if (mode === 'mail') {
let res = await qortalRequest({
action: 'FETCH_QDN_RESOURCE',
name: name,
service: service,
identifier: identifier,
encoding: 'base64'
})
// const toUnit8Array = base64ToUint8Array(res)
const resName = await qortalRequest({
action: 'GET_NAME_DATA',
// change this
name: otherUser
})
if (!resName?.owner)
throw new Error('Unable to locate details to decrypt file')
const recipientAddress = resName.owner
const resAddress = await qortalRequest({
action: 'GET_ACCOUNT_DATA',
address: recipientAddress
})
if (!resAddress?.publicKey)
throw new Error('Unable to locate details to decrypt file')
const recipientPublicKey = resAddress.publicKey
let requestEncryptBody: any = {
action: 'DECRYPT_DATA',
encryptedData: res,
publicKey: recipientPublicKey
}
const resDecrypt = await qortalRequest(requestEncryptBody)
if (!resDecrypt) throw new Error('Unable to decrypt file')
const decryptToUnit8Array = base64ToUint8Array(resDecrypt)
let blob = null
if (download?.blogPost?.mimeType) {
blob = new Blob([decryptToUnit8Array], {
type: download?.blogPost?.mimeType
})
} else {
blob = new Blob([decryptToUnit8Array])
}
if (!blob) throw new Error('Unable build file into blob')
await qortalRequest({
action: 'SAVE_FILE',
blob,
filename:
download?.blogPost?.originalFilename ||
download?.blogPost?.filename,
mimeType: download?.blogPost?.mimeType || ''
})
return
}
const url = `/arbitrary/${service}/${name}/${identifier}`
fetch(url)
.then((response) => response.blob())
.then(async (blob) => {
await qortalRequest({
action: 'SAVE_FILE',
blob,
filename: download?.blogPost?.filename,
mimeType: download?.blogPost?.mimeType || ''
})
// saveAs(blob, download?.blogPost?.filename)
})
.catch((error) => {
console.error('Error fetching the video:', error)
// clearInterval(intervalId)
})
} catch (error: any) {
let notificationObj = 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
}
if (!postId && mode !== 'mail') return
const { name, service, identifier } = fileInfo
let filename = fileProperties?.filename
let mimeType = fileProperties?.mimeType
if (!fileProperties) {
try {
dispatch(
setNotification({
msg: 'Downloading file... please wait',
alertType: 'info'
})
)
let res = await qortalRequest({
action: 'GET_QDN_RESOURCE_PROPERTIES',
name: name,
service: service,
identifier: identifier
})
setFileProperties(res)
filename = res?.filename
mimeType = res?.mimeType
} catch (error: any) {
console.log({ error })
dispatch(
setNotification({
msg: error?.message || 'Error with download. Please try again',
alertType: 'error'
})
)
}
}
if (!filename) return
downloadVideo({
name,
service,
identifier,
blogPost: {
postId,
user,
audioTitle: title,
audioDescription: description,
audioAuthor: author,
filename,
mimeType,
originalFilename: fileInfo?.originalFilename
}
})
}
React.useEffect(() => {
if (
resourceStatus?.status === 'READY' &&
download?.url &&
download?.blogPost?.filename &&
hasCommencedDownload.current
) {
setIsLoading(false)
dispatch(
setNotification({
msg: 'Download completed. Click to save file',
alertType: 'info'
})
)
}
}, [resourceStatus, download])
return (
<Box
onClick={handlePlay}
sx={{
width: '100%',
overflow: 'hidden',
position: 'relative',
cursor: 'pointer'
}}
>
{children && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
position: 'relative',
gap: '7px'
}}
>
{children}{' '}
{(resourceStatus.status && resourceStatus?.status !== 'READY') ||
isLoading ? (
<CircularProgress color="secondary" size={14} />
) : 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) && (
<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?.blogPost?.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>
)
}

230
src/components/common/AudioPanel.tsx

@ -0,0 +1,230 @@
import React, { useState, useEffect } from 'react'
import { styled, Box } from '@mui/system'
import {
Drawer,
List,
ListItem,
ListItemText,
Typography,
ButtonBase,
Button,
Tooltip
} from '@mui/material'
import VideoCallIcon from '@mui/icons-material/VideoCall'
import VideoModal from './VideoPublishModal'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { AudioModal } from './AudioPublishModal'
import AudioFileIcon from '@mui/icons-material/AudioFile'
interface VideoPanelProps {
onSelect: (video: Video) => void
height?: string
width?: string
}
interface VideoApiResponse {
videos: Video[]
}
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;
}
`
const PublishButton = styled(Button)`
/* position: absolute;
bottom: 20px;
left: 0;
right: 0;
margin: auto; */
max-width: 80%;
`
export const AudioPanel: React.FC<VideoPanelProps> = ({
onSelect,
height,
width
}) => {
const [isOpen, setIsOpen] = useState(false)
const [videos, setVideos] = useState<Video[]>([])
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false)
const { user } = useSelector((state: RootState) => state.auth)
const fetchVideos = React.useCallback(async (): Promise<Video[]> => {
if (!user?.name) return []
let res
try {
res = await qortalRequest({
action: 'LIST_QDN_RESOURCES',
service: 'AUDIO',
name: user.name,
includeMetadata: true,
limit: 100,
offset: 0,
reverse: true
})
} catch (error) {
}
// Replace this URL with the actual API endpoint
return res
}, [user])
useEffect(() => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
}, [])
const handleToggle = () => {
setIsOpen(!isOpen)
}
const handleClick = (video: Video) => {
onSelect(video)
}
return (
<Box
sx={{
display: 'flex'
}}
>
<Tooltip title="Add an audio file" arrow>
<AudioFileIcon
onClick={handleToggle}
sx={{
height: height || '30px',
width: width || 'auto',
cursor: 'pointer'
}}
></AudioFileIcon>
</Tooltip>
<Drawer
anchor="right"
open={isOpen}
onClose={handleToggle}
ModalProps={{
keepMounted: true // Better performance on mobile
}}
sx={{
'& .MuiPaper-root': {
width: '400px'
}
}}
>
<Panel>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0'
}}
>
<Typography
variant="h5"
component="div"
sx={{ flexGrow: 1, mt: 2, mb: 1 }}
>
Select Audio
</Typography>
<Typography
variant="subtitle2"
component="div"
sx={{ flexGrow: 1, mb: 2 }}
>
List of audios in QDN under your name
</Typography>
</Box>
<List
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
flex: '1',
overflow: 'auto'
}}
>
{videos.map((video) => (
<ListItem key={video.identifier}>
<ButtonBase
onClick={() => handleClick(video)}
sx={{ width: '100%' }}
>
<ListItemText
primary={video?.metadata?.title || ''}
secondary={video?.metadata?.description || ''}
/>
</ButtonBase>
</ListItem>
))}
</List>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
flex: '0 0 50px'
}}
>
<PublishButton
variant="contained"
onClick={() => setIsOpenVideoModal(true)}
>
Publish new audio file
</PublishButton>
</Box>
</Panel>
</Drawer>
<AudioModal
onClose={() => {
setIsOpenVideoModal(false)
}}
open={isOpenVideoModal}
onPublish={(value) => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
setIsOpenVideoModal(false)
}}
/>
</Box>
)
}
// Add this to your 'types.ts' file
export interface Video {
name: string
service: string
identifier: string
metadata: {
title: string
description: string
tags: string[]
category: string
categoryName: string
}
size: number
created: number
updated: number
}

192
src/components/common/AudioPlayer.tsx

@ -0,0 +1,192 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Box, IconButton, Slider } from '@mui/material'
import { CircularProgress, Typography } from '@mui/material'
import AudioPlyr from 'philliplm-react-modern-audio-player'
import LinearProgress from '@mui/material/LinearProgress'
import {
PlayArrow,
Pause,
VolumeUp,
Fullscreen,
PictureInPicture
} from '@mui/icons-material'
import { styled } from '@mui/system'
import {
removeAudio,
setShowingAudioPlayer
} from '../../state/features/globalSlice'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
const VideoContainer = styled(Box)`
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin: 20px 0px;
z-index: 501;
`
const VideoElement = styled('video')`
width: 100%;
height: auto;
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
title?: string
description?: string
playlist?: IPlaylist[]
currAudio: number | null
}
export interface IPlaylist {
name: string
identifier: string
service: string
title: string
description: string
}
interface CustomWindow extends Window {
_qdnTheme: any // Replace 'any' with the appropriate type if you know it
}
const customWindow = window as unknown as CustomWindow
const themeColor = customWindow?._qdnTheme
export const AudioPlayer: React.FC<VideoPlayerProps> = ({ currAudio }) => {
const [isLoading, setIsLoading] = useState<boolean>(false)
const { downloads, showingAudioPlayer } = useSelector(
(state: RootState) => state.global
)
const dispatch = useDispatch()
const downloadsLength: number = useMemo(
() =>
Object.keys(downloads)
.map((item) => {
return downloads[item]
})
.filter(
(download: any) =>
download?.service === 'AUDIO' &&
download?.status?.status === 'READY' &&
!!download.url
).length,
[downloads]
)
const audioPlayList = useMemo(() => {
const filterAudios = Object.keys(downloads)
.map((item) => {
return downloads[item]
})
.filter(
(download: any) =>
download?.service === 'AUDIO' &&
download?.url &&
download?.status?.status === 'READY'
)
return filterAudios.map((audio: any, index: number) => {
return {
name: audio?.blogPost?.audioTitle,
src: audio?.url,
id: index + 1,
identifier: audio?.identifier,
description: audio?.blogPost?.audioDescription || ''
}
})
}, [downloadsLength])
const currAudioMemo: number | null = useMemo(() => {
const findIndex = audioPlayList.findIndex(
(item) => item?.identifier === currAudio
)
if (findIndex !== -1) {
return findIndex
}
return null
}, [audioPlayList, currAudio])
if (isLoading)
return (
<Box
sx={{
isolation: 'isolate',
width: '100%',
position: 'fixed',
colorScheme: 'light',
bottom: '0px',
padding: '10px',
height: '50px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start'
}}
>
<Typography
sx={{
fontSize: '10px'
}}
>
Loading playlist...
</Typography>
<LinearProgress
sx={{
width: '100%'
}}
/>
</Box>
)
if (audioPlayList.length === 0 || !showingAudioPlayer) return null
return (
<VideoContainer>
<AudioPlyr
rootContainerProps={{
defaultColorScheme: themeColor === 'dark' ? 'dark' : 'light',
colorScheme: themeColor === 'dark' ? 'dark' : 'light'
}}
currentIndex={currAudioMemo}
playList={audioPlayList}
activeUI={{
all: true
}}
placement={{
player: 'bottom',
playList: 'top',
volumeSlider: 'top'
}}
closeCallback={() => {
dispatch(setShowingAudioPlayer(false))
}}
// rootContainerProps={{
// colorScheme: theme,
// width
// }}
/>
</VideoContainer>
)
}

358
src/components/common/AudioPublishModal.tsx

@ -0,0 +1,358 @@
import React, { useState } from 'react'
import {
Box,
Button,
Modal,
TextField,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
SelectChangeEvent,
OutlinedInput,
Chip,
IconButton
} from '@mui/material'
import { styled } from '@mui/system'
import { useDropzone } from 'react-dropzone'
import { toBase64 } from '../../utils/toBase64'
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
import { usePublishAudio } from './PublishAudio'
const StyledModal = styled(Modal)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}))
const ChipContainer = styled(Box)({
display: 'flex',
flexWrap: 'wrap',
'& > *': {
margin: '4px'
}
})
const ModalContent = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(4),
borderRadius: theme.spacing(1),
width: '40%',
'&:focus': {
outline: 'none'
}
}))
interface VideoModalProps {
open: boolean
onClose: () => void
onPublish: (value: any) => void
}
interface SelectOption {
id: string
name: string
}
async function addAudioCoverImage(
base64Audio: string,
coverImageBase64: string
): Promise<string> {
// Decode the base64 audio data
const audioData: Uint8Array = new Uint8Array(
atob(base64Audio)
.split('')
.map((char) => char.charCodeAt(0))
)
const decoder: TextDecoder = new TextDecoder('utf-8')
const decodedAudioData: string = decoder.decode(audioData)
// Create a Blob object from the decoded audio data
const blob: Blob = new Blob([decodedAudioData], { type: 'audio/mpeg' })
// Create a new file name for the audio with cover image
const fileName: string = 'audio-with-cover.mp3'
// Create a new FormData object to hold the file and metadata
const formData: FormData = new FormData()
formData.append('file', blob, fileName)
// Create a new image object from the base64 data
const image: HTMLImageElement = new Image()
image.src = `data:image/png;base64,${coverImageBase64}`
// Wait for the image to load before getting its dimensions
await new Promise((resolve) => {
image.onload = () => resolve(null)
})
// Get the image dimensions
const width: number = image.width
const height: number = image.height
// Create a new metadata object with the image dimensions
const metadata: any = {
title: 'Audio with Cover',
artist: 'Artist Name',
album: 'Album Name',
trackNumber: 1,
image: {
mime: 'image/png',
type: 3,
description: 'Cover Image',
data: coverImageBase64,
width: width,
height: height
}
}
// Set the metadata on the file
formData.set('metadata', JSON.stringify(metadata))
// Create a new URL object for the file
const url: string = URL.createObjectURL(blob)
// Create a download link for the file
const link: HTMLAnchorElement = document.createElement('a')
link.href = url
link.download = fileName
link.click()
// Read the downloaded file and return its contents as a base64 string
const fileReader: FileReader = new FileReader()
fileReader.readAsDataURL(blob)
return await new Promise<string>((resolve, reject) => {
fileReader.onload = () => {
const base64: string | undefined = fileReader.result?.toString()
if (base64 !== undefined) {
resolve(base64)
} else {
reject(new Error('Failed to read downloaded file.'))
}
}
fileReader.onerror = () => reject(fileReader.error)
})
}
export const AudioModal: React.FC<VideoModalProps> = ({
open,
onClose,
onPublish
}) => {
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
null
)
const [inputValue, setInputValue] = useState<string>('')
const [chips, setChips] = useState<string[]>([])
const [options, setOptions] = useState<SelectOption[]>([])
const [tags, setTags] = useState<string[]>([])
const { publishAudio } = usePublishAudio()
const { getRootProps, getInputProps } = useDropzone({
accept: {
'audio/*': []
},
maxFiles: 1,
onDrop: (acceptedFiles) => {
setFile(acceptedFiles[0])
}
})
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTitle(event.target.value)
}
const handleDescriptionChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setDescription(event.target.value)
}
const handleOptionChange = (event: SelectChangeEvent<string>) => {
const optionId = event.target.value
const selectedOption = options.find((option) => option.id === optionId)
setSelectedOption(selectedOption || null)
}
const handleChipDelete = (index: number) => {
const newChips = [...chips]
newChips.splice(index, 1)
setChips(newChips)
}
const handleSubmit = async () => {
const missingFields = []
if (!title) missingFields.push('title')
if (!file) missingFields.push('file')
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ')
const errMsg = `Missing: ${missingFieldsString}`
return
}
if (!file) return
const formattedTags: { [key: string]: string } = {}
chips.forEach((tag, i) => {
formattedTags[`tag${i + 1}`] = tag
})
try {
const base64 = await toBase64(file)
if (typeof base64 !== 'string') return
const base64String = base64.split(',')[1]
const res = await publishAudio({
title,
description,
base64: base64String,
category: selectedOption?.id || '',
...formattedTags
})
onPublish(res)
setFile(null)
setTitle('')
setDescription('')
onClose()
} catch (error) {}
}
const handleInputChange = (event: any) => {
setInputValue(event.target.value)
}
const handleInputKeyDown = (event: any) => {
if (event.key === 'Enter' && inputValue !== '') {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
} else {
event.preventDefault()
}
}
}
const addChip = () => {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
}
}
const getListCategories = React.useCallback(async () => {
try {
const url = `/arbitrary/categories`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
setOptions(responseData)
} catch (error) {}
}, [])
React.useEffect(() => {
getListCategories()
}, [getListCategories])
return (
<StyledModal open={open} onClose={onClose}>
<ModalContent>
<Typography variant="h6" component="h2" gutterBottom>
Upload Audio
</Typography>
<Box
{...getRootProps()}
sx={{
border: '1px dashed gray',
padding: 2,
textAlign: 'center',
marginBottom: 2
}}
>
<input {...getInputProps()} />
<Typography>
{file
? file.name
: 'Drag and drop an audio file here or click to select a file'}
</Typography>
</Box>
<TextField
label="Audio Title"
variant="outlined"
fullWidth
value={title}
onChange={handleTitleChange}
inputProps={{ maxLength: 40 }}
sx={{ marginBottom: 2 }}
/>
<TextField
label="Audio Description"
variant="outlined"
fullWidth
multiline
rows={4}
value={description}
onChange={handleDescriptionChange}
inputProps={{ maxLength: 180 }}
sx={{ marginBottom: 2 }}
/>
{options.length > 0 && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedOption?.id || ''}
onChange={handleOptionChange}
>
{options.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<TextField
label="Add a tag"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
disabled={chips.length === 3}
/>
<IconButton onClick={addChip} disabled={chips.length === 3}>
<AddIcon />
</IconButton>
</Box>
<ChipContainer>
{chips.map((chip, index) => (
<Chip
key={index}
label={chip}
onDelete={() => handleChipDelete(index)}
deleteIcon={<CloseIcon />}
/>
))}
</ChipContainer>
</FormControl>
<Button variant="contained" color="primary" onClick={handleSubmit}>
Submit
</Button>
</ModalContent>
</StyledModal>
)
}

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_q-blog`
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_q-blog',
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: 'Arial'
}}
onClick={() => removeFromBlockList(name)}
>
Remove
</Button>
</ListItem>
))}
</List>
<Button variant="contained" color="primary" onClick={onClose}>
Close
</Button>
</ModalContent>
</StyledModal>
)
}

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

@ -0,0 +1,279 @@
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
useTheme
} from '@mui/material'
import React, { useCallback, useState } from 'react'
import { CommentEditor } from './CommentEditor'
import { CardContentContainerComment } from '../../../pages/BlogList/PostPreview-styles'
import { StyledCardHeaderComment } from '../../../pages/BlogList/PostPreview-styles'
import { StyledCardColComment } from '../../../pages/BlogList/PostPreview-styles'
import { AuthorTextComment } from '../../../pages/BlogList/PostPreview-styles'
import { StyledCardContentComment } from '../../../pages/BlogList/PostPreview-styles'
import { useSelector } from 'react-redux'
import { RootState } from '../../../state/store'
import Portal from '../Portal'
import { Tipping } from '../Tipping/Tipping'
interface CommentProps {
comment: any
postId: string
onSubmit: (obj?: any, isEdit?: boolean) => void
}
export const Comment = ({ comment, postId, 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 handleSubmit = useCallback((comment: any, isEdit?: boolean) => {
onSubmit(comment, isEdit)
setCurrentEdit(null)
setIsReplying(false)
}, [])
return (
<Box
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}
isEdit
commentId={currentEdit?.identifier}
commentMessage={currentEdit?.message}
/>
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setCurrentEdit(null)}>
Close
</Button>
</DialogActions>
</Dialog>
</Portal>
)}
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'column'
}}
>
<CommentCard
name={comment?.name}
message={comment?.message}
replies={comment?.replies || []}
setCurrentEdit={setCurrentEdit}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '5px',
marginTop: '20px',
justifyContent: 'flex-end'
}}
>
<Button
size="small"
variant="contained"
onClick={() => setIsReplying(true)}
>
reply
</Button>
{user?.name === comment?.name && (
<Button
size="small"
variant="contained"
onClick={() => setCurrentEdit(comment)}
>
edit
</Button>
)}
{isReplying && (
<Button
size="small"
variant="contained"
onClick={() => {
setIsReplying(false)
setIsEditing(false)
}}
>
close
</Button>
)}
</Box>
</CommentCard>
{/* <Typography variant="body1"> {comment?.message}</Typography> */}
</Box>
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'column',
alignItems: 'center'
}}
>
{isReplying && (
<CommentEditor
onSubmit={handleSubmit}
postId={postId}
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 {
let url = await qortalRequest({
action: 'GET_QDN_RESOURCE_URL',
name: author,
service: 'THUMBNAIL',
identifier: 'qortal_avatar'
})
setAvatarUrl(url)
} catch (error) {}
}, [])
React.useEffect(() => {
getAvatar(name)
}, [name])
return (
<CardContentContainerComment>
<StyledCardHeaderComment
sx={{
'& .MuiCardHeader-content': {
overflow: 'hidden'
}
}}
>
<Box>
<Avatar src={avatarUrl} alt={`${name}'s avatar`} />
</Box>
<StyledCardColComment>
<AuthorTextComment
color={
theme.palette.mode === 'light'
? theme.palette.text.secondary
: '#d6e8ff'
}
>
{name}
</AuthorTextComment>
</StyledCardColComment>
{name && (
<Tipping
name={name}
onSubmit={() => {
// setNameTip('')
}}
onClose={() => {
// setNameTip('')
}}
onlyIcon={true}
/>
)}
</StyledCardHeaderComment>
<StyledCardContentComment>
<Typography
variant="body2"
color={theme.palette.text.primary}
sx={{
fontSize: '16px'
}}
>
{message}
</Typography>
</StyledCardContentComment>
<Box
sx={{
paddingLeft: '15px',
display: 'flex',
flexDirection: 'column'
}}
>
{replies?.map((reply: any) => {
return (
<Box
key={reply?.identifier}
sx={{
display: 'flex',
border: '1px solid grey',
borderRadius: '10px',
marginTop: '8px'
}}
>
<CommentCard
name={reply?.name}
message={reply?.message}
setCurrentEdit={setCurrentEdit}
>
{user?.name === reply?.name && (
<Button
size="small"
variant="contained"
onClick={() => setCurrentEdit(reply)}
sx={{
width: '30px',
alignSelf: 'flex-end',
background: theme.palette.primary.light
}}
>
edit
</Button>
)}
</CommentCard>
{/* <Typography variant="body2"> {reply?.message}</Typography> */}
</Box>
)
})}
</Box>
{children}
</CardContentContainerComment>
)
}

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

@ -0,0 +1,172 @@
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'
const uid = new ShortUniqueId()
interface CommentEditorProps {
postId: 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,
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) => {
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'
})
)
return resourceResponse
} catch (error: any) {
let notificationObj = 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 = `qcomment_v1_qblog_${postId.slice(-12)}_${id}`
if (isReply && commentId) {
identifier = `qcomment_v1_qblog_${postId.slice(
-12
)}_reply_${commentId.slice(-6)}_${id}`
}
if (isEdit && commentId) {
identifier = commentId
}
await publishComment(identifier)
onSubmit({
created: Date.now(),
identifier,
message: value,
service: 'BLOG_COMMENT',
name: user?.name
})
setValue('')
} catch (error) {}
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
marginTop: '15px',
width: '90%'
}}
>
<TextField
id="standard-multiline-flexible"
label="Your comment"
multiline
maxRows={4}
variant="filled"
value={value}
inputProps={{
maxLength: 200,
style: {
fontSize: '16px'
}
}}
InputLabelProps={{ style: { fontSize: '18px' } }}
onChange={(e) => setValue(e.target.value)}
/>
<Button variant="contained" onClick={handleSubmit}>
{isReply ? 'Submit reply' : isEdit ? 'Edit' : 'Submit comment'}
</Button>
</Box>
)
}

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

@ -0,0 +1,307 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CommentEditor } from './CommentEditor'
import { Comment } from './Comment'
import { Box, Button, Drawer, Typography, useTheme } from '@mui/material'
import { styled } from '@mui/system'
import CloseIcon from '@mui/icons-material/Close'
import { useSelector } from 'react-redux'
import { RootState } from '../../../state/store'
import CommentIcon from '@mui/icons-material/Comment'
interface CommentSectionProps {
postId: 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 }: CommentSectionProps) => {
const [listComments, setListComments] = useState<any[]>([])
const [isOpen, setIsOpen] = useState<boolean>(false)
const { user } = useSelector((state: RootState) => state.auth)
const [newMessages, setNewMessages] = useState(0)
const theme = useTheme()
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
}
])
}
const getComments = useCallback(
async (isNewMessages?: boolean, numberOfComments?: number) => {
let offset: number = 0
if (isNewMessages && numberOfComments) {
offset = numberOfComments
}
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=qcomment_v1_qblog_${postId.slice(
-12
)}&limit=20&includemetadata=true&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
})
}
}
}
if (isNewMessages) {
setListComments((prev) => [...prev, ...comments])
setNewMessages(0)
} else {
setListComments(comments)
}
try {
} catch (error) {}
},
[]
)
useEffect(() => {
getComments()
}, [getComments])
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])
const interval = useRef<any>(null)
const checkNewComments = useCallback(async () => {
try {
const offset = listComments.length
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=qcomment_v1_qblog_${postId.slice(
-12
)}&limit=20&includemetadata=true&offset=${offset}&reverse=false&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
setNewMessages(responseData.length)
} catch (error) {}
}, [listComments, postId])
const checkNewMessagesFunc = useCallback(() => {
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling) return
isCalling = true
const res = await checkNewComments()
isCalling = false
}, 15000)
}, [checkNewComments])
useEffect(() => {
checkNewMessagesFunc()
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNewMessagesFunc])
return (
<>
<Box
sx={{
position: 'relative'
}}
>
<CommentIcon
sx={{
cursor: 'pointer'
}}
onClick={() => setIsOpen((prev) => !prev)}
>
Comments
</CommentIcon>
{listComments?.length > 0 && (
<Box
sx={{
fontSize: '12px',
background: theme.palette.mode === 'dark' ? 'white' : 'black',
color: theme.palette.mode === 'dark' ? 'black' : 'white',
borderRadius: '50%',
position: 'absolute',
top: '-15px',
right: '-15px',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{listComments.length < 10 ? listComments.length : '9+'}
</Box>
)}
</Box>
<Drawer
variant="persistent"
hideBackdrop={true}
anchor="right"
open={isOpen}
onClose={() => {}}
ModalProps={{
keepMounted: true // Better performance on mobile
}}
sx={{
'& .MuiPaper-root': {
width: '400px'
}
}}
>
<Panel>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flex: '0 0',
padding: '10px',
width: '100%'
}}
>
<Box
sx={{
display: 'flex'
}}
>
{newMessages > 0 && (
<Button
onClick={() => getComments(true, listComments.length)}
variant="contained"
size="small"
>
Load {newMessages} new{' '}
{newMessages > 1 ? 'messages' : 'message'}
</Button>
)}
</Box>
<CloseIcon
sx={{
cursor: 'pointer'
}}
onClick={() => setIsOpen(false)}
/>
</Box>
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
flex: '1',
overflow: 'auto'
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
margin: '25px 0px 50px 0px',
maxWidth: '400px',
width: '100%',
gap: '10px',
padding: '0px 5px'
}}
>
{structuredCommentList.map((comment: any) => {
return (
<Comment
key={comment?.identifier}
comment={comment}
onSubmit={onSubmit}
postId={postId}
/>
)
})}
</Box>
</Box>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
flex: '0 0 100px'
}}
>
<CommentEditor onSubmit={onSubmit} postId={postId} />
</Box>
</Panel>
</Drawer>
</>
)
}

56
src/components/common/ConfirmationModal.tsx

@ -0,0 +1,56 @@
import React from 'react'
import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button
} from '@mui/material'
export interface ModalProps {
open: boolean
title: string
message: string
handleConfirm: () => void
handleCancel: () => void
}
const ConfirmationModal: React.FC<ModalProps> = ({
open,
title,
message,
handleConfirm,
handleCancel
}) => {
return (
<Dialog
open={open}
onClose={handleCancel}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{title}</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={handleCancel} color="primary">
Cancel
</Button>
<Button
variant="contained"
onClick={handleConfirm}
color="primary"
autoFocus
>
Proceed
</Button>
</DialogActions>
</Dialog>
)
}
export default ConfirmationModal

16
src/components/common/CustomIcon.tsx

@ -0,0 +1,16 @@
import React from 'react'
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'
import { styled } from '@mui/system'
const CustomSvgIcon: React.FC<any> = styled(SvgIcon)(({ theme }) => ({
cursor: 'pointer',
color: '#5f6368',
transition: 'all 0.2s',
'&:hover': {
transform: 'scale(1.1)'
}
})) as unknown as React.FC<any>
export const CustomIcon: React.FC<any> = (props) => {
return <CustomSvgIcon {...props} />
}

289
src/components/common/DownloadTaskManager.tsx

@ -0,0 +1,289 @@
import React, { useState, useEffect } from 'react'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
LinearProgress,
List,
ListItem,
ListItemIcon,
Typography,
useTheme
} from '@mui/material'
import { Movie, ArrowDropDown } from '@mui/icons-material'
import { SxProps } from '@mui/system'
import { Theme } from '@mui/material/styles'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { removePrefix } from '../../utils/blogIdformats'
import { useLocation, useNavigate } from 'react-router-dom'
import AudiotrackIcon from '@mui/icons-material/Audiotrack'
import {
setCurrAudio,
setShowingAudioPlayer
} from '../../state/features/globalSlice'
import { MAIL_ATTACHMENT_SERVICE_TYPE } from '../../constants/mail'
type DownloadItem = {
id: string
name: string
progress: number
}
export const DownloadTaskManager: React.FC = () => {
const { downloads } = useSelector((state: RootState) => state.global)
const dispatch = useDispatch()
const location = useLocation()
const isMailRoute = location.pathname === '/mail'
const theme = useTheme()
const [visible, setVisible] = useState(false)
const [hidden, setHidden] = useState(true)
const navigate = useNavigate()
const containerStyles: SxProps<Theme> = {
position: 'fixed',
top: '50px',
right: 0,
zIndex: 1000,
maxHeight: '80%',
overflowY: 'auto',
backgroundColor: 'background.paper',
boxShadow: 2,
display: 'block'
}
useEffect(() => {
// Simulate downloads for demo purposes
if (visible) {
setTimeout(() => {
setHidden(true)
setVisible(false)
}, 3000)
}
}, [visible])
const toggleVisibility = () => {
setVisible(true)
setHidden(false)
}
useEffect(() => {
if (Object.keys(downloads).length === 0) return
setVisible(true)
setHidden(false)
}, [downloads])
if (isMailRoute) return null
if (
!downloads ||
Object.keys(downloads).filter(
(item) => downloads[item].service !== MAIL_ATTACHMENT_SERVICE_TYPE
).length === 0
)
return null
return (
<Box sx={{ position: 'fixed', top: '50px', right: '5px', zIndex: 1000 }}>
<Accordion
sx={{
width: '200px',
backgroundColor: theme.palette.primary.main
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1a-content"
id="panel1a-header"
sx={{
minHeight: 'unset',
height: '36px',
backgroundColor: theme.palette.primary.light,
'&.MuiAccordionSummary-content': {
padding: 0,
margin: 0
},
'&.Mui-expanded': {
minHeight: 'unset',
height: '36px'
}
}}
>
<Typography
sx={{
fontFamily: 'Arial',
color: theme.palette.text.primary,
fontSize: '14px'
}}
>
Downloads
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{
padding: '5px'
}}
>
<List
sx={{
maxHeight: '50vh',
overflow: 'auto'
}}
>
{Object.keys(downloads)
.filter(
(item) =>
downloads[item].service !== MAIL_ATTACHMENT_SERVICE_TYPE
)
.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={() => {
if (service === 'AUDIO' && downloadObj?.identifier) {
dispatch(setCurrAudio(downloadObj?.identifier))
dispatch(setShowingAudioPlayer(true))
return
}
const str = downloadObj?.blogPost?.postId
if (!str) return
const arr = str.split('-post-')
const str1 = arr[0]
const str2 = arr[1]
const blogId = removePrefix(str1)
navigate(
`/${downloadObj?.blogPost.user}/${blogId}/${str2}`
)
}}
>
<Box
sx={{
width: '100%',
display: 'flex',
alignItems: 'center'
}}
>
<ListItemIcon>
{service === 'AUDIO' && (
<AudiotrackIcon
sx={{ color: theme.palette.text.primary }}
/>
)}
{service === 'VIDEO' && (
<Movie 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
}}
>
{downloadObj?.identifier}
</Typography>
</ListItem>
)
})}
</List>
</AccordionDetails>
</Accordion>
{/* <IconButton onClick={() => {}} aria-label="toggle download manager">
<ArrowDropDown />
</IconButton> */}
{/* <Box sx={containerStyles}>
<List
sx={{
width: '200px'
}}
>
{Object.keys(downloads).map((download: any) => {
const downloadObj = downloads[download]
const progress = downloads[download]?.status?.percentLoaded || 0
return (
<ListItem
key={downloadObj?.identifier}
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'center'
}}
>
<Box
sx={{
width: '100%',
display: 'flex',
alignItems: 'center'
}}
>
<ListItemIcon>
<Movie />
</ListItemIcon>
<Box sx={{ width: '100px', marginLeft: 1 }}>
<LinearProgress variant="determinate" value={progress} />
</Box>
<Typography variant="caption">{`${progress}%`}</Typography>
</Box>
<ListItemText
primary={downloadObj?.identifier}
sx={{
fontSize: '14px',
width: '100%',
textAlign: 'end'
}}
/>
</ListItem>
)
})}
</List>
</Box> */}
</Box>
)
}

55
src/components/common/DraggableResizableGrid.tsx

@ -0,0 +1,55 @@
// DraggableResizableGrid.tsx
import React from 'react'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import GridLayout, { Layout } from 'react-grid-layout'
import './DraggableResizableGrid.css' // Add your custom CSS for the grid layout
interface GridItem {
id: string
content: React.ReactNode
}
interface DraggableResizableGridProps {
items: GridItem[]
cols?: number
rowHeight?: number
onLayoutChange?: (layout: Layout[]) => void
}
const DraggableResizableGrid: React.FC<DraggableResizableGridProps> = ({
items,
cols = 12,
rowHeight = 30,
onLayoutChange
}) => {
const layout = items.map((item, index) => ({
i: item.id,
x: index % cols,
y: Math.floor(index / cols),
w: 4,
h: 4
}))
return (
<DndProvider backend={HTML5Backend}>
<GridLayout
className="layout"
layout={layout}
cols={cols}
rowHeight={rowHeight}
width={1200}
onLayoutChange={onLayoutChange}
>
{items.map((item) => (
<div key={item.id} className="grid-item">
{item.content}
</div>
))}
</GridLayout>
</DndProvider>
)
}
export default DraggableResizableGrid

36
src/components/common/ErrorBoundary.tsx

@ -0,0 +1,36 @@
import React, { ReactNode } from 'react'
interface ErrorBoundaryProps {
children: ReactNode
fallback: ReactNode
}
interface ErrorBoundaryState {
hasError: boolean
}
class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = {
hasError: false
}
static getDerivedStateFromError(_: Error): ErrorBoundaryState {
return { hasError: true }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// You can log the error and errorInfo here, for example, to an error reporting service.
console.error('Error caught in ErrorBoundary:', error, errorInfo)
}
render(): React.ReactNode {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
export default ErrorBoundary

232
src/components/common/FilePanel.tsx

@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react'
import { styled, Box } from '@mui/system'
import {
Drawer,
List,
ListItem,
ListItemText,
Typography,
ButtonBase,
Button,
Tooltip
} from '@mui/material'
import VideoCallIcon from '@mui/icons-material/VideoCall'
import VideoModal from './VideoPublishModal'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import AttachFileIcon from '@mui/icons-material/AttachFile'
import { AudioModal } from './AudioPublishModal'
import AudioFileIcon from '@mui/icons-material/AudioFile'
import { GenericModal } from './GenericPublishModal'
interface VideoPanelProps {
onSelect: (video: Video) => void
height?: string
width?: string
}
interface VideoApiResponse {
videos: Video[]
}
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;
}
`
const PublishButton = styled(Button)`
/* position: absolute;
bottom: 20px;
left: 0;
right: 0;
margin: auto; */
max-width: 80%;
`
export const FilePanel: React.FC<VideoPanelProps> = ({
onSelect,
height,
width
}) => {
const [isOpen, setIsOpen] = useState(false)
const [videos, setVideos] = useState<Video[]>([])
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false)
const { user } = useSelector((state: RootState) => state.auth)
const fetchVideos = React.useCallback(async (): Promise<Video[]> => {
if (!user?.name) return []
let res
try {
res = await qortalRequest({
action: 'LIST_QDN_RESOURCES',
service: 'FILE',
name: user.name,
includeMetadata: true,
limit: 100,
offset: 0,
reverse: true
})
} catch (error) {}
// Replace this URL with the actual API endpoint
return res
}, [user])
useEffect(() => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
}, [])
const handleToggle = () => {
setIsOpen(!isOpen)
}
const handleClick = (video: Video) => {
onSelect(video)
}
return (
<Box
sx={{
display: 'flex'
}}
>
<Tooltip title="Add any type of file" arrow>
<AttachFileIcon
onClick={handleToggle}
sx={{
height: height || '30px',
width: width || 'auto',
cursor: 'pointer'
}}
></AttachFileIcon>
</Tooltip>
<Drawer
anchor="right"
open={isOpen}
onClose={handleToggle}
ModalProps={{
keepMounted: true // Better performance on mobile
}}
sx={{
'& .MuiPaper-root': {
width: '400px'
}
}}
>
<Panel>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0'
}}
>
<Typography
variant="h5"
component="div"
sx={{ flexGrow: 1, mt: 2, mb: 1 }}
>
Select File
</Typography>
<Typography
variant="subtitle2"
component="div"
sx={{ flexGrow: 1, mb: 2 }}
>
List of Files in QDN under your name (FILE service)
</Typography>
</Box>
<List
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
flex: '1',
overflow: 'auto'
}}
>
{videos.map((video) => (
<ListItem key={video.identifier}>
<ButtonBase
onClick={() => handleClick(video)}
sx={{ width: '100%' }}
>
<ListItemText
primary={video?.metadata?.title || ''}
secondary={video?.metadata?.description || ''}
/>
</ButtonBase>
</ListItem>
))}
</List>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
flex: '0 0 50px'
}}
>
<PublishButton
variant="contained"
onClick={() => setIsOpenVideoModal(true)}
>
Publish new file
</PublishButton>
</Box>
</Panel>
</Drawer>
<GenericModal
service="FILE"
identifierPrefix="qfile_qblog"
onClose={() => {
setIsOpenVideoModal(false)
}}
open={isOpenVideoModal}
onPublish={(value) => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
setIsOpenVideoModal(false)
}}
/>
</Box>
)
}
// Add this to your 'types.ts' file
export interface Video {
name: string
service: string
identifier: string
metadata: {
title: string
description: string
tags: string[]
category: string
categoryName: string
}
size: number
created: number
updated: number
}

308
src/components/common/GenericPublishModal.tsx

@ -0,0 +1,308 @@
import React, { useState } from 'react'
import {
Box,
Button,
Modal,
TextField,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
SelectChangeEvent,
OutlinedInput,
Chip,
IconButton
} from '@mui/material'
import { styled } from '@mui/system'
import { useDropzone } from 'react-dropzone'
import { toBase64 } from '../../utils/toBase64'
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
import { usePublishGeneric } from './PublishGeneric'
import { useDispatch } from 'react-redux'
import { setNotification } from '../../state/features/notificationsSlice'
const StyledModal = styled(Modal)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}))
const ChipContainer = styled(Box)({
display: 'flex',
flexWrap: 'wrap',
'& > *': {
margin: '4px'
}
})
const ModalContent = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(4),
borderRadius: theme.spacing(1),
width: '40%',
'&:focus': {
outline: 'none'
}
}))
interface GenericModalProps {
open: boolean
onClose: () => void
onPublish: (value: any) => void
acceptedFileType?: string
acceptedFileTypes?: string[]
service: string
identifierPrefix: string
}
interface SelectOption {
id: string
name: string
}
const maxSize = 500 * 1024 * 1024
export const GenericModal: React.FC<GenericModalProps> = ({
open,
onClose,
onPublish,
acceptedFileType,
acceptedFileTypes,
service,
identifierPrefix
}) => {
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
null
)
const [inputValue, setInputValue] = useState<string>('')
const [chips, setChips] = useState<string[]>([])
const [options, setOptions] = useState<SelectOption[]>([])
const [tags, setTags] = useState<string[]>([])
const { publishGeneric } = usePublishGeneric()
const dispatch = useDispatch()
let acceptedFile = {}
if (acceptedFileType) {
acceptedFile = {
[acceptedFileType]: []
}
}
const { getRootProps, getInputProps } = useDropzone({
...acceptedFile,
maxFiles: 1,
maxSize,
onDrop: (acceptedFiles) => {
setFile(acceptedFiles[0])
},
onDropRejected: (rejectedFiles) => {
dispatch(
setNotification({
msg: 'Your file is over the 500mb limit.',
alertType: 'error'
})
)
}
})
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTitle(event.target.value)
}
const handleDescriptionChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setDescription(event.target.value)
}
const handleOptionChange = (event: SelectChangeEvent<string>) => {
const optionId = event.target.value
const selectedOption = options.find((option) => option.id === optionId)
setSelectedOption(selectedOption || null)
}
const handleChipDelete = (index: number) => {
const newChips = [...chips]
newChips.splice(index, 1)
setChips(newChips)
}
const handleSubmit = async () => {
const missingFields = []
if (!title) missingFields.push('title')
if (!file) missingFields.push('file')
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ')
const errMsg = `Missing: ${missingFieldsString}`
return
}
if (!file) return
const formattedTags: { [key: string]: string } = {}
chips.forEach((tag, i) => {
formattedTags[`tag${i + 1}`] = tag
})
try {
const base64 = await toBase64(file)
if (typeof base64 !== 'string') return
const base64String = base64.split(',')[1]
const fileExtension = file?.name?.split('.')?.pop()
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20)
const filename = `${fileTitle}.${fileExtension}`
const res = await publishGeneric({
service,
identifierPrefix,
title,
description,
base64: base64String,
filename: filename,
category: selectedOption?.id || '',
...formattedTags
})
onPublish(res)
setFile(null)
setTitle('')
setDescription('')
onClose()
} catch (error) {}
}
const handleInputChange = (event: any) => {
setInputValue(event.target.value)
}
const handleInputKeyDown = (event: any) => {
if (event.key === 'Enter' && inputValue !== '') {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
} else {
event.preventDefault()
}
}
}
const addChip = () => {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
}
}
const getListCategories = React.useCallback(async () => {
try {
const url = `/arbitrary/categories`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
setOptions(responseData)
} catch (error) {}
}, [])
React.useEffect(() => {
getListCategories()
}, [getListCategories])
return (
<StyledModal open={open} onClose={onClose}>
<ModalContent>
<Typography variant="h6" component="h2" gutterBottom>
Upload {service}
</Typography>
<Box
{...getRootProps()}
sx={{
border: '1px dashed gray',
padding: 2,
textAlign: 'center',
marginBottom: 2
}}
>
<input {...getInputProps()} />
<Typography>
{file
? file.name
: 'Drag and drop a file here or click to select a file'}
</Typography>
</Box>
<TextField
label="Title"
variant="outlined"
fullWidth
value={title}
onChange={handleTitleChange}
inputProps={{ maxLength: 40 }}
sx={{ marginBottom: 2 }}
/>
<TextField
label="Description"
variant="outlined"
fullWidth
multiline
rows={4}
value={description}
onChange={handleDescriptionChange}
inputProps={{ maxLength: 180 }}
sx={{ marginBottom: 2 }}
/>
{options.length > 0 && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedOption?.id || ''}
onChange={handleOptionChange}
>
{options.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<TextField
label="Add a tag"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
disabled={chips.length === 3}
/>
<IconButton onClick={addChip} disabled={chips.length === 3}>
<AddIcon />
</IconButton>
</Box>
<ChipContainer>
{chips.map((chip, index) => (
<Chip
key={index}
label={chip}
onDelete={() => handleChipDelete(index)}
deleteIcon={<CloseIcon />}
/>
))}
</ChipContainer>
</FormControl>
<Button variant="contained" color="primary" onClick={handleSubmit}>
Submit
</Button>
</ModalContent>
</StyledModal>
)
}

74
src/components/common/ImageUploader.tsx

@ -0,0 +1,74 @@
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,
success(result) {
const file = new File([result], 'name', {
type: image.type
})
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;

47
src/components/common/LazyLoad.tsx

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

37
src/components/common/LoaderBar.tsx

@ -0,0 +1,37 @@
import * as React from 'react'
import LinearProgress, {
LinearProgressProps
} from '@mui/material/LinearProgress'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import { useTheme } from '@mui/material'
interface LoaderBarProps {
message: string
}
export const LoaderBar = ({ message }: LoaderBarProps) => {
const theme = useTheme()
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
position: 'fixed',
bottom: '10px',
right: '10px',
zIndex: 1500,
width: '250px',
backgroundColor: theme.palette.background.paper,
borderRadius: '5px',
padding: '5px'
}}
>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress color="secondary" />
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography variant="body2">{message}</Typography>
</Box>
</Box>
)
}

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: 200000
}}
>
<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

281
src/components/common/PostPublishModal.tsx

@ -0,0 +1,281 @@
import React, { useState } from 'react'
import {
Box,
Button,
Modal,
TextField,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
SelectChangeEvent,
OutlinedInput,
Chip,
IconButton
} from '@mui/material'
import { styled } from '@mui/system'
import { useDropzone } from 'react-dropzone'
import { usePublishVideo } from './PublishVideo'
import { toBase64 } from '../../utils/toBase64'
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
const StyledModal = styled(Modal)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}))
const ChipContainer = styled(Box)({
display: 'flex',
flexWrap: 'wrap',
'& > *': {
margin: '4px'
}
})
const ModalContent = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(4),
borderRadius: theme.spacing(1),
width: '40%',
'&:focus': {
outline: 'none'
}
}))
interface PostModalProps {
open: boolean
onClose: () => void
onPublish: (value: any) => Promise<void>
post: any
mode?: string
metadata?: any
}
interface SelectOption {
id: string
name: string
}
const PostPublishModal: React.FC<PostModalProps> = ({
open,
onClose,
onPublish,
post,
mode,
metadata
}) => {
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
null
)
const [inputValue, setInputValue] = useState<string>('')
const [chips, setChips] = useState<string[]>([])
const [options, setOptions] = useState<SelectOption[]>([])
const [tags, setTags] = useState<string[]>([])
const { publishVideo } = usePublishVideo()
const { getRootProps, getInputProps } = useDropzone({
accept: {
'video/*': []
},
maxFiles: 1,
onDrop: (acceptedFiles) => {
setFile(acceptedFiles[0])
}
})
React.useEffect(() => {
if (post.title) {
setTitle(post.title)
}
// if (post.description) {
// setDescription(post.description)
// }
}, [post])
React.useEffect(() => {
if (mode === 'edit' && metadata) {
if (metadata.description) {
setDescription(metadata.description)
}
const findCategory = options.find(
(option) => option.id === metadata?.category
)
if (findCategory) {
setSelectedOption(findCategory)
}
if (!metadata?.tags || !Array.isArray(metadata?.tags)) return
setChips(metadata.tags.slice(0, -2))
}
}, [mode, metadata, options])
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTitle(event.target.value)
}
const handleDescriptionChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setDescription(event.target.value)
}
const handleOptionChange = (event: SelectChangeEvent<string>) => {
const optionId = event.target.value
const selectedOption = options.find((option) => option.id === optionId)
setSelectedOption(selectedOption || null)
}
const handleChipDelete = (index: number) => {
const newChips = [...chips]
newChips.splice(index, 1)
setChips(newChips)
}
const handleSubmit = async () => {
const formattedTags: { [key: string]: string } = {}
chips.forEach((tag, i) => {
formattedTags[`tag${i + 1}`] = tag
})
try {
await onPublish({
title,
description,
tags: chips,
category: selectedOption?.id || ''
})
setFile(null)
setTitle('')
setDescription('')
onClose()
} catch (error) {}
}
const handleInputChange = (event: any) => {
setInputValue(event.target.value)
}
const handleInputKeyDown = (event: any) => {
if (event.key === 'Enter' && inputValue !== '') {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
} else {
event.preventDefault()
}
}
}
const addChip = () => {
if (chips.length < 3) {
setChips([...chips, inputValue])
setInputValue('')
}
}
const getListCategories = React.useCallback(async () => {
try {
const url = `/arbitrary/categories`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
setOptions(responseData)
} catch (error) {}
}, [])
React.useEffect(() => {
getListCategories()
}, [getListCategories])
return (
<StyledModal open={open} onClose={onClose}>
<ModalContent>
<Typography variant="h6" component="h2" gutterBottom>
Upload Blog Post
</Typography>
<TextField
label="Post Title"
variant="outlined"
fullWidth
value={title}
onChange={handleTitleChange}
inputProps={{ maxLength: 40 }}
sx={{ marginBottom: 2 }}
disabled
/>
<TextField
label="Post Description"
variant="outlined"
fullWidth
multiline
rows={4}
value={description}
onChange={handleDescriptionChange}
inputProps={{ maxLength: 180 }}
sx={{ marginBottom: 2 }}
/>
{options.length > 0 && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedOption?.id || ''}
onChange={handleOptionChange}
>
{options.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<TextField
label="Add a tag"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
disabled={chips.length === 3}
/>
<IconButton onClick={addChip} disabled={chips.length === 3}>
<AddIcon />
</IconButton>
</Box>
<ChipContainer>
{chips.map((chip, index) => (
<Chip
key={index}
label={chip}
onDelete={() => handleChipDelete(index)}
deleteIcon={<CloseIcon />}
/>
))}
</ChipContainer>
</FormControl>
<Button variant="contained" color="primary" onClick={handleSubmit}>
Submit
</Button>
</ModalContent>
</StyledModal>
)
}
export default PostPublishModal

106
src/components/common/PublishAudio.tsx

@ -0,0 +1,106 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { setNotification } from '../../state/features/notificationsSlice'
import { RootState } from '../../state/store'
import ShortUniqueId from 'short-unique-id'
const uid = new ShortUniqueId()
interface IPublishVideo {
title: string
description: string
base64: string
category: string
}
export const usePublishAudio = () => {
const { user } = useSelector((state: RootState) => state.auth)
const dispatch = useDispatch()
const publishAudio = async ({
title,
description,
base64,
category,
...rest
}: IPublishVideo) => {
let address
let name
let errorMsg = ''
address = user?.address
name = user?.name || ''
const missingFields = []
if (!address) {
errorMsg = "Cannot post: your address isn't available"
}
if (!name) {
errorMsg = 'Cannot post without a name'
}
if (!title) missingFields.push('title')
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ')
const errMsg = `Missing: ${missingFieldsString}`
errorMsg = errMsg
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: 'error'
})
)
throw new Error(errorMsg)
}
try {
const id = uid()
const identifier = `qaudio_qblog_${id}`
const resourceResponse = await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE',
name: name,
service: 'AUDIO',
data64: base64,
title: title,
description: description,
category: category,
...rest,
identifier: identifier
})
dispatch(
setNotification({
msg: 'Audio successfully published',
alertType: 'success'
})
)
return resourceResponse
} catch (error: any) {
let notificationObj = null
if (typeof error === 'string') {
notificationObj = {
msg: error || 'Failed to publish audio',
alertType: 'error'
}
} else if (typeof error?.error === 'string') {
notificationObj = {
msg: error?.error || 'Failed to publish audio',
alertType: 'error'
}
} else {
notificationObj = {
msg: error?.message || error?.message || 'Failed to publish audio',
alertType: 'error'
}
}
if (!notificationObj) return
dispatch(setNotification(notificationObj))
}
}
return {
publishAudio
}
}

113
src/components/common/PublishGeneric.tsx

@ -0,0 +1,113 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { setNotification } from '../../state/features/notificationsSlice'
import { RootState } from '../../state/store'
import ShortUniqueId from 'short-unique-id'
const uid = new ShortUniqueId()
interface IPublishGeneric {
title: string
description: string
base64: string
category: string
service: string
identifierPrefix: string
filename: string
}
export const usePublishGeneric = () => {
const { user } = useSelector((state: RootState) => state.auth)
const dispatch = useDispatch()
const publishGeneric = async ({
service,
identifierPrefix,
filename,
title,
description,
base64,
category,
...rest
}: IPublishGeneric) => {
let address
let name
let errorMsg = ''
address = user?.address
name = user?.name || ''
const missingFields = []
if (!address) {
errorMsg = "Cannot post: your address isn't available"
}
if (!name) {
errorMsg = 'Cannot post without a name'
}
if (!title) missingFields.push('title')
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ')
const errMsg = `Missing: ${missingFieldsString}`
errorMsg = errMsg
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: 'error'
})
)
throw new Error(errorMsg)
}
try {
const id = uid()
const identifier = `${identifierPrefix}_${id}`
const resourceResponse = await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE',
name: name,
service: service,
data64: base64,
title: title,
description: description,
category: category,
filename,
...rest,
identifier: identifier
})
dispatch(
setNotification({
msg: `${service} successfully published`,
alertType: 'success'
})
)
return resourceResponse
} catch (error: any) {
let notificationObj = null
if (typeof error === 'string') {
notificationObj = {
msg: error || `Failed to publish ${service}`,
alertType: 'error'
}
} else if (typeof error?.error === 'string') {
notificationObj = {
msg: error?.error || `Failed to publish ${service}`,
alertType: 'error'
}
} else {
notificationObj = {
msg:
error?.message || error?.message || `Failed to publish ${service}`,
alertType: 'error'
}
}
if (!notificationObj) return
dispatch(setNotification(notificationObj))
}
}
return {
publishGeneric
}
}

106
src/components/common/PublishVideo.tsx

@ -0,0 +1,106 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { setNotification } from '../../state/features/notificationsSlice'
import { RootState } from '../../state/store'
import ShortUniqueId from 'short-unique-id'
const uid = new ShortUniqueId()
interface IPublishVideo {
title: string
description: string
base64: string
category: string
}
export const usePublishVideo = () => {
const { user } = useSelector((state: RootState) => state.auth)
const dispatch = useDispatch()
const publishVideo = async ({
title,
description,
base64,
category,
...rest
}: IPublishVideo) => {
let address
let name
let errorMsg = ''
address = user?.address
name = user?.name || ''
const missingFields = []
if (!address) {
errorMsg = "Cannot post: your address isn't available"
}
if (!name) {
errorMsg = 'Cannot post without a name'
}
if (!title) missingFields.push('title')
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ')
const errMsg = `Missing: ${missingFieldsString}`
errorMsg = errMsg
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: 'error'
})
)
throw new Error(errorMsg)
}
try {
const id = uid()
const identifier = `qvideo_qblog_${id}`
const resourceResponse = await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE',
name: name,
service: 'VIDEO',
data64: base64,
title: title,
description: description,
category: category,
...rest,
identifier: identifier
})
dispatch(
setNotification({
msg: 'Video successfully published',
alertType: 'success'
})
)
return resourceResponse
} catch (error: any) {
let notificationObj = null
if (typeof error === 'string') {
notificationObj = {
msg: error || 'Failed to publish video',
alertType: 'error'
}
} else if (typeof error?.error === 'string') {
notificationObj = {
msg: error?.error || 'Failed to publish video',
alertType: 'error'
}
} else {
notificationObj = {
msg: error?.message || 'Failed to publish video',
alertType: 'error'
}
}
if (!notificationObj) return
dispatch(setNotification(notificationObj))
}
}
return {
publishVideo
}
}

289
src/components/common/Tipping/Tipping.tsx

@ -0,0 +1,289 @@
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Input,
InputAdornment,
InputLabel,
Tooltip,
Typography,
useTheme
} from '@mui/material'
import React, { useCallback, useState } from 'react'
import { CardContentContainerComment } from '../../../pages/BlogList/PostPreview-styles'
import { StyledCardHeaderComment } from '../../../pages/BlogList/PostPreview-styles'
import { StyledCardColComment } from '../../../pages/BlogList/PostPreview-styles'
import { AuthorTextComment } from '../../../pages/BlogList/PostPreview-styles'
import { StyledCardContentComment } from '../../../pages/BlogList/PostPreview-styles'
import MenuItem from '@mui/material/MenuItem'
import Select, { SelectChangeEvent } from '@mui/material/Select'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../../state/store'
import Portal from '../Portal'
import MonetizationOnIcon from '@mui/icons-material/MonetizationOn'
interface TippingProps {
name: string
onSubmit: () => void
onClose: () => void
onlyIcon?: boolean
}
import QORT from '../../../assets/img/qort.png'
import ARRR from '../../../assets/img/arrr.png'
import LTC from '../../../assets/img/ltc.png'
import BTC from '../../../assets/img/btc.png'
import DOGE from '../../../assets/img/doge.png'
import DGB from '../../../assets/img/dgb.png'
import RVN from '../../../assets/img/rvn.png'
import { setNotification } from '../../../state/features/notificationsSlice'
const coins = [
{ value: 'QORT', label: 'QORT' },
{ value: 'ARRR', label: 'ARRR' },
{ value: 'LTC', label: 'LTC' },
{ value: 'BTC', label: 'BTC' },
{ value: 'DOGE', label: 'DOGE' },
{ value: 'DGB', label: 'DGB' },
{ value: 'RVN', label: 'RVN' }
]
export const Tipping = ({
onSubmit,
onClose,
name,
onlyIcon
}: TippingProps) => {
const { user } = useSelector((state: RootState) => state.auth)
const [isOpen, setIsOpen] = useState<boolean>(false)
const [selectedCoin, setSelectedCoint] = useState<any>(coins[0])
const [amount, setAmount] = useState<number>(0)
const dispatch = useDispatch()
const resetValues = () => {
setSelectedCoint(coins[0])
setAmount(0)
setIsOpen(false)
}
const sendCoin = async () => {
try {
if (!name) return
let res = await qortalRequest({
action: 'GET_NAME_DATA',
name: name
})
const address = res.owner
if (!address || !amount || !selectedCoin?.value) return
if (isNaN(amount)) return
await qortalRequest({
action: 'SEND_COIN',
coin: selectedCoin.value,
destinationAddress: address,
amount: amount
})
dispatch(
setNotification({
msg: 'Coin successfully sent',
alertType: 'success'
})
)
resetValues()
onSubmit()
} catch (error: any) {
let notificationObj = null
if (typeof error === 'string') {
notificationObj = {
msg: error || 'Failed to send coin',
alertType: 'error'
}
} else if (typeof error?.error === 'string') {
notificationObj = {
msg: error?.error || 'Failed to send coin',
alertType: 'error'
}
} else {
notificationObj = {
msg: error?.message || 'Failed to send coin',
alertType: 'error'
}
}
if (!notificationObj) return
dispatch(setNotification(notificationObj))
}
}
const handleOptionChange = (event: SelectChangeEvent<string>) => {
const optionId = event.target.value
const selectedOption = coins.find(
(option: any) => option.value === optionId
)
setSelectedCoint(selectedOption || null)
}
const getLogo = (coin: string) => {
switch (coin) {
case 'QORT':
return QORT
case 'ARRR':
return ARRR
case 'LTC':
return LTC
case 'BTC':
return BTC
case 'DOGE':
return DOGE
case 'DGB':
return DGB
case 'RVN':
return RVN
default:
''
// code block
}
}
return (
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
<Tooltip title={`Support ${name}`} arrow>
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer'
}}
onClick={() => setIsOpen((prev) => !prev)}
>
<MonetizationOnIcon
sx={{
cursor: 'pointer',
color: 'gold'
}}
></MonetizationOnIcon>
{!onlyIcon && (
<Typography
sx={{
fontSize: '14px'
}}
>
Support
</Typography>
)}
</Box>
</Tooltip>
{isOpen && (
<Portal>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
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'
}}
>
<Box>
<InputLabel htmlFor="standard-adornment-name">To</InputLabel>
<Input id="standard-adornment-name" value={name} disabled />
<InputLabel htmlFor="standard-adornment-coin">
Coin
</InputLabel>
<Select
id="standard-adornment-coin"
sx={{ width: '100%' }}
defaultValue=""
displayEmpty
value={selectedCoin?.value || ''}
onChange={handleOptionChange}
renderValue={(value) => {
return (
<Box
sx={{
display: 'flex',
gap: 1,
justifyContent: 'center',
alignItems: 'center'
}}
>
{value && (
<img
style={{
height: '25px',
width: '25px'
}}
src={getLogo(value)}
/>
)}
{value}
</Box>
)
}}
>
{coins.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.value}
</MenuItem>
))}
</Select>
<InputLabel htmlFor="standard-adornment-amount">
Amount
</InputLabel>
<Input
id="standard-adornment-amount"
type="number"
value={amount}
onChange={(e) => setAmount(+e.target.value)}
startAdornment={
<InputAdornment position="start">
<img
style={{
height: '15px',
width: '15px'
}}
src={getLogo(selectedCoin?.value || '')}
/>
</InputAdornment>
}
/>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button
variant="contained"
onClick={() => {
setIsOpen(false)
resetValues()
onClose()
}}
>
Close
</Button>
<Button variant="contained" onClick={sendCoin}>
Send Coin
</Button>
</DialogActions>
</Dialog>
</Portal>
)}
</Box>
)
}

55
src/components/common/UserNavbar/UserNavbar-styles.ts

@ -0,0 +1,55 @@
import { styled } from '@mui/system'
import {
AppBar,
Toolbar,
Typography,
Menu,
MenuItem
} from '@mui/material'
export const CustomAppBar = styled(AppBar)(({ theme }) => ({
backgroundColor: theme.palette.mode === "light" ? theme.palette.background.default : "#19191b",
color: theme.palette.text.primary
}))
export const CustomToolbar = styled(Toolbar)({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
})
export const CustomTitle = styled(Typography)(({ theme }) => ({
color: theme.palette.text.primary,
fontFamily: 'Raleway, Arial',
fontSize: '18px'
}))
export const StyledAppBar = styled(AppBar)(({ theme }) => ({
backgroundColor: theme.palette.primary.main
}))
export const StyledToolbar = styled(Toolbar)(({ theme }) => ({
justifyContent: 'space-between'
}))
export const StyledMenu = styled(Menu)(({ theme }) => ({
marginTop: theme.spacing(2),
overflow: 'hidden',
padding: 0,
}))
export const StyledMenuItem = styled(MenuItem)(({ theme }) => ({
width: '100%',
whiteSpace: 'nowrap',
maxWidth: '300px',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: "16px",
fontFamily: "Arial",
padding: "12px 10px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
filter: "brightness(1.1)"
}
}))

135
src/components/common/UserNavbar/UserNavbar.tsx

@ -0,0 +1,135 @@
import React from 'react'
import { styled } from '@mui/system'
import {
AppBar,
Toolbar,
Typography,
IconButton,
Menu,
MenuItem,
Box,
Button
} from '@mui/material'
import {
CustomAppBar,
CustomToolbar,
CustomTitle,
StyledAppBar,
StyledToolbar,
StyledMenu,
StyledMenuItem
} from './UserNavbar-styles'
import { useNavigate } from 'react-router-dom'
import { Menu as MenuIcon } from '@mui/icons-material'
import { removePrefix } from '../../../utils/blogIdformats'
import { QblogLogoContainer } from '../../layout/Navbar/Navbar-styles'
import QblogLogo from '../../../assets/img/qBlogLogo.png'
interface Props {
title: string
menuItems: any[]
name: string
blogId: string
}
export const UserNavbar: React.FC<Props> = ({
title,
menuItems,
name,
blogId
}) => {
const navigate = useNavigate()
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const goToPost = (item: any) => {
if (!name) return
const { postId } = item
const str = postId
const arr = str.split('-post-')
const str1 = arr[0]
const str2 = arr[1]
const blogId = removePrefix(str1)
navigate(`/${name}/${blogId}/${str2}`)
}
const handleAction = (action: () => void) => {
handleClose()
setTimeout(() => {
action()
}, 100)
}
return (
<CustomAppBar position="sticky">
<CustomToolbar variant="dense">
<Box
sx={{
display: 'flex',
alignItems: 'center'
}}
>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
onClick={handleClick}
>
<MenuIcon />
</IconButton>
<CustomTitle
variant="h6"
sx={{
cursor: 'pointer',
marginLeft: '10px'
}}
onClick={() => {
navigate(`/${name}/${blogId}`)
}}
>
{title}
</CustomTitle>
</Box>
<StyledMenu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
PaperProps={{ style: { width: '250px' } }}
>
{menuItems.map((item, index) => (
<StyledMenuItem
key={index}
onClick={() => handleAction(() => goToPost(item))}
>
{item.name}
</StyledMenuItem>
))}
</StyledMenu>
<Box
sx={{
display: 'flex',
alignItems: 'center'
}}
>
<QblogLogoContainer
src={QblogLogo}
alt="Qblog Logo"
onClick={() => {
navigate(`/`)
}}
/>
</Box>
</CustomToolbar>
</CustomAppBar>
)
}

51
src/components/common/VideoContent.tsx

@ -0,0 +1,51 @@
import React from 'react'
import { Box, Typography } from '@mui/material'
import { styled } from '@mui/system'
import { Description, Movie } from '@mui/icons-material'
interface VideoProps {
title: string
description: string
}
const StyledBox = styled(Box)`
margin: 20px 0px;
display: flex;
align-items: center;
`
const Title = styled(Typography)``
const DescriptionIcon = styled(Description)`
color: #666;
margin-right: 0.5rem;
`
const MovieIcon = styled(Movie)`
color: #666;
margin-right: 0.5rem;
`
export const VideoContent: React.FC<VideoProps> = ({ title, description }) => {
return (
<StyledBox>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Box display="flex" alignItems="center">
<MovieIcon />
<Title variant="h4">{title}</Title>
</Box>
<Box display="flex" alignItems="center">
<DescriptionIcon />
<Typography variant="body1">{description}</Typography>
</Box>
</Box>
</StyledBox>
)
}

226
src/components/common/VideoPanel.tsx

@ -0,0 +1,226 @@
import React, { useState, useEffect } from 'react'
import { styled, Box } from '@mui/system'
import {
Drawer,
List,
ListItem,
ListItemText,
Typography,
ButtonBase,
Button,
Tooltip
} from '@mui/material'
import VideoCallIcon from '@mui/icons-material/VideoCall'
import VideoModal from './VideoPublishModal'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
interface VideoPanelProps {
onSelect: (video: Video) => void
height?: string
width?: string
}
interface VideoApiResponse {
videos: Video[]
}
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;
}
`
const PublishButton = styled(Button)`
/* position: absolute;
bottom: 20px;
left: 0;
right: 0;
margin: auto; */
max-width: 80%;
`
export const VideoPanel: React.FC<VideoPanelProps> = ({
onSelect,
height,
width
}) => {
const [isOpen, setIsOpen] = useState(false)
const [videos, setVideos] = useState<Video[]>([])
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false)
const { user } = useSelector((state: RootState) => state.auth)
const fetchVideos = React.useCallback(async (): Promise<Video[]> => {
if (!user?.name) return []
// Replace this URL with the actual API endpoint
let res
try {
res = await qortalRequest({
action: 'LIST_QDN_RESOURCES',
service: 'VIDEO',
name: user.name,
includeMetadata: true,
limit: 100,
offset: 0,
reverse: true
})
} catch (error) {
}
return res
}, [user])
useEffect(() => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
}, [])
const handleToggle = () => {
setIsOpen(!isOpen)
}
const handleClick = (video: Video) => {
onSelect(video)
}
return (
<Box
sx={{
display: 'flex'
}}
>
<Tooltip title="Add a video" arrow>
<VideoCallIcon
onClick={handleToggle}
sx={{
height: height || '30px',
width: width || 'auto',
cursor: 'pointer'
}}
></VideoCallIcon>
</Tooltip>
<Drawer
anchor="right"
open={isOpen}
onClose={handleToggle}
ModalProps={{
keepMounted: true // Better performance on mobile
}}
sx={{
'& .MuiPaper-root': {
width: '400px'
}
}}
>
<Panel>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0'
}}
>
<Typography
variant="h5"
component="div"
sx={{ flexGrow: 1, mt: 2, mb: 1 }}
>
Select Video
</Typography>
<Typography
variant="subtitle2"
component="div"
sx={{ flexGrow: 1, mb: 2 }}
>
List of videos in QDN under your name
</Typography>
</Box>
<List
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
flex: '1',
overflow: 'auto'
}}
>
{videos.map((video) => (
<ListItem key={video.identifier}>
<ButtonBase
onClick={() => handleClick(video)}
sx={{ width: '100%' }}
>
<ListItemText
primary={video?.metadata?.title || ''}
secondary={video?.metadata?.description || ''}
/>
</ButtonBase>
</ListItem>
))}
</List>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
flex: '0 0 50px'
}}
>
<PublishButton
variant="contained"
onClick={() => setIsOpenVideoModal(true)}
>
Publish new video
</PublishButton>
</Box>
</Panel>
</Drawer>
<VideoModal
onClose={() => {
setIsOpenVideoModal(false)
}}
open={isOpenVideoModal}
onPublish={(value) => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
setIsOpenVideoModal(false)
}}
/>
</Box>
)
}
// Add this to your 'types.ts' file
export interface Video {
name: string
service: string
identifier: string
metadata: {
title: string
description: string
tags: string[]
category: string
categoryName: string
}
size: number
created: number
updated: number
}

492
src/components/common/VideoPlayer.tsx

@ -0,0 +1,492 @@
import React, { useContext, useMemo, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Box, IconButton, Slider } from '@mui/material'
import { CircularProgress, Typography } from '@mui/material'
import {
PlayArrow,
Pause,
VolumeUp,
Fullscreen,
PictureInPicture
} from '@mui/icons-material'
import { styled } from '@mui/system'
import { MyContext } from '../../wrappers/DownloadWrapper'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
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;
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
setCount?: () => void
customStyle?: any
user?: string
postId?: string
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
poster,
name,
identifier,
service,
autoplay = true,
from = null,
setCount,
customStyle = {},
user = '',
postId = ''
}) => {
const videoRef = useRef<HTMLVideoElement | null>(null)
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(1)
const [progress, setProgress] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [startPlay, setStartPlay] = useState(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 src = useMemo(() => {
return download?.url || ''
}, [download?.url])
const resourceStatus = useMemo(() => {
return download?.status || {}
}, [download])
const toggleRef = useRef<any>(null)
const { downloadVideo } = useContext(MyContext)
const togglePlay = async () => {
if (!videoRef.current) return
setStartPlay(true)
if (!src) {
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)
}
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 = () => {
if (!isFullscreen) {
enterFullscreen()
} else {
exitFullscreen()
}
}
const togglePictureInPicture = async () => {
if (!videoRef.current) return
if (document.pictureInPictureElement === videoRef.current) {
await document.exitPictureInPicture()
} else {
await videoRef.current.requestPictureInPicture()
}
}
React.useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, [])
const handleLoadedMetadata = () => {
setIsLoading(false)
}
const handleCanPlay = () => {
if (setCount) {
setCount()
}
setIsLoading(false)
}
const getSrc = React.useCallback(async () => {
if (!name || !identifier || !service || !postId || !user) return
try {
downloadVideo({
name,
service,
identifier,
blogPost: {
postId,
user
}
})
} catch (error) {}
}, [identifier, name, service])
React.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
)
}
}
}, [])
React.useEffect(() => {
const videoElement = videoRef.current
const minimizeVideo = async () => {
if (!videoElement) return
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 remainingSeconds: number | string = seconds % 60
if (minutes < 10) {
minutes = '0' + minutes
}
if (remainingSeconds < 10) {
remainingSeconds = '0' + remainingSeconds
}
return minutes + ':' + remainingSeconds
}
return (
<VideoContainer
style={{
padding: from === 'create' ? '8px' : 0
}}
>
{isLoading && (
<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'
}}
>
<CircularProgress color="secondary" />
{resourceStatus && (
<Typography
variant="subtitle2"
component="div"
sx={{
color: 'white',
fontSize: '18px'
}}
>
{resourceStatus?.status === 'REFETCHING' ? (
<>
<>
{(
(resourceStatus?.localChunkCount /
resourceStatus?.totalChunkCount) *
100
)?.toFixed(0)}
%
</>
<> Refetching in 2 minutes</>
</>
) : resourceStatus?.status === 'DOWNLOADED' ? (
<>Download Completed: building video...</>
) : resourceStatus?.status !== 'READY' ? (
<>
{(
(resourceStatus?.localChunkCount /
resourceStatus?.totalChunkCount) *
100
)?.toFixed(0)}
%
</>
) : (
<>Download Completed: 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
togglePlay()
}}
sx={{
cursor: 'pointer'
}}
>
<PlayArrow
sx={{
width: '50px',
height: '50px',
color: 'white'
}}
/>
</Box>
)}
<VideoElement
ref={videoRef}
src={!startPlay ? '' : src}
poster={poster}
onTimeUpdate={updateProgress}
autoPlay={autoplay}
onEnded={handleEnded}
// onLoadedMetadata={handleLoadedMetadata}
onCanPlay={handleCanPlay}
preload="metadata"
style={{
...customStyle
}}
/>
<ControlsContainer
style={{
bottom: from === 'create' ? '15px' : 0
}}
>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</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>
<VolumeUp />
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
/>
<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>
</ControlsContainer>
</VideoContainer>
)
}

279
src/components/common/VideoPublishModal.tsx

@ -0,0 +1,279 @@
import React, { useState } from 'react'
import {
Box,
Button,
Modal,
TextField,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
SelectChangeEvent,
OutlinedInput,
Chip,
IconButton
} from '@mui/material'
import { styled } from '@mui/system'
import { useDropzone } from 'react-dropzone'
import { usePublishVideo } from './PublishVideo'
import { toBase64 } from '../../utils/toBase64'
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
const StyledModal = styled(Modal)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}))
const ChipContainer = styled(Box)({
display: 'flex',
flexWrap: 'wrap',
'& > *': {
margin: '4px'
}
})
const ModalContent = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(4),
borderRadius: theme.spacing(1),
width: '40%',
'&:focus': {
outline: 'none'
}
}))
interface VideoModalProps {
open: boolean
onClose: () => void
onPublish: (value: any) => void
}
interface SelectOption {
id: string
name: string
}
const VideoModal: React.FC<VideoModalProps> = ({
open,
onClose,
onPublish
}) => {
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
null
)
const [inputValue, setInputValue] = useState<string>('')
const [chips, setChips] = useState<string[]>([])
const [options, setOptions] = useState<SelectOption[]>([])
const [tags, setTags] = useState<string[]>([])
const { publishVideo } = usePublishVideo()
const { getRootProps, getInputProps } = useDropzone({
accept: {
'video/*': []
},
maxFiles: 1,
onDrop: (acceptedFiles) => {
setFile(acceptedFiles[0])
}
})
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTitle(event.target.value)
}
const handleDescriptionChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setDescription(event.target.value)
}
const handleOptionChange = (event: SelectChangeEvent<string>) => {
const optionId = event.target.value
const selectedOption = options.find((option) => option.id === optionId)
setSelectedOption(selectedOption || null)
}
const handleChipDelete = (index: number) => {
const newChips = [...chips]
newChips.splice(index, 1)
setChips(newChips)
}
const handleSubmit = async () => {
const missingFields = []
if (!title) missingFields.push('title')
if (!file) missingFields.push('file')
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ')
const errMsg = `Missing: ${missingFieldsString}`
return
}
if (!file) return
const formattedTags: { [key: string]: string } = {}
chips.forEach((tag, i) => {
formattedTags[`tag${i + 1}`] = tag
})
try {
const base64 = await toBase64(file)
if (typeof base64 !== 'string') return
const base64String = base64.split(',')[1]
const res = await publishVideo({
title,
description,
base64: base64String,
category: selectedOption?.id || '',
...formattedTags
})
onPublish(res)
setFile(null)
setTitle('')
setDescription('')
onClose()
} catch (error) {}
}
const handleInputChange = (event: any) => {
setInputValue(event.target.value)
}
const handleInputKeyDown = (event: any) => {
if (event.key === 'Enter' && inputValue !== '') {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
} else {
event.preventDefault()
}
}
}
const addChip = () => {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
}
}
const getListCategories = React.useCallback(async () => {
try {
const url = `/arbitrary/categories`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
setOptions(responseData)
} catch (error) {}
}, [])
React.useEffect(() => {
getListCategories()
}, [getListCategories])
return (
<StyledModal open={open} onClose={onClose}>
<ModalContent>
<Typography variant="h6" component="h2" gutterBottom>
Upload Video
</Typography>
<Box
{...getRootProps()}
sx={{
border: '1px dashed gray',
padding: 2,
textAlign: 'center',
marginBottom: 2
}}
>
<input {...getInputProps()} />
<Typography>
{file
? file.name
: 'Drag and drop a video file here or click to select a file'}
</Typography>
</Box>
<TextField
label="Video Title"
variant="outlined"
fullWidth
value={title}
onChange={handleTitleChange}
inputProps={{ maxLength: 40 }}
sx={{ marginBottom: 2 }}
/>
<TextField
label="Video Description"
variant="outlined"
fullWidth
multiline
rows={4}
value={description}
onChange={handleDescriptionChange}
inputProps={{ maxLength: 180 }}
sx={{ marginBottom: 2 }}
/>
{options.length > 0 && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedOption?.id || ''}
onChange={handleOptionChange}
>
{options.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<TextField
label="Add a tag"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
disabled={chips.length === 3}
/>
<IconButton onClick={addChip} disabled={chips.length === 3}>
<AddIcon />
</IconButton>
</Box>
<ChipContainer>
{chips.map((chip, index) => (
<Chip
key={index}
label={chip}
onDelete={() => handleChipDelete(index)}
deleteIcon={<CloseIcon />}
/>
))}
</ChipContainer>
</FormControl>
<Button variant="contained" color="primary" onClick={handleSubmit}>
Submit
</Button>
</ModalContent>
</StyledModal>
)
}
export default VideoModal

78
src/components/editor/BlogEditor.css

@ -0,0 +1,78 @@
/* src/components/BlogEditor.css */
.blog-editor {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
line-height: 1.5;
font-size: 18px;
max-height: 50vh;
overflow-y: auto;
min-height: 200px;
z-index: 500;
}
.toolbar {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.toolbar-button:focus {
outline: none;
}
.code-block {
background-color: #2c2b31;
color: rgb(238, 234, 234);
border-radius: 3px;
padding: 10px;
margin: 10px 0;
font-family: 'Courier New', Courier, monospace;
white-space: pre-wrap;
overflow-x: auto;
max-width: 100%;
font-size: 14px;
}
.paragraph {
font-size: 20px;
margin: 0px;
}
.paragraph-mail {
font-size: 16px;
margin: 0px;
}
.toolbar-button {
background-color: white;
border: 1px solid gray;
border-radius: 5px;
margin-right: 5px;
cursor: pointer;
outline: none;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.toolbar-button.active {
background-color: lightgray;
}
.h2 {
font-size: 25px
}
.h2 {
font-size: 22px
}
.align-center {
text-align: center;
}

579
src/components/editor/BlogEditor.tsx

@ -0,0 +1,579 @@
// src/components/BlogEditor.tsx
// @ts-nocheck
import React, { useMemo, useState, useCallback } from 'react';
import { createEditor, Descendant, Editor, Transforms, Range } from 'slate'
import SvgIcon from '@material-ui/core/SvgIcon'
import {
Slate,
Editable,
withReact,
RenderElementProps,
RenderLeafProps,
useSlate
} from 'slate-react'
import { styled } from '@mui/system'
import { CustomElement, CustomText, FormatMark } from './customTypes'
import './BlogEditor.css'
import { Modal, Box, TextField, Button } from '@mui/material'
import { AlignCenterSVG } from '../../assets/svgs/AlignCenterSVG'
import { BoldSVG } from '../../assets/svgs/BoldSVG'
import { ItalicSVG } from '../../assets/svgs/ItalicSVG'
import { UnderlineSVG } from '../../assets/svgs/UnderlineSVG'
import { H2SVG } from '../../assets/svgs/H2SVG'
import { H3SVG } from '../../assets/svgs/H3SVG'
import { AlignLeftSVG } from '../../assets/svgs/AlignLeftSVG'
import { AlignRightSVG } from '../../assets/svgs/AlignRightSVG'
import { CodeBlockSVG } from '../../assets/svgs/CodeBlockSVG'
import { LinkSVG } from '../../assets/svgs/LinkSVG'
const initialValue: Descendant[] = [
{
type: 'paragraph',
children: [{ text: 'Start writing your blog post...' }]
}
]
interface MyComponentProps {
addPostSection?: (value: any) => void
editPostSection?: (value: any, section: any) => void
defaultValue?: any
section?: any
value: any
setValue: (value: any) => void
editorKey?: number
mode?: string
disableMaxHeight?: boolean
}
const ModalBox = styled(Box)(({ theme }) => ({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: theme.palette.background.paper,
boxShadow: theme.shadows[5],
padding: theme.spacing(2, 4, 3),
gap: '15px',
borderRadius: '5px',
alignItems: 'center',
display: 'flex',
flex: 0
}))
const BlogEditor: React.FC<MyComponentProps> = ({
addPostSection,
editPostSection,
defaultValue,
section,
value,
setValue,
editorKey,
mode,
disableMaxHeight
}) => {
const editor = useMemo(() => withReact(createEditor()), [])
// const [value, setValue] = useState(defaultValue || initialValue);
const isTextAlignmentActive = (editor: Editor, alignment: string) => {
const [match] = Editor.nodes(editor, {
match: (n) => {
return n?.textAlign === alignment?.replace(/^align-/, '')
}
})
return !!match
}
const toggleTextAlignment = (editor: Editor, alignment: string) => {
const isActive = isTextAlignmentActive(editor, alignment)
Transforms.setNodes(
editor,
{ style: { textAlign: isActive ? 'inherit' : alignment } },
{ match: (n) => Editor.isBlock(editor, n) }
)
}
const toggleMark = (editor: Editor, format: FormatMark) => {
if (
format === 'align-left' ||
format === 'align-center' ||
format === 'align-right'
) {
toggleTextAlignment(editor, format)
} else {
const isActive = Editor?.marks(editor)?.[format] === true
if (isActive) {
Editor?.removeMark(editor, format)
} else {
Editor?.addMark(editor, format, true)
}
}
}
const newValue = useMemo(() => [...(value || initialValue)], [value])
const types = ['paragraph', 'heading-2', 'heading-3']
const setTextAlignment = (editor, alignment) => {
const isActive = isTextAlignmentActive(editor, alignment)
const alignmentType = ''
Transforms?.setNodes(
editor,
{
textAlign: isActive ? null : alignment
},
{
match: (n) =>
n.type === 'heading-2' ||
n.type === 'heading-3' ||
n.type === 'paragraph'
}
)
}
const ToolbarButton: React.FC<{
format: FormatMark | string
label: string
editor: Editor
children: React.ReactNode
}> = ({ format, label, editor, children }) => {
useSlate()
let onClick = () => {
if (format === 'heading-2' || format === 'heading-3') {
toggleBlock(editor, format)
} else if (
format === 'bold' ||
format === 'italic' ||
format === 'underline' ||
format === ''
) {
toggleMark(editor, format)
} else if (
format === 'align-left' ||
format === 'align-center' ||
format === 'align-right'
) {
setTextAlignment(editor, format?.replace(/^align-/, ''))
}
}
let isActive = false
try {
if (
format === 'align-left' ||
format === 'align-center' ||
format === 'align-right'
) {
isActive = isTextAlignmentActive(editor, format)
} else if (format === 'heading-2' || format === 'heading-3') {
isActive = isBlockActive(editor, format)
} else if (
format === 'bold' ||
format === 'italic' ||
format === 'underline' ||
format === ''
) {
isActive = Editor?.marks(editor)?.[format] === true
}
} catch (error) {}
return (
<button
className={`toolbar-button ${isActive ? 'active' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
onClick()
}}
>
{children ? children : label}
</button>
)
}
const ToolbarButtonCodeBlock: React.FC<{
format: FormatMark | string
label: string
editor: Editor
children: React.ReactNode
}> = ({ format, label, editor, children }) => {
const editor2 = useSlate()
let onClick = () => {
if (format === 'code-block') {
toggleBlock(editor, 'code-block')
}
}
let isActive = false
try {
if (format === 'code-block') {
isActive = isBlockActive(editor, format)
}
} catch (error) {}
return (
<button
className={`toolbar-button ${isActive ? 'active' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
onClick()
}}
>
{children ? children : label}
</button>
)
}
const ToolbarButtonAlign: React.FC<{
format: string
label: string
editor: Editor
}> = ({ format, label, editor }) => {
const isActive =
Editor?.nodes(editor, {
match: (n) => n?.align === format
})?.length > 0
return (
<button
className={`toolbar-button ${isActive ? 'active' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
Transforms?.setNodes(
editor,
{ align: format },
{ match: (n) => Editor?.isBlock(editor, n) }
)
}}
>
{label}
</button>
)
}
const ToolbarButtonCodeLink: React.FC<{
format: FormatMark | string
label: string
editor: Editor
children: React.ReactNode
}> = ({ format, label, editor, children }) => {
useSlate()
let isActive = false
try {
if (format === 'link') {
isActive = !!Editor?.marks(editor)?.link
}
} catch (error) {}
return (
<button
className={`toolbar-button ${isActive ? 'active' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
const isActive2 = !!Editor?.marks(editor)?.link
if (isActive2) {
Editor?.removeMark(editor, 'link')
return
}
// const url = window.prompt('Enter the URL of the link:')
setOpen(true)
}}
>
{children ? children : label}
</button>
)
}
// Create a toggleBlock function and an isBlockActive function to handle block elements
const toggleBlock = (editor: Editor, format: string) => {
const isActive = isBlockActive(editor, format)
Transforms?.unwrapNodes(editor, {
match: (n) => Editor?.isBlock(editor, n),
split: true
})
if (isActive) {
Transforms?.setNodes(editor, { type: 'paragraph' })
} else {
Transforms?.setNodes(editor, { type: format })
}
}
const isBlockActive = (editor: Editor, format: string) => {
const [match] = Editor?.nodes(editor, {
match: (n) => n?.type === format
})
return !!match
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && isBlockActive(editor, 'code-block')) {
event.preventDefault()
editor?.insertText('\n')
}
if (event.key === 'ArrowDown' && isBlockActive(editor, 'code-block')) {
event.preventDefault()
Transforms?.insertNodes(editor, {
type: 'paragraph',
children: [{ text: '' }]
})
}
}
const handleChange = (newValue: Descendant[]) => {
setValue(newValue)
}
const toggleLink = (editor: Editor, url: string) => {
const { selection } = editor
if (selection && !Range.isCollapsed(selection)) {
const isLink = Editor?.marks(editor)?.link === true
const isInsideLink = isLinkActive(editor)
if (isLink) {
Editor?.removeMark(editor, 'link')
} else if (url) {
Editor?.addMark(editor, 'link', url)
}
}
}
const [open, setOpen] = useState(false)
const initialValue = 'qortal://'
const [inputValue, setInputValue] = useState(initialValue)
const handleChangeLink = (event) => {
const newValue = event?.target?.value
if (newValue?.startsWith(initialValue)) {
setInputValue(newValue)
}
}
const isLinkActive = (editor: Editor) => {
const [link] = Editor?.nodes(editor, {
match: (n) => n?.type === 'link'
})
return !!link
}
const handleSaveClick = () => {
const marks = Editor?.marks(editor)
const isLink = marks?.link === true
if (isLink) {
Editor?.removeMark(editor, 'link')
return // Return early to skip the rest of the function
}
toggleLink(editor, inputValue)
setOpen(false)
}
const onClose = () => {
setOpen(false)
}
const handlePaste = (event: React.ClipboardEvent) => {
event.preventDefault()
const text = event?.clipboardData?.getData('text/plain')
const isCodeBlock = isBlockActive(editor, 'code-block')
if (isCodeBlock) {
const lines = text?.split('\n')
const fragment: Descendant[] = [
{
type: 'code-block',
children: lines?.map((line) => ({
type: 'code-line',
children: [{ text: line }]
}))
}
]
Transforms?.insertFragment(editor, fragment)
} else if (text) {
const fragment = text?.split('\n').map((line) => ({
type: 'paragraph',
children: [{ text: line }]
}))
Transforms?.insertFragment(editor, fragment)
}
}
return (
<Box
sx={{
width: '100%',
border: '1px solid',
borderRadius: '5px',
marginTop: '20px',
padding: '10px'
}}
>
<Slate
editor={editor}
value={newValue}
onChange={(newValue) => handleChange(newValue)}
key={editorKey || 1}
>
<div className="toolbar">
<ToolbarButton format="bold" label="B" editor={editor}>
<BoldSVG height="24px" width="auto" />
</ToolbarButton>
<ToolbarButton format="italic" label="I" editor={editor}>
<ItalicSVG height="24px" width="auto" />
</ToolbarButton>
<ToolbarButton format="underline" label="U" editor={editor}>
<UnderlineSVG height="24px" width="auto" />
</ToolbarButton>
<ToolbarButton format="heading-2" label="H2" editor={editor}>
<H2SVG height="24px" width="auto" />
</ToolbarButton>
<ToolbarButton format="heading-3" label="H3" editor={editor}>
<H3SVG height="24px" width="auto" />
</ToolbarButton>
<ToolbarButton format="align-left" label="L" editor={editor}>
<AlignLeftSVG height="24px" width="auto" />
</ToolbarButton>
<ToolbarButton format="align-center" label="C" editor={editor}>
<AlignCenterSVG height="24px" width="auto" />
</ToolbarButton>
<ToolbarButton format="align-right" label="R" editor={editor}>
<AlignRightSVG height="24px" width="auto" />
</ToolbarButton>
<ToolbarButtonCodeBlock
format="code-block"
label="Code"
editor={editor}
>
<CodeBlockSVG height="24px" width="auto" />
</ToolbarButtonCodeBlock>
<ToolbarButtonCodeLink format="link" label="Link" editor={editor}>
<LinkSVG height="24px" width="auto" />
</ToolbarButtonCodeLink>
</div>
<Editable
className="blog-editor"
renderElement={(props) => renderElement({ ...props, mode })}
renderLeaf={renderLeaf}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
mode={mode}
style={{
maxHeight: disableMaxHeight && 'unset'
}}
/>
</Slate>
<Modal open={open} onClose={onClose}>
<ModalBox>
<TextField
label="Link"
value={inputValue}
onChange={handleChangeLink}
/>
<Button variant="contained" onClick={handleSaveClick}>
Save
</Button>
</ModalBox>
</Modal>
{editPostSection && (
<Button onClick={() => editPostSection(value, section)}>
Edit Section
</Button>
)}
</Box>
)
}
export default BlogEditor
type ExtendedRenderElementProps = RenderElementProps & { mode?: string }
export const renderElement = ({
attributes,
children,
element,
mode
}: ExtendedRenderElementProps) => {
switch (element.type) {
case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'heading-2':
return (
<h2
className="h2"
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</h2>
)
case 'heading-3':
return (
<h3
className="h3"
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</h3>
)
case 'code-block':
return (
<pre {...attributes} className="code-block">
<code>{children}</code>
</pre>
)
case 'code-line':
return <div {...attributes}>{children}</div>
case 'link':
return (
<a href={element.url} {...attributes}>
{children}
</a>
)
default:
return (
<p
className={`paragraph${mode ? `-${mode}` : ''}`}
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</p>
)
}
}
export const renderLeaf = ({ attributes, children, leaf }: RenderLeafProps) => {
let el = children
if (leaf.bold) {
el = <strong>{el}</strong>
}
if (leaf.italic) {
el = <em>{el}</em>
}
if (leaf.underline) {
el = <u>{el}</u>
}
if (leaf.link) {
el = (
<a href={leaf.link} {...attributes}>
{el}
</a>
)
}
return <span {...attributes}>{el}</span>
}

25
src/components/editor/ReadOnlySlate.tsx

@ -0,0 +1,25 @@
import React, { useMemo } from 'react';
import { createEditor, Descendant, Editor } from 'slate';
import { withReact, Slate, Editable, RenderElementProps, RenderLeafProps } from 'slate-react';
import { renderElement, renderLeaf } from './BlogEditor';
interface ReadOnlySlateProps {
content: any
mode?: string
}
const ReadOnlySlate: React.FC<ReadOnlySlateProps> = ({ content, mode }) => {
const editor = useMemo(() => withReact(createEditor()), [])
const value = useMemo(() => content, [content])
return (
<Slate editor={editor} value={value} onChange={() => {}}>
<Editable
readOnly
renderElement={(props) => renderElement({ ...props, mode })}
renderLeaf={renderLeaf}
/>
</Slate>
)
}
export default ReadOnlySlate;

47
src/components/editor/customTypes.ts

@ -0,0 +1,47 @@
// src/customTypes.ts
import { BaseEditor } from 'slate';
import { ReactEditor } from 'slate-react';
export type CustomText = {
text: string
bold?: boolean
italic?: boolean
underline?: boolean
code?: boolean
}
export type HeadingElement = {
type: 'heading'
children: CustomText[]
}
export type BlockQuoteElement = {
type: 'block-quote'
children: CustomText[]
}
export type ParagraphElement = {
type: 'paragraph'
children: CustomText[]
}
export type CodeBlockElement = {
type: 'code-block'
children: CustomText[]
}
export type CustomElement =
| HeadingElement
| BlockQuoteElement
| ParagraphElement
| CodeBlockElement
export type FormatMark = 'bold' | 'italic' | 'underline' | 'code'
declare module 'slate' {
interface CustomTypes {
Editor: BaseEditor & ReactEditor;
Element: CustomElement;
Text: CustomText;
}
}

112
src/components/layout/Navbar/Navbar-styles.ts

@ -0,0 +1,112 @@
import { AppBar, Button, Toolbar, Typography, Box } from '@mui/material'
import { styled } from '@mui/system'
export const QblogLogoContainer = styled('img')({
width: 'auto',
height: 'auto',
userSelect: 'none',
objectFit: 'contain',
cursor: 'pointer'
})
export const CustomAppBar = styled(AppBar)(({ theme }) => ({
backgroundColor: theme.palette.mode === "light" ? theme.palette.background.default : "#19191b",
[theme.breakpoints.only('xs')]: {
gap: '15px',
},
}))
export const CustomToolbar = styled(Toolbar)({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
})
export const CustomTitle = styled(Typography)({
fontWeight: 600,
color: '#000000'
})
export const StyledButton = styled(Button)(({ theme }) => ({
fontWeight: 600,
color: theme.palette.text.primary
}))
export const CreateBlogButton = styled(Button)(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '8px 15px',
borderRadius: "40px",
gap: '4px',
backgroundColor: theme.palette.secondary.main,
color: '#fff',
fontFamily: "Arial",
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.main,
filter: "brightness(1.1)",
}
}))
export const AuthenticateButton = styled(Button)({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '8px 15px',
borderRadius: "40px",
gap: '4px',
backgroundColor: "#4ACE91",
color: '#fff',
fontFamily: "Arial",
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: "#4ACE91",
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.primary.main,
padding: "10px 15px",
transition: "all 0.4s ease-in-out",
"&:hover": {
cursor: "pointer",
filter: "brightness(0.95)"
}
}));
export const DropdownText = styled(Typography)(({ theme }) => ({
fontFamily: "Arial",
fontSize: "16px",
color: theme.palette.text.primary,
userSelect: "none"
}));
export const NavbarName = styled(Typography)(({ theme }) => ({
fontFamily: "Arial",
fontSize: "18px",
color: theme.palette.text.primary,
margin: "0 10px",
}));

169
src/components/layout/Navbar/Navbar.tsx

@ -0,0 +1,169 @@
import React, { useRef, useState } from 'react'
import { Box, Popover, useTheme } from '@mui/material'
import ExitToAppIcon from '@mui/icons-material/ExitToApp'
import { useNavigate } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../../state/store'
import { UserNavbar } from '../../common/UserNavbar/UserNavbar'
import { removePrefix } from '../../../utils/blogIdformats'
import { useLocation } from 'react-router-dom'
import { BlockedNamesModal } from '../../common/BlockedNamesModal/BlockedNamesModal'
import {
AvatarContainer,
CustomAppBar,
CustomToolbar,
DropdownContainer,
DropdownText,
QblogLogoContainer,
AuthenticateButton,
NavbarName
} from './Navbar-styles'
import { AccountCircleSVG } from '../../../assets/svgs/AccountCircleSVG'
import QMailLogo from '../../../assets/img/qmaillogo.png'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import PersonOffIcon from '@mui/icons-material/PersonOff'
import {
addFilteredPosts,
setFilterValue,
setIsFiltering
} from '../../../state/features/blogSlice'
interface Props {
isAuthenticated: boolean
userName: string | null
userAvatar: string
authenticate: () => void
}
const NavBar: React.FC<Props> = ({
isAuthenticated,
userName,
userAvatar,
authenticate
}) => {
const navigate = useNavigate()
const dispatch = useDispatch()
const theme = useTheme()
const { visitingBlog } = useSelector((state: RootState) => state.global)
const location = useLocation()
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null)
const [isOpenModal, setIsOpenModal] = React.useState<boolean>(false)
const searchValRef = useRef('')
const inputRef = useRef<HTMLInputElement>(null)
const stripBlogId = removePrefix(visitingBlog?.blogId || '')
if (visitingBlog?.navbarConfig && location?.pathname?.includes(stripBlogId)) {
return (
<UserNavbar
title={visitingBlog?.title || ''}
menuItems={visitingBlog?.navbarConfig?.navItems || []}
name={visitingBlog?.name || ''}
blogId={visitingBlog?.blogId || ''}
/>
)
}
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.currentTarget as unknown as HTMLButtonElement | null
setAnchorEl(target)
}
const handleClose = () => {
setAnchorEl(null)
}
const onClose = () => {
setIsOpenModal(false)
}
const open = Boolean(anchorEl)
const id = open ? 'simple-popover' : undefined
return (
<CustomAppBar position="sticky" elevation={2}>
<CustomToolbar variant="dense">
<QblogLogoContainer
style={{
height: '32px'
}}
src={QMailLogo}
alt="Q-Mail Logo"
onClick={() => {
navigate(`/`)
dispatch(setIsFiltering(false))
dispatch(setFilterValue(''))
dispatch(addFilteredPosts([]))
searchValRef.current = ''
if (!inputRef.current) return
inputRef.current.value = ''
}}
/>
<Box
sx={{
display: 'flex',
alignItems: 'center'
}}
>
{/* Add isAuthenticated && before username and wrap StyledButton in this condition*/}
{!isAuthenticated && (
<AuthenticateButton onClick={authenticate}>
<ExitToAppIcon />
Authenticate
</AuthenticateButton>
)}
{isAuthenticated && userName && (
<AvatarContainer onClick={handleClick}>
<NavbarName>{userName}</NavbarName>
{!userAvatar ? (
<AccountCircleSVG
color={theme.palette.text.primary}
width="32"
height="32"
/>
) : (
<img
src={userAvatar}
alt="User Avatar"
width="32"
height="32"
style={{
borderRadius: '50%'
}}
/>
)}
<ExpandMoreIcon id="expand-icon" sx={{ color: '#ACB6BF' }} />
</AvatarContainer>
)}
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
>
<DropdownContainer
onClick={() => {
setIsOpenModal(true)
handleClose()
}}
>
<PersonOffIcon
sx={{
color: '#e35050'
}}
/>
<DropdownText>Blocked Names</DropdownText>
</DropdownContainer>
</Popover>
{isOpenModal && (
<BlockedNamesModal open={isOpenModal} onClose={onClose} />
)}
</Box>
</CustomToolbar>
</CustomAppBar>
)
}
export default NavBar

70
src/components/modals/ConsentModal.tsx

@ -0,0 +1,70 @@
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-blog-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">
The Qortal community, along with its development team and the
creators of this application, cannot be held accountable for any
content published or displayed. Furthermore, they bear no
responsibility for any data loss that may occur as a result of using
this application.
</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>
)
}

247
src/components/modals/EditBlogModal.tsx

@ -0,0 +1,247 @@
import React, { useState } from 'react'
import {
Box,
Button,
TextField,
Typography,
Modal,
Select,
MenuItem,
FormControl,
InputLabel,
SelectChangeEvent,
OutlinedInput,
Chip,
IconButton
} from '@mui/material'
import { useDispatch } from 'react-redux'
import { togglePublishBlogModal } from '../../state/features/globalSlice'
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
import { styled } from '@mui/system'
interface SelectOption {
id: string
name: string
}
interface MyModalProps {
open: boolean
onClose: () => void
onPublish: (
title: string,
description: string,
category: string,
tags: string[]
) => Promise<void>
currentBlog: any
}
const ChipContainer = styled(Box)({
display: 'flex',
flexWrap: 'wrap',
'& > *': {
margin: '4px'
}
})
const MyModal: React.FC<MyModalProps> = ({
open,
onClose,
onPublish,
currentBlog
}) => {
const dispatch = useDispatch()
const [title, setTitle] = useState<string>('')
const [description, setDescription] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
null
)
const [inputValue, setInputValue] = useState<string>('')
const [chips, setChips] = useState<string[]>([])
const [options, setOptions] = useState<SelectOption[]>([])
React.useEffect(() => {
if (currentBlog) {
setTitle(currentBlog?.title || '')
setDescription(currentBlog?.description || '')
const findCategory = options.find(
(option) => option.id === currentBlog?.category
)
if (!findCategory) return
setSelectedOption(findCategory)
if (!currentBlog?.tags || !Array.isArray(currentBlog.tags)) return
setChips(currentBlog.tags)
}
}, [currentBlog, options])
const handlePublish = async (): Promise<void> => {
try {
await onPublish(title, description, selectedOption?.id || '', chips)
handleClose()
} catch (error: any) {
setErrorMessage(error.message)
}
}
const handleClose = (): void => {
setErrorMessage('')
dispatch(togglePublishBlogModal(false))
onClose()
}
const handleOptionChange = (event: SelectChangeEvent<string>) => {
const optionId = event.target.value
const selectedOption = options.find((option) => option.id === optionId)
setSelectedOption(selectedOption || null)
}
const handleChipDelete = (index: number) => {
const newChips = [...chips]
newChips.splice(index, 1)
setChips(newChips)
}
const handleInputChange = (event: any) => {
setInputValue(event.target.value)
}
const handleInputKeyDown = (event: any) => {
if (event.key === 'Enter' && inputValue !== '') {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
} else {
event.preventDefault()
}
}
}
const addChip = () => {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
}
}
const getListCategories = React.useCallback(async () => {
try {
const url = `/arbitrary/categories`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
setOptions(responseData)
} catch (error) {}
}, [])
React.useEffect(() => {
getListCategories()
}, [getListCategories])
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
gap: 2
}}
>
<Typography id="modal-title" variant="h6" component="h2">
Edit Blog
</Typography>
<TextField
id="modal-title-input"
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
/>
<TextField
id="modal-description-input"
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
multiline
rows={4}
fullWidth
/>
{options.length > 0 && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedOption?.id || ''}
onChange={handleOptionChange}
>
{options.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<TextField
label="Add a tag"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
disabled={chips.length === 3}
/>
<IconButton onClick={addChip} disabled={chips.length === 3}>
<AddIcon />
</IconButton>
</Box>
<ChipContainer>
{chips.map((chip, index) => (
<Chip
key={index}
label={chip}
onDelete={() => handleChipDelete(index)}
deleteIcon={<CloseIcon />}
/>
))}
</ChipContainer>
</FormControl>
{errorMessage && (
<Typography color="error" variant="body1">
{errorMessage}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button variant="outlined" color="error" onClick={handleClose}>
Cancel
</Button>
<Button variant="contained" color="success" onClick={handlePublish}>
Publish
</Button>
</Box>
</Box>
</Modal>
)
}
export default MyModal

281
src/components/modals/PublishBlogModal.tsx

@ -0,0 +1,281 @@
import React, { ChangeEvent, useState } from 'react'
import {
Box,
Button,
TextField,
Typography,
Modal,
Select,
MenuItem,
FormControl,
InputLabel,
SelectChangeEvent,
OutlinedInput,
Chip,
IconButton
} from '@mui/material'
import { useDispatch } from 'react-redux'
import { togglePublishBlogModal } from '../../state/features/globalSlice'
import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close'
import { styled } from '@mui/system'
interface SelectOption {
id: string
name: string
}
interface MyModalProps {
open: boolean
onClose: () => void
onPublish: (
title: string,
description: string,
category: string,
tags: string[],
blogIdentifier: string
) => Promise<void>
username: string
}
const ChipContainer = styled(Box)({
display: 'flex',
flexWrap: 'wrap',
'& > *': {
margin: '4px'
}
})
const MyModal: React.FC<MyModalProps> = ({
open,
onClose,
onPublish,
username
}) => {
const dispatch = useDispatch()
const [title, setTitle] = useState<string>('')
const [description, setDescription] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
null
)
const [inputValue, setInputValue] = useState<string>('')
const [chips, setChips] = useState<string[]>([])
const [blogIdentifier, setBlogIdentifier] = useState(username || '')
const [options, setOptions] = useState<SelectOption[]>([])
const handlePublish = async (): Promise<void> => {
try {
await onPublish(
title,
description,
selectedOption?.id || '',
chips,
blogIdentifier
)
handleClose()
} catch (error: any) {
setErrorMessage(error.message)
}
}
const handleClose = (): void => {
setTitle('')
setDescription('')
setErrorMessage('')
dispatch(togglePublishBlogModal(false))
onClose()
}
const handleOptionChange = (event: SelectChangeEvent<string>) => {
const optionId = event.target.value
const selectedOption = options.find((option) => option.id === optionId)
setSelectedOption(selectedOption || null)
}
const handleChipDelete = (index: number) => {
const newChips = [...chips]
newChips.splice(index, 1)
setChips(newChips)
}
const handleInputChange = (event: any) => {
setInputValue(event.target.value)
}
const handleInputKeyDown = (event: any) => {
if (event.key === 'Enter' && inputValue !== '') {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
} else {
event.preventDefault()
}
}
}
const addChip = () => {
if (chips.length < 5) {
setChips([...chips, inputValue])
setInputValue('')
}
}
const getListCategories = React.useCallback(async () => {
try {
const url = `/arbitrary/categories`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
setOptions(responseData)
} catch (error) {}
}, [])
React.useEffect(() => {
getListCategories()
}, [getListCategories])
const handleInputChangeId = (event: ChangeEvent<HTMLInputElement>) => {
// Replace any non-alphanumeric and non-space characters with an empty string
// Replace multiple spaces with a single dash and remove any dashes that come one after another
let newValue = event.target.value
.replace(/[^a-zA-Z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
if (newValue.toLowerCase().includes('post')) {
// Replace the 'post' string with an empty string
newValue = newValue.replace(/post/gi, '')
}
if (newValue.toLowerCase().includes('q-blog')) {
// Replace the 'q-blog' string with an empty string
newValue = newValue.replace(/q-blog/gi, '')
}
setBlogIdentifier(newValue)
}
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
gap: 2,
overflowY: 'auto',
maxHeight: '95vh'
}}
>
<Typography id="modal-title" variant="h6" component="h2">
Create blog
</Typography>
<TextField
id="modal-title-input"
label="Url Preview"
value={`/${username}/${blogIdentifier}`}
// onChange={(e) => setTitle(e.target.value)}
fullWidth
disabled={true}
/>
<TextField
id="modal-blogId-input"
label="Blog Id"
value={blogIdentifier}
onChange={handleInputChangeId}
fullWidth
inputProps={{ maxLength: 25 }}
/>
<TextField
id="modal-title-input"
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
/>
<TextField
id="modal-description-input"
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
multiline
rows={4}
fullWidth
/>
{options.length > 0 && (
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<InputLabel id="Category">Select a Category</InputLabel>
<Select
labelId="Category"
input={<OutlinedInput label="Select a Category" />}
value={selectedOption?.id || ''}
onChange={handleOptionChange}
>
{options.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<TextField
label="Add a tag"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
disabled={chips.length === 3}
/>
<IconButton onClick={addChip} disabled={chips.length === 3}>
<AddIcon />
</IconButton>
</Box>
<ChipContainer>
{chips.map((chip, index) => (
<Chip
key={index}
label={chip}
onDelete={() => handleChipDelete(index)}
deleteIcon={<CloseIcon />}
/>
))}
</ChipContainer>
</FormControl>
{errorMessage && (
<Typography color="error" variant="body1">
{errorMessage}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button variant="outlined" color="error" onClick={handleClose}>
Cancel
</Button>
<Button variant="contained" color="success" onClick={handlePublish}>
Publish
</Button>
</Box>
</Box>
</Modal>
)
}
export default MyModal

47
src/components/modals/ReusableModal.tsx

@ -0,0 +1,47 @@
import React from 'react'
import { Box, Modal, useTheme } from '@mui/material'
interface MyModalProps {
open: boolean
onClose?: () => void
onSubmit?: (obj: any) => Promise<void>
children: any
customStyles?: any
}
export const ReusableModal: React.FC<MyModalProps> = ({
open,
onClose,
onSubmit,
children,
customStyles = {}
}) => {
const theme = useTheme()
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '75%',
bgcolor: theme.palette.primary.main,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
gap: 2,
...customStyles
}}
>
{children}
</Box>
</Modal>
)
}

3
src/constants/mail.ts

@ -0,0 +1,3 @@
export const MAIL_SERVICE_TYPE: 'MAIL_PRIVATE' = 'MAIL_PRIVATE'
export const MAIL_ATTACHMENT_SERVICE_TYPE: 'ATTACHMENT_PRIVATE' =
'ATTACHMENT_PRIVATE'

61
src/global.d.ts vendored

@ -0,0 +1,61 @@
// 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
mode?: string
txGroupId?: string | number
after?: number
before?: number
groupId?: string | number
message?: string
}
declare function qortalRequest(options: QortalRequestOptions): Promise<any>
declare function qortalRequestWithTimeout(
options: QortalRequestOptions,
time: number
): Promise<any>
declare global {
interface Window {
_qdnBase: any // Replace 'any' with the appropriate type if you know it
_qdnTheme: string
}
}
declare global {
interface Window {
showSaveFilePicker: (
options?: SaveFilePickerOptions
) => Promise<FileSystemFileHandle>
}
}

36
src/hooks/useConfirmModal.tsx

@ -0,0 +1,36 @@
import { useState } from 'react'
import ConfirmationModal, {
ModalProps
} from '../components/common/ConfirmationModal'
const useConfirmationModal = (props: any) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false)
const [resolvePromise, setResolvePromise] =
useState<(value: boolean) => void>()
const showModal = async () => {
setIsModalOpen(true)
return new Promise<boolean>((resolve) => {
setResolvePromise(() => resolve)
})
}
const handleUserAction = (userConfirmed: boolean) => {
setIsModalOpen(false)
resolvePromise?.(userConfirmed)
}
const Modal = () => (
<ConfirmationModal
open={isModalOpen}
title={props.title}
message={props.message}
handleConfirm={() => handleUserAction(true)}
handleCancel={() => handleUserAction(false)}
/>
)
return { Modal, showModal }
}
export default useConfirmationModal

469
src/hooks/useFetchMail.tsx

@ -0,0 +1,469 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
addPosts,
addToHashMap,
BlogPost,
populateFavorites,
setCountNewPosts,
upsertFilteredPosts,
upsertPosts,
upsertPostsBeginning,
upsertSubscriptionPosts
} from '../state/features/blogSlice'
import {
setCurrentBlog,
setIsLoadingGlobal,
setUserAvatarHash
} from '../state/features/globalSlice'
import { RootState } from '../state/store'
import { fetchAndEvaluatePosts } from '../utils/fetchPosts'
import { fetchAndEvaluateMail } from '../utils/fetchMail'
import {
addToHashMapMail,
upsertMessages,
upsertMessagesBeginning
} from '../state/features/mailSlice'
import { MAIL_SERVICE_TYPE } from '../constants/mail'
export const useFetchMail = () => {
const dispatch = useDispatch()
const hashMapPosts = useSelector(
(state: RootState) => state.blog.hashMapPosts
)
const hashMapMailMessages = useSelector(
(state: RootState) => state.mail.hashMapMailMessages
)
const posts = useSelector((state: RootState) => state.blog.posts)
const mailMessages = useSelector(
(state: RootState) => state.mail.mailMessages
)
const filteredPosts = useSelector(
(state: RootState) => state.blog.filteredPosts
)
const favoritesLocal = useSelector(
(state: RootState) => state.blog.favoritesLocal
)
const favorites = useSelector((state: RootState) => state.blog.favorites)
const subscriptionPosts = useSelector(
(state: RootState) => state.blog.subscriptionPosts
)
const subscriptions = useSelector(
(state: RootState) => state.blog.subscriptions
)
const checkAndUpdatePost = React.useCallback(
(post: BlogPost) => {
// Check if the post exists in hashMapPosts
const existingPost = hashMapPosts[post.id]
if (!existingPost) {
// If the post doesn't exist, add it to hashMapPosts
return true
} else if (
post?.updated &&
existingPost?.updated &&
(!existingPost?.updated || post?.updated) > existingPost?.updated
) {
// If the post exists and its updated is more recent than the existing post's updated, update it in hashMapPosts
return true
} else {
return false
}
},
[hashMapPosts]
)
const getBlogPost = async (user: string, postId: string, content: any) => {
const res = await fetchAndEvaluatePosts({
user,
postId,
content
})
dispatch(addToHashMap(res))
}
const getMailMessage = async (user: string, postId: string, content: any) => {
const res = await fetchAndEvaluateMail({
user,
postId,
content
})
dispatch(addToHashMapMail(res))
}
const checkNewMessages = React.useCallback(
async (recipientName: string, recipientAddress: string) => {
try {
const query = `qortal_qmail_${recipientName.slice(
0,
20
)}_${recipientAddress.slice(-6)}_mail_`
const url = `/arbitrary/resources/search?mode=ALL&service=${MAIL_SERVICE_TYPE}&query=${query}&limit=20&includemetadata=true&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const latestPost = mailMessages[0]
if (!latestPost) return
const findPost = responseData?.findIndex(
(item: any) => item?.identifier === latestPost?.id
)
if (findPost === -1) {
return
}
const newArray = responseData.slice(0, findPost)
const structureData = newArray.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: post?.created,
updated: post?.updated,
user: post.name,
id: post.identifier
}
})
dispatch(upsertMessagesBeginning(structureData))
return
} catch (error) {}
},
[mailMessages]
)
const getNewPosts = React.useCallback(async () => {
try {
dispatch(setIsLoadingGlobal(true))
dispatch(setCountNewPosts(0))
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const latestPost = posts[0]
if (!latestPost) return
const findPost = responseData?.findIndex(
(item: any) => item?.identifier === latestPost?.id
)
let fetchAll = responseData
let willFetchAll = true
if (findPost !== -1) {
willFetchAll = false
fetchAll = responseData.slice(0, findPost)
}
const structureData = fetchAll.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: post?.created,
updated: post?.updated,
user: post.name,
postImage: '',
id: post.identifier
}
})
if (!willFetchAll) {
dispatch(upsertPostsBeginning(structureData))
}
if (willFetchAll) {
dispatch(addPosts(structureData))
}
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [posts, hashMapPosts])
const getBlogPosts = React.useCallback(async () => {
try {
const offset = posts.length
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: post?.created,
updated: post?.updated,
user: post.name,
postImage: '',
id: post.identifier
}
})
dispatch(upsertPosts(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [posts, hashMapPosts])
const getAvatar = async (user: string) => {
try {
let url = await qortalRequest({
action: 'GET_QDN_RESOURCE_URL',
name: user,
service: 'THUMBNAIL',
identifier: 'qortal_avatar'
})
dispatch(
setUserAvatarHash({
name: user,
url
})
)
} catch (error) {}
}
const getMailMessages = React.useCallback(
async (recipientName: string, recipientAddress: string) => {
try {
const offset = mailMessages.length
dispatch(setIsLoadingGlobal(true))
const query = `qortal_qmail_${recipientName.slice(
0,
20
)}_${recipientAddress.slice(-6)}_mail_`
const url = `/arbitrary/resources/search?mode=ALL&service=${MAIL_SERVICE_TYPE}&query=${query}&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: post?.created,
updated: post?.updated,
user: post.name,
id: post.identifier
}
})
dispatch(upsertMessages(structureData))
for (const content of structureData) {
if (content.user && content.id) {
getAvatar(content.user)
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
},
[mailMessages, hashMapMailMessages]
)
const getBlogFilteredPosts = React.useCallback(
async (filterValue: string) => {
try {
const offset = filteredPosts.length
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true&name=${filterValue}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: post?.created,
updated: post?.updated,
user: post.name,
postImage: '',
id: post.identifier
}
})
dispatch(upsertFilteredPosts(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
},
[filteredPosts, hashMapPosts]
)
const getBlogPostsSubscriptions = React.useCallback(
async (username: string) => {
try {
const offset = subscriptionPosts.length
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&namefilter=q-blog-subscriptions-${username}&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: '',
user: post.name,
postImage: '',
id: post.identifier
}
})
dispatch(upsertSubscriptionPosts(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
},
[subscriptionPosts, hashMapPosts, subscriptions]
)
const getBlogPostsFavorites = React.useCallback(async () => {
try {
const offset = favorites.length
const favSlice = (favoritesLocal || []).slice(offset, 20)
let favs = []
for (const item of favSlice) {
try {
// await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// service: "THUMBNAIL",
// query: "search query goes here", // Optional - searches both "identifier" and "name" fields
// identifier: "search query goes here", // Optional - searches only the "identifier" field
// name: "search query goes here", // Optional - searches only the "name" field
// prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
// default: false, // Optional - if true, only resources without identifiers are returned
// includeStatus: false, // Optional - will take time to respond, so only request if necessary
// includeMetadata: false, // Optional - will take time to respond, so only request if necessary
// limit: 100,
// offset: 0,
// reverse: true
// });
//TODO - NAME SHOULD BE EXACT
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${item.id}&exactmatchnames=true&name=${item.user}&limit=20&includemetadata=true&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const data = await response.json()
//
if (data.length > 0) {
favs.push(data[0])
}
} catch (error) {}
}
const structureData = favs.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: '',
user: post.name,
postImage: '',
id: post.identifier
}
})
dispatch(populateFavorites(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
}
}, [hashMapPosts, favoritesLocal])
return {
getBlogPosts,
getBlogPostsFavorites,
getBlogPostsSubscriptions,
checkAndUpdatePost,
getBlogPost,
hashMapPosts,
checkNewMessages,
getNewPosts,
getBlogFilteredPosts,
getMailMessages
}
}

362
src/hooks/useFetchPosts.tsx

@ -0,0 +1,362 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
addPosts,
addToHashMap,
BlogPost,
populateFavorites,
setCountNewPosts,
upsertFilteredPosts,
upsertPosts,
upsertPostsBeginning,
upsertSubscriptionPosts
} from '../state/features/blogSlice'
import {
setCurrentBlog,
setIsLoadingGlobal
} from '../state/features/globalSlice'
import { RootState } from '../state/store'
import { fetchAndEvaluatePosts } from '../utils/fetchPosts'
export const useFetchPosts = () => {
const dispatch = useDispatch()
const hashMapPosts = useSelector(
(state: RootState) => state.blog.hashMapPosts
)
const posts = useSelector((state: RootState) => state.blog.posts)
const filteredPosts = useSelector(
(state: RootState) => state.blog.filteredPosts
)
const favoritesLocal = useSelector(
(state: RootState) => state.blog.favoritesLocal
)
const favorites = useSelector((state: RootState) => state.blog.favorites)
const subscriptionPosts = useSelector(
(state: RootState) => state.blog.subscriptionPosts
)
const subscriptions = useSelector(
(state: RootState) => state.blog.subscriptions
)
const checkAndUpdatePost = React.useCallback(
(post: BlogPost) => {
// Check if the post exists in hashMapPosts
const existingPost = hashMapPosts[post.id]
if (!existingPost) {
// If the post doesn't exist, add it to hashMapPosts
return true
} else if (
post?.updated &&
existingPost?.updated &&
(!existingPost?.updated || post?.updated) > existingPost?.updated
) {
// If the post exists and its updated is more recent than the existing post's updated, update it in hashMapPosts
return true
} else {
return false
}
},
[hashMapPosts]
)
const getBlogPost = async (user: string, postId: string, content: any) => {
const res = await fetchAndEvaluatePosts({
user,
postId,
content
})
dispatch(addToHashMap(res))
}
const checkNewMessages = React.useCallback(async () => {
try {
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const latestPost = posts[0]
if (!latestPost) return
const findPost = responseData?.findIndex(
(item: any) => item?.identifier === latestPost?.id
)
if (findPost === -1) {
dispatch(setCountNewPosts(responseData.length))
return
}
const newArray = responseData.slice(0, findPost)
dispatch(setCountNewPosts(newArray.length))
return
} catch (error) {}
}, [posts])
const getNewPosts = React.useCallback(async () => {
try {
dispatch(setIsLoadingGlobal(true))
dispatch(setCountNewPosts(0))
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const latestPost = posts[0]
if (!latestPost) return
const findPost = responseData?.findIndex(
(item: any) => item?.identifier === latestPost?.id
)
let fetchAll = responseData
let willFetchAll = true
if (findPost !== -1) {
willFetchAll = false
fetchAll = responseData.slice(0, findPost)
}
const structureData = fetchAll.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: post?.created,
updated: post?.updated,
user: post.name,
postImage: '',
id: post.identifier
}
})
if (!willFetchAll) {
dispatch(upsertPostsBeginning(structureData))
}
if (willFetchAll) {
dispatch(addPosts(structureData))
}
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [posts, hashMapPosts])
const getBlogPosts = React.useCallback(async () => {
try {
const offset = posts.length
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: post?.created,
updated: post?.updated,
user: post.name,
postImage: '',
id: post.identifier
}
})
dispatch(upsertPosts(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [posts, hashMapPosts])
const getBlogFilteredPosts = React.useCallback(
async (filterValue: string) => {
try {
const offset = filteredPosts.length
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true&name=${filterValue}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: post?.created,
updated: post?.updated,
user: post.name,
postImage: '',
id: post.identifier
}
})
dispatch(upsertFilteredPosts(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
},
[filteredPosts, hashMapPosts]
)
const getBlogPostsSubscriptions = React.useCallback(
async (username: string) => {
try {
const offset = subscriptionPosts.length
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&namefilter=q-blog-subscriptions-${username}&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: '',
user: post.name,
postImage: '',
id: post.identifier
}
})
dispatch(upsertSubscriptionPosts(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
},
[subscriptionPosts, hashMapPosts, subscriptions]
)
const getBlogPostsFavorites = React.useCallback(async () => {
try {
const offset = favorites.length
const favSlice = (favoritesLocal || []).slice(offset, 20)
let favs = []
for (const item of favSlice) {
try {
// await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// service: "THUMBNAIL",
// query: "search query goes here", // Optional - searches both "identifier" and "name" fields
// identifier: "search query goes here", // Optional - searches only the "identifier" field
// name: "search query goes here", // Optional - searches only the "name" field
// prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
// default: false, // Optional - if true, only resources without identifiers are returned
// includeStatus: false, // Optional - will take time to respond, so only request if necessary
// includeMetadata: false, // Optional - will take time to respond, so only request if necessary
// limit: 100,
// offset: 0,
// reverse: true
// });
//TODO - NAME SHOULD BE EXACT
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${item.id}&exactmatchnames=true&name=${item.user}&limit=20&includemetadata=true&reverse=true&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const data = await response.json()
//
if (data.length > 0) {
favs.push(data[0])
}
} catch (error) {}
}
const structureData = favs.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: '',
user: post.name,
postImage: '',
id: post.identifier
}
})
dispatch(populateFavorites(structureData))
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
}
}, [hashMapPosts, favoritesLocal])
return {
getBlogPosts,
getBlogPostsFavorites,
getBlogPostsSubscriptions,
checkAndUpdatePost,
getBlogPost,
hashMapPosts,
checkNewMessages,
getNewPosts,
getBlogFilteredPosts
}
}

168
src/index.css

@ -0,0 +1,168 @@
@font-face {
font-family: 'CambonLight';
src: url('./styles/fonts/Cambon-Light.ttf') format('truetype');
}
@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: 0.75;
transition: 0.2s all;
}
.post-image {
max-width: 100%;
border-radius: 5px;
width: 100%;
height: 100%;
}
.grid-item {
/* Other styles */
/* overflow: auto; */
}
.grid-item-view {
/* Other styles */
/* overflow: auto; */
}
.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;
}
.threadScroller::-webkit-scrollbar-track {
background-color: transparent;
}
.threadScroller::-webkit-scrollbar-track:hover {
background-color: transparent;
}
.threadScroller::-webkit-scrollbar {
width: 16px;
height: 10px;
background-color: white;
}
.threadScroller::-webkit-scrollbar-thumb {
background-color: #838eee;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}

9
src/index.d.ts vendored

@ -0,0 +1,9 @@
declare module 'webworker:getBlogWorker' {
const value: new () => Worker;
export default value;
}
declare module 'webworker:decodeBase64' {
const value: new () => Worker
export default value
}

8
src/interfaces/interfaces.ts

@ -0,0 +1,8 @@
export interface BlogContent {
postContent: any[]
title: string
createdAt: number
user?: any
postId?: string
layouts?: any
}

28
src/main.tsx

@ -0,0 +1,28 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import { HashRouter, BrowserRouter } from 'react-router-dom'
if (typeof global === 'undefined') {
// Check if window is defined to avoid issues in non-browser environments
if (typeof window !== 'undefined') {
;(window as any).global = window
}
}
interface CustomWindow extends Window {
_qdnBase: any // Replace 'any' with the appropriate type if you know it
}
const customWindow = window as unknown as CustomWindow
// Now you can access the _qdnTheme property without TypeScript errors
const baseUrl = customWindow?._qdnBase || ''
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<BrowserRouter basename={baseUrl}>
<App />
<div id="modal-root" />
</BrowserRouter>
)

855
src/pages/BlogIndividualPost/BlogIndividualPost.tsx

@ -0,0 +1,855 @@
import React, { useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import {
Button,
Box,
Typography,
CardHeader,
Avatar,
useTheme
} from '@mui/material'
import { useNavigate } from 'react-router-dom'
import { styled } from '@mui/system'
import AudiotrackIcon from '@mui/icons-material/Audiotrack'
import ReadOnlySlate from '../../components/editor/ReadOnlySlate'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { checkStructure } from '../../utils/checkStructure'
import { BlogContent } from '../../interfaces/interfaces'
import {
setAudio,
setCurrAudio,
setIsLoadingGlobal,
setVisitingBlog
} from '../../state/features/globalSlice'
import { VideoPlayer } from '../../components/common/VideoPlayer'
import { AudioPlayer, IPlaylist } from '../../components/common/AudioPlayer'
import { Responsive, WidthProvider } from 'react-grid-layout'
import '/node_modules/react-grid-layout/css/styles.css'
import '/node_modules/react-resizable/css/styles.css'
import DynamicHeightItem from '../../components/DynamicHeightItem'
import {
addPrefix,
buildIdentifierFromCreateTitleIdAndId
} from '../../utils/blogIdformats'
import { DynamicHeightItemMinimal } from '../../components/DynamicHeightItemMinimal'
import { ReusableModal } from '../../components/modals/ReusableModal'
import AudioElement from '../../components/AudioElement'
import ErrorBoundary from '../../components/common/ErrorBoundary'
import { CommentSection } from '../../components/common/Comments/CommentSection'
import { Tipping } from '../../components/common/Tipping/Tipping'
import FileElement from '../../components/FileElement'
const ResponsiveGridLayout = WidthProvider(Responsive)
const initialMinHeight = 2 // Define an initial minimum height for grid items
const md = [
{ i: 'a', x: 0, y: 0, w: 4, h: initialMinHeight },
{ i: 'b', x: 6, y: 0, w: 4, h: initialMinHeight }
]
const sm = [
{ i: 'a', x: 0, y: 0, w: 6, h: initialMinHeight },
{ i: 'b', x: 6, y: 0, w: 6, h: initialMinHeight }
]
const xs = [
{ i: 'a', x: 0, y: 0, w: 6, h: initialMinHeight },
{ i: 'b', x: 6, y: 0, w: 6, h: initialMinHeight }
]
interface ILayoutGeneralSettings {
padding: number
blogPostType: string
}
export const BlogIndividualPost = () => {
const { user, postId, blog } = useParams()
const blogFull = React.useMemo(() => {
if (!blog) return ''
return addPrefix(blog)
}, [blog])
const { user: userState } = useSelector((state: RootState) => state.auth)
const { audios, audioPostId } = useSelector(
(state: RootState) => state.global
)
const [avatarUrl, setAvatarUrl] = React.useState<string>('')
const dispatch = useDispatch()
const navigate = useNavigate()
const theme = useTheme()
// const [currAudio, setCurrAudio] = React.useState<number | null>(null)
const [layouts, setLayouts] = React.useState<any>({ md, sm, xs })
const [count, setCount] = React.useState<number>(1)
const [layoutGeneralSettings, setLayoutGeneralSettings] =
React.useState<ILayoutGeneralSettings | null>(null)
const [currentBreakpoint, setCurrentBreakpoint] = React.useState<any>()
const handleLayoutChange = (layout: any, layoutss: any) => {
// const redoLayouts = setAutoHeight(layoutss)
setLayouts(layoutss)
// saveLayoutsToLocalStorage(layoutss)
}
const [blogContent, setBlogContent] = React.useState<BlogContent | null>(null)
const [isOpenSwitchPlaylistModal, setisOpenSwitchPlaylistModal] =
useState<boolean>(false)
const tempSaveAudio = useRef<any>(null)
const saveAudio = React.useRef<any>(null)
const fullPostId = useMemo(() => {
if (!blog || !postId) return ''
dispatch(setIsLoadingGlobal(true))
const formBlogId = addPrefix(blog)
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId)
return formPostId
}, [blog, postId])
const getBlogPost = React.useCallback(async () => {
try {
if (!blog || !postId) return
dispatch(setIsLoadingGlobal(true))
const formBlogId = addPrefix(blog)
const formPostId = buildIdentifierFromCreateTitleIdAndId(
formBlogId,
postId
)
const url = `/arbitrary/BLOG_POST/${user}/${formPostId}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
if (checkStructure(responseData)) {
setBlogContent(responseData)
if (responseData?.layouts) {
setLayouts(responseData?.layouts)
}
if (responseData?.layoutGeneralSettings) {
setLayoutGeneralSettings(responseData.layoutGeneralSettings)
}
const filteredAudios = (responseData?.postContent || []).filter(
(content: any) => content?.type === 'audio'
)
const transformAudios = filteredAudios?.map((fa: any) => {
return {
...(fa?.content || {}),
id: fa?.id
}
})
if (!audios && transformAudios.length > 0) {
saveAudio.current = { audios: transformAudios, postId: formPostId }
dispatch(setAudio({ audios: transformAudios, postId: formPostId }))
} else if (
formPostId === audioPostId &&
audios?.length !== transformAudios.length
) {
tempSaveAudio.current = {
message:
"This post's audio playlist has updated. Would you like to switch?"
}
setisOpenSwitchPlaylistModal(true)
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [user, postId, blog])
React.useEffect(() => {
getBlogPost()
}, [postId])
const switchPlayList = () => {
const filteredAudios = (blogContent?.postContent || []).filter(
(content) => content?.type === 'audio'
)
const formatAudios = filteredAudios.map((fa) => {
return {
...(fa?.content || {}),
id: fa?.id
}
})
if (!blog || !postId) return
const formBlogId = addPrefix(blog)
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId)
dispatch(setAudio({ audios: formatAudios, postId: formPostId }))
if (tempSaveAudio?.current?.currentSelection) {
const findIndex = (formatAudios || []).findIndex(
(item) =>
item?.identifier ===
tempSaveAudio?.current?.currentSelection?.content?.identifier
)
if (findIndex >= 0) {
dispatch(setCurrAudio(findIndex))
}
}
setisOpenSwitchPlaylistModal(false)
}
const getAvatar = React.useCallback(async () => {
try {
let url = await qortalRequest({
action: 'GET_QDN_RESOURCE_URL',
name: user,
service: 'THUMBNAIL',
identifier: 'qortal_avatar'
})
setAvatarUrl(url)
} catch (error) {}
}, [user])
React.useEffect(() => {
getAvatar()
}, [])
const onBreakpointChange = React.useCallback((newBreakpoint: any) => {
setCurrentBreakpoint(newBreakpoint)
}, [])
const onResizeStop = React.useCallback((layout: any, layoutItem: any) => {
// Update the layout state with the new position and size of the component
setCount((prev) => prev + 1)
}, [])
// const audios = React.useMemo<IPlaylist[]>(() => {
// const filteredAudios = (blogContent?.postContent || []).filter(
// (content) => content.type === 'audio'
// )
// return filteredAudios.map((fa) => {
// return {
// ...fa.content,
// id: fa.id
// }
// })
// }, [blogContent])
const handleResize = () => {
setCount((prev) => prev + 1)
}
React.useEffect(() => {
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const handleCount = React.useCallback(() => {
// Update the layout state with the new position and size of the component
setCount((prev) => prev + 1)
}, [])
const getBlog = React.useCallback(async () => {
let name = user
if (!name) return
if (!blogFull) return
try {
const urlBlog = `/arbitrary/BLOG/${name}/${blogFull}`
const response = await fetch(urlBlog, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
dispatch(setVisitingBlog({ ...responseData, name }))
} catch (error) {}
}, [user, blogFull])
React.useEffect(() => {
getBlog()
}, [user, blogFull])
if (!blogContent) return null
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column'
}}
>
<Box
sx={{
maxWidth: '1400px',
// margin: '15px',
width: '95%',
paddingBottom: '50px'
}}
>
{user === userState?.name && (
<Button
sx={{ backgroundColor: theme.palette.secondary.main }}
onClick={() => {
navigate(`/${user}/${blog}/${postId}/edit`)
}}
>
Edit Post
</Button>
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
<CardHeader
onClick={() => {
navigate(`/${user}/${blog}`)
}}
sx={{
cursor: 'pointer',
'& .MuiCardHeader-content': {
overflow: 'hidden'
},
padding: '10px 0px'
}}
avatar={<Avatar src={avatarUrl} alt={`${user}'s avatar`} />}
subheader={
<Typography
sx={{ fontFamily: 'Cairo', fontSize: '25px' }}
color={theme.palette.text.primary}
>{` ${user}`}</Typography>
}
/>
{user && (
<Tipping
name={user || ''}
onSubmit={() => {
// setNameTip('')
}}
onClose={() => {
// setNameTip('')
}}
/>
)}
</Box>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="h1"
color="textPrimary"
sx={{
textAlign: 'center'
}}
>
{blogContent?.title}
</Typography>
<CommentSection postId={fullPostId} />
</Box>
{(layoutGeneralSettings?.blogPostType === 'builder' ||
!layoutGeneralSettings?.blogPostType) && (
<Content
layouts={layouts}
blogContent={blogContent}
onResizeStop={onResizeStop}
onBreakpointChange={onBreakpointChange}
handleLayoutChange={handleLayoutChange}
>
{blogContent?.postContent?.map((section: any) => {
if (section?.type === 'editor') {
return (
<div key={section?.id} className="grid-item-view">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItem
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={layoutGeneralSettings?.padding}
>
<ReadOnlySlate content={section.content} />
</DynamicHeightItem>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'image') {
return (
<div key={section?.id} className="grid-item-view">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItem
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={layoutGeneralSettings?.padding}
>
<img
src={section.content.image}
className="post-image"
/>
</DynamicHeightItem>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'video') {
return (
<div key={section?.id} className="grid-item-view">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItem
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={layoutGeneralSettings?.padding}
>
<VideoPlayer
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
setCount={handleCount}
user={user}
postId={fullPostId}
/>
</DynamicHeightItem>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'audio') {
return (
<div key={section?.id} className="grid-item-view">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItem
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={layoutGeneralSettings?.padding}
>
<AudioElement
key={section.id}
audioInfo={section.content}
postId={fullPostId}
user={user ? user : ''}
onClick={() => {
if (!blog || !postId) return
const formBlogId = addPrefix(blog)
const formPostId =
buildIdentifierFromCreateTitleIdAndId(
formBlogId,
postId
)
if (audioPostId && formPostId !== audioPostId) {
tempSaveAudio.current = {
...(tempSaveAudio.current || {}),
currentSelection: section,
message:
'You are current on a playlist. Would you like to switch?'
}
setisOpenSwitchPlaylistModal(true)
} else {
if (!audios && saveAudio?.current) {
const findIndex = (
saveAudio?.current?.audios || []
).findIndex(
(item: any) =>
item.identifier ===
section.content.identifier
)
dispatch(setAudio(saveAudio?.current))
dispatch(setCurrAudio(findIndex))
return
}
const findIndex = (audios || []).findIndex(
(item) =>
item.identifier === section.content.identifier
)
if (findIndex >= 0) {
dispatch(setCurrAudio(findIndex))
}
}
}}
title={section.content?.title}
description={section.content?.description}
author=""
/>
</DynamicHeightItem>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'file') {
return (
<div key={section?.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<FileElement
key={section.id}
fileInfo={section.content}
postId={fullPostId}
user={user ? user : ''}
title={section.content?.title}
description={section.content?.description}
mimeType={section.content?.mimeType}
author=""
/>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
})}
</Content>
)}
{layoutGeneralSettings?.blogPostType === 'minimal' && (
<>
{layouts?.rows?.map((row: any, rowIndex: number) => {
return (
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: '25px',
gap: 2
}}
>
{row?.ids?.map((elementId: string) => {
const section: any = blogContent?.postContent?.find(
(el) => el?.id === elementId
)
if (!section) return null
if (section?.type === 'editor') {
return (
<div
key={section?.id}
className="grid-item"
style={{
maxWidth: '800px',
display: 'flex',
flexDirection: 'column',
width: '100%'
}}
>
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<ReadOnlySlate
key={section.id}
content={section.content}
/>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'image') {
return (
<div key={section.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
type="image"
padding={0}
>
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%'
}}
>
<img
src={section.content.image}
className="post-image"
style={{
objectFit: 'contain',
maxHeight: '50vh'
}}
/>
</Box>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'video') {
return (
<div key={section?.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%'
}}
>
<VideoPlayer
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
customStyle={{
height: '50vh'
}}
user={user}
postId={fullPostId}
/>
</Box>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'audio') {
return (
<div key={section?.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<AudioElement
key={section.id}
audioInfo={section.content}
postId={fullPostId}
user={user ? user : ''}
onClick={() => {
if (!blog || !postId) return
const formBlogId = addPrefix(blog)
const formPostId =
buildIdentifierFromCreateTitleIdAndId(
formBlogId,
postId
)
if (formPostId !== audioPostId) {
tempSaveAudio.current = {
...(tempSaveAudio.current || {}),
currentSelection: section,
message:
'You are current on a playlist. Would you like to switch?'
}
setisOpenSwitchPlaylistModal(true)
} else {
const findIndex = (audios || []).findIndex(
(item) =>
item.identifier ===
section.content.identifier
)
if (findIndex >= 0) {
dispatch(setCurrAudio(findIndex))
}
}
}}
title={section.content?.title}
description={section.content?.description}
author=""
/>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'file') {
return (
<div key={section?.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<FileElement
key={section.id}
fileInfo={section.content}
postId={fullPostId}
user={user ? user : ''}
title={section.content?.title}
description={section.content?.description}
mimeType={section.content?.mimeType}
author=""
/>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
})}
</Box>
)
})}
</>
)}
<ReusableModal open={isOpenSwitchPlaylistModal}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
<Typography>
{tempSaveAudio?.current?.message
? tempSaveAudio?.current?.message
: 'You are current on a playlist. Would you like to switch?'}
</Typography>
</Box>
<Button
variant="contained"
onClick={() => setisOpenSwitchPlaylistModal(false)}
>
Cancel
</Button>
<Button variant="contained" onClick={switchPlayList}>
Switch
</Button>
</ReusableModal>
</Box>
</Box>
)
}
const Content = ({
children,
layouts,
blogContent,
onResizeStop,
onBreakpointChange,
handleLayoutChange
}: any) => {
if (layouts && blogContent?.layouts) {
return (
<ErrorBoundary
fallback={
<Typography>Error loading content: Invalid Layout</Typography>
}
>
<ResponsiveGridLayout
layouts={layouts}
breakpoints={{ md: 996, sm: 768, xs: 480 }}
cols={{ md: 4, sm: 3, xs: 1 }}
measureBeforeMount={false}
onLayoutChange={handleLayoutChange}
autoSize={true}
compactType={null}
isBounded={true}
resizeHandles={['se', 'sw', 'ne', 'nw']}
rowHeight={25}
onResizeStop={onResizeStop}
onBreakpointChange={onBreakpointChange}
isDraggable={false}
isResizable={false}
margin={[0, 0]}
>
{children}
</ResponsiveGridLayout>
</ErrorBoundary>
)
}
return children
}

298
src/pages/BlogIndividualProfile/BlogIndividualProfile.tsx

@ -0,0 +1,298 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { useParams } from 'react-router-dom'
import { Typography, Box, Button, useTheme } from '@mui/material'
import EditIcon from '@mui/icons-material/Edit'
import BlogPostPreview from '../BlogList/PostPreview'
import {
setIsLoadingGlobal,
setVisitingBlog,
toggleEditBlogModal
} from '../../state/features/globalSlice'
import {
addSubscription,
BlogPost,
removeSubscription
} from '../../state/features/blogSlice'
import { useFetchPosts } from '../../hooks/useFetchPosts'
import LazyLoad from '../../components/common/LazyLoad'
import { addPrefix, removePrefix } from '../../utils/blogIdformats'
import Masonry from 'react-masonry-css'
const breakpointColumnsObj = {
default: 5,
1600: 4,
1300: 3,
940: 2,
700: 1,
500: 1
}
export const BlogIndividualProfile = () => {
const navigate = useNavigate()
const theme = useTheme()
const { user } = useSelector((state: RootState) => state.auth)
const { currentBlog } = useSelector((state: RootState) => state.global)
const subscriptions = useSelector(
(state: RootState) => state.blog.subscriptions
)
const { blog: blogShortVersion, user: username } = useParams()
const blog = React.useMemo(() => {
if (!blogShortVersion) return ''
return addPrefix(blogShortVersion)
}, [blogShortVersion])
const dispatch = useDispatch()
const [userBlog, setUserBlog] = React.useState<any>(null)
const { checkAndUpdatePost, getBlogPost, hashMapPosts } = useFetchPosts()
const [blogPosts, setBlogPosts] = React.useState<BlogPost[]>([])
const getBlogPosts = React.useCallback(async () => {
let name = username
if (!name) return
if (!blog) return
try {
dispatch(setIsLoadingGlobal(true))
const offset = blogPosts.length
//TODO - NAME SHOULD BE EXACT
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=${blog}-post-&limit=20&exactmatchnames=true&name=${name}&includemetadata=true&offset=${offset}&reverse=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: '',
user: post.name,
postImage: '',
id: post.identifier
}
})
setBlogPosts(structureData)
const copiedBlogPosts: BlogPost[] = [...blogPosts]
structureData.forEach((post: BlogPost) => {
const index = blogPosts.findIndex((p) => p.id === post.id)
if (index !== -1) {
copiedBlogPosts[index] = post
} else {
copiedBlogPosts.push(post)
}
})
setBlogPosts(copiedBlogPosts)
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdatePost(content)
if (res) {
getBlogPost(content.user, content.id, content)
}
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [username, blog, blogPosts])
const getBlog = React.useCallback(async () => {
let name = username
if (!name) return
if (!blog) return
try {
const urlBlog = `/arbitrary/BLOG/${name}/${blog}`
const response = await fetch(urlBlog, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
dispatch(setVisitingBlog({ ...responseData, name }))
setUserBlog(responseData)
} catch (error) {}
}, [username, blog])
React.useEffect(() => {
getBlog()
}, [username, blog])
const getPosts = React.useCallback(async () => {
await getBlogPosts()
}, [getBlogPosts])
const subscribe = async () => {
try {
if (!user?.name) return
const body = {
items: [username]
}
const listName = `q-blog-subscriptions-${user.name}`
const response = await qortalRequest({
action: 'ADD_LIST_ITEMS',
list_name: listName,
items: [username]
})
if (response === true) {
dispatch(addSubscription(username))
}
} catch (error) {}
}
const unsubscribe = async () => {
try {
if (!user?.name) return
const listName = `q-blog-subscriptions-${user.name}`
const response = await qortalRequest({
action: 'DELETE_LIST_ITEM',
list_name: listName,
item: username
})
if (response === true) {
dispatch(removeSubscription(username))
}
} catch (error) {}
}
if (!userBlog) return null
return (
<>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="h1"
color="textPrimary"
sx={{
textAlign: 'center',
marginTop: '20px'
}}
>
{currentBlog?.blogId === blog ? currentBlog?.title : userBlog.title}
</Typography>
{currentBlog?.blogId === blog && (
<EditIcon
sx={{
cursor: 'pointer'
}}
onClick={() => {
dispatch(toggleEditBlogModal(true))
}}
></EditIcon>
)}
{subscriptions.includes(username) && (
<Button
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
fontFamily: 'Arial'
}}
onClick={unsubscribe}
>
Unsubscribe
</Button>
)}
{!subscriptions.includes(username) && (
<Button
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
fontFamily: 'Arial'
}}
onClick={subscribe}
>
Subscribe
</Button>
)}
</Box>
<Masonry
breakpointCols={breakpointColumnsObj}
className="my-masonry-grid"
columnClassName="my-masonry-grid_column"
style={{ backgroundColor: theme.palette.background.default }}
>
{blogPosts.map((post, index) => {
const existingPost = hashMapPosts[post.id]
let blogPost = post
if (existingPost) {
blogPost = existingPost
}
return (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
width: 'auto',
position: 'relative',
' @media (max-width: 450px)': {
width: '100%'
}
}}
>
<BlogPostPreview
onClick={() => {
const str = blogPost.id
const arr = str.split('-post-')
const str1 = arr[0]
const blogId = removePrefix(str1)
const str2 = arr[1]
navigate(`/${blogPost.user}/${blogId}/${str2}`)
}}
description={blogPost?.description}
title={blogPost?.title}
createdAt={blogPost?.createdAt}
author={blogPost.user}
postImage={blogPost?.postImage}
blogPost={blogPost}
/>
{blogPost.user === user?.name && (
<EditIcon
className="edit-btn"
sx={{
position: 'absolute',
zIndex: 10,
bottom: '25px',
right: '25px',
cursor: 'pointer'
}}
onClick={() => {
const str = blogPost.id
const arr = str.split('-post-')
const str1 = arr[0]
const str2 = arr[1]
const blogId = removePrefix(str1)
navigate(`/${blogPost.user}/${blogId}/${str2}/edit`)
}}
/>
)}
</Box>
)
})}
</Masonry>
<LazyLoad onLoadMore={getPosts}></LazyLoad>
</>
)
}

230
src/pages/BlogList/BlogList.tsx

@ -0,0 +1,230 @@
import React, { FC, useCallback, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import EditIcon from '@mui/icons-material/Edit'
import {
Box,
Button,
List,
ListItem,
Typography,
useTheme
} from '@mui/material'
import BlogPostPreview from './PostPreview'
import { useFetchPosts } from '../../hooks/useFetchPosts'
import LazyLoad from '../../components/common/LazyLoad'
import { removePrefix } from '../../utils/blogIdformats'
import Masonry from 'react-masonry-css'
const breakpointColumnsObj = {
default: 5,
1600: 4,
1300: 3,
940: 2,
700: 1,
500: 1
}
interface BlogListProps {
mode?: string
}
export const BlogList = ({ mode }: BlogListProps) => {
const theme = useTheme()
const prevVal = useRef('')
const { user } = useSelector((state: RootState) => state.auth)
const hashMapPosts = useSelector(
(state: RootState) => state.blog.hashMapPosts
)
const favoritesLocal = useSelector(
(state: RootState) => state.blog.favoritesLocal
)
const subscriptionPosts = useSelector(
(state: RootState) => state.blog.subscriptionPosts
)
const countNewPosts = useSelector(
(state: RootState) => state.blog.countNewPosts
)
const isFiltering = useSelector((state: RootState) => state.blog.isFiltering)
const filterValue = useSelector((state: RootState) => state.blog.filterValue)
const filteredPosts = useSelector(
(state: RootState) => state.blog.filteredPosts
)
const { posts: globalPosts, favorites } = useSelector(
(state: RootState) => state.blog
)
const navigate = useNavigate()
const {
getBlogPosts,
getBlogPostsFavorites,
getBlogPostsSubscriptions,
checkNewMessages,
getNewPosts,
getBlogFilteredPosts
} = useFetchPosts()
const getPosts = React.useCallback(async () => {
if (isFiltering) {
getBlogFilteredPosts(filterValue)
return
}
if (mode === 'favorites') {
getBlogPostsFavorites()
return
}
if (mode === 'subscriptions' && user?.name) {
getBlogPostsSubscriptions(user.name)
return
}
await getBlogPosts()
}, [getBlogPosts, mode, favoritesLocal, user?.name, isFiltering, filterValue])
let posts = globalPosts
if (mode === 'favorites') {
posts = favorites
}
if (mode === 'subscriptions') {
posts = subscriptionPosts
}
if (isFiltering) {
posts = filteredPosts
}
const interval = useRef<any>(null)
const checkNewMessagesFunc = useCallback(() => {
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling) return
isCalling = true
const res = await checkNewMessages()
isCalling = false
}, 30000) // 1 second interval
}, [checkNewMessages])
useEffect(() => {
if (!mode) {
checkNewMessagesFunc()
}
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [mode, checkNewMessagesFunc])
useEffect(() => {
if (isFiltering && filterValue !== prevVal?.current) {
prevVal.current = filterValue
getPosts()
}
}, [filterValue, isFiltering, filteredPosts])
// if (!favoritesLocal) return null
return (
<>
{/* <List
sx={{
margin: '0px',
padding: '10px',
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center'
}}
> */}
{!mode && countNewPosts > 0 && !isFiltering && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography>
{countNewPosts === 1
? `There is ${countNewPosts} new post`
: `There are ${countNewPosts} new posts`}
</Typography>
<Button
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
fontFamily: 'Arial'
}}
onClick={getNewPosts}
>
Load new Posts
</Button>
</Box>
)}
<Masonry
breakpointCols={breakpointColumnsObj}
className="my-masonry-grid"
columnClassName="my-masonry-grid_column"
>
{posts.map((post, index) => {
const existingPost = hashMapPosts[post.id]
let blogPost = post
if (existingPost) {
blogPost = existingPost
}
return (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
width: 'auto',
position: 'relative',
' @media (max-width: 450px)': {
width: '100%'
}
}}
key={blogPost.id}
>
<BlogPostPreview
onClick={() => {
const str = blogPost.id
const arr = str.split('-post-')
const str1 = arr[0]
const str2 = arr[1]
const blogId = removePrefix(str1)
navigate(`/${blogPost.user}/${blogId}/${str2}`)
}}
description={blogPost?.description}
title={blogPost?.title}
createdAt={blogPost?.createdAt}
author={blogPost.user}
postImage={blogPost?.postImage}
blogPost={blogPost}
isValid={blogPost?.isValid}
/>
{blogPost.user === user?.name && (
<EditIcon
className="edit-btn"
sx={{
position: 'absolute',
zIndex: 10,
bottom: '25px',
right: '25px',
cursor: 'pointer'
}}
onClick={() => {
const str = blogPost.id
const arr = str.split('-post-')
const str1 = arr[0]
const str2 = arr[1]
const blogId = removePrefix(str1)
navigate(`/${blogPost.user}/${blogId}/${str2}/edit`)
}}
/>
)}
</Box>
)
})}
</Masonry>
{/* </List> */}
<LazyLoad onLoadMore={getPosts}></LazyLoad>
</>
)
}

134
src/pages/BlogList/PostPreview-styles.ts

@ -0,0 +1,134 @@
import { styled } from "@mui/system";
import { Card, Box, Typography } 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'
? theme.palette.primary.dark
: theme.palette.primary.light,
margin: '0px',
borderRadius: '15px',
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: '5px',
padding: '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 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)({
fontFamily: 'Raleway, sans-serif',
fontSize: '16px',
lineHeight: '1.2'
})
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)",
}
})

304
src/pages/BlogList/PostPreview.tsx

@ -0,0 +1,304 @@
import React, { useMemo, useState } from 'react'
import {
Avatar,
Card,
CardContent,
CardHeader,
CardMedia,
Typography,
Box,
Button,
Tooltip,
useTheme
} from '@mui/material'
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 { styled } from '@mui/system'
import {
CardContentContainer,
StyledCard,
StyledCardContent,
TitleText,
AuthorText,
StyledCardHeader,
StyledCardCol,
IconsBox,
BlockIconContainer,
BookmarkIconContainer
} from './PostPreview-styles'
import moment from 'moment'
import {
blockUser,
BlogPost,
removeFavorites,
removeSubscription,
upsertFavorites
} from '../../state/features/blogSlice'
import { useDispatch, useSelector } from 'react-redux'
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'
import BookmarkIcon from '@mui/icons-material/Bookmark'
import { AppDispatch, RootState } from '../../state/store'
import BlockIcon from '@mui/icons-material/Block'
import { CustomIcon } from '../../components/common/CustomIcon'
interface BlogPostPreviewProps {
title: string
createdAt: number | string
author: string
postImage?: string
description: any
blogPost: BlogPost
onClick?: () => void
isValid?: boolean
}
const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
title,
createdAt,
author,
postImage,
description,
onClick,
blogPost,
isValid
}) => {
const [avatarUrl, setAvatarUrl] = React.useState<string>('')
const [showIcons, setShowIcons] = React.useState<boolean>(false)
const dispatch = useDispatch<AppDispatch>()
const theme = useTheme()
const favoritesLocal = useSelector(
(state: RootState) => state.blog.favoritesLocal
)
const [isOpenAlert, setIsOpenAlert] = useState<boolean>(false)
const subscriptions = useSelector(
(state: RootState) => state.blog.subscriptions
)
const username = useSelector((state: RootState) => state.auth?.user?.name)
const formatDate = (unixTimestamp: number): string => {
const date = moment(unixTimestamp, 'x').fromNow()
return date
}
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
}
const getAvatar = React.useCallback(async () => {
try {
let url = await qortalRequest({
action: 'GET_QDN_RESOURCE_URL',
name: author,
service: 'THUMBNAIL',
identifier: 'qortal_avatar'
})
setAvatarUrl(url)
} catch (error) {}
}, [author])
React.useEffect(() => {
getAvatar()
}, [])
const isFavorite = useMemo(() => {
if (!favoritesLocal) return false
return favoritesLocal.find((fav) => fav?.id === blogPost?.id)
}, [favoritesLocal, blogPost?.id])
const blockUserFunc = async (user: string) => {
if (user === 'Q-Blog') return
if (subscriptions.includes(user) && username) {
try {
const listName = `q-blog-subscriptions-${username}`
const response = await qortalRequest({
action: 'DELETE_LIST_ITEM',
list_name: listName,
item: user
})
if (response === true) {
dispatch(removeSubscription(user))
}
} catch (error) {}
}
try {
const response = await qortalRequest({
action: 'ADD_LIST_ITEMS',
list_name: 'blockedNames_q-blog',
items: [user]
})
if (response === true) {
dispatch(blockUser(user))
dispatch(removeFavorites(blogPost.id))
}
} catch (error) {}
}
const continueToPost = () => {
if (isValid === false) {
setIsOpenAlert(true)
return
}
if (!onClick) return
onClick()
}
const handleClose = () => {
setIsOpenAlert(false)
}
return (
<>
<StyledCard
onClick={continueToPost}
onMouseEnter={() => setShowIcons(true)}
onMouseLeave={() => setShowIcons(false)}
>
{postImage && (
<Box sx={{ padding: '2px' }}>
<img
src={postImage}
style={{
width: '100%',
height: 'auto',
borderRadius: '8px'
}}
/>
</Box>
)}
<CardContentContainer>
<StyledCardHeader
sx={{
'& .MuiCardHeader-content': {
overflow: 'hidden'
}
}}
>
<Box>
<Avatar src={avatarUrl} alt={`${author}'s avatar`} />
</Box>
<StyledCardCol>
<TitleText
color={theme.palette.text.primary}
noWrap
variant="body1"
>
{title}
</TitleText>
<AuthorText
color={
theme.palette.mode === 'light'
? theme.palette.text.secondary
: '#d6e8ff'
}
>
{author}
</AuthorText>
</StyledCardCol>
</StyledCardHeader>
<StyledCardContent>
<Typography variant="body2" color={theme.palette.text.primary}>
{description}
</Typography>
<Box sx={{ textAlign: 'flex-start', width: '100%' }}>
<Typography variant="h6" color={theme.palette.text.primary}>
{formatDate(+createdAt)}
</Typography>
</Box>
</StyledCardContent>
</CardContentContainer>
</StyledCard>
<IconsBox
sx={{ opacity: showIcons ? 1 : 0 }}
onMouseEnter={() => setShowIcons(true)}
onMouseLeave={() => setShowIcons(false)}
>
{username && isFavorite && (
<Tooltip title="Remove from favorites" placement="top">
<BookmarkIconContainer
onMouseEnter={() => setShowIcons(true)}
onMouseLeave={() => setShowIcons(false)}
>
<BookmarkIcon
sx={{
color: 'red'
}}
onClick={() => {
dispatch(removeFavorites(blogPost.id))
}}
/>
</BookmarkIconContainer>
</Tooltip>
)}
{username && !isFavorite && (
<Tooltip title="Save to favorites" placement="top">
<BookmarkIconContainer
onMouseEnter={() => setShowIcons(true)}
onMouseLeave={() => setShowIcons(false)}
>
<BookmarkBorderIcon
onClick={() => {
dispatch(upsertFavorites([blogPost]))
}}
/>
</BookmarkIconContainer>
</Tooltip>
)}
<Tooltip title="Block user content" placement="top">
<BlockIconContainer
onMouseEnter={() => setShowIcons(true)}
onMouseLeave={() => setShowIcons(false)}
>
<BlockIcon
onClick={() => {
blockUserFunc(blogPost.user)
}}
/>
</BlockIconContainer>
</Tooltip>
</IconsBox>
<Dialog
open={isOpenAlert}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
Invalid Content Structure
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
This post seems to contain an invalid content structure. Click
continue to proceed
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>
<Button onClick={onClick} autoFocus>
Continue
</Button>
</DialogActions>
</Dialog>
</>
)
}
export default BlogPostPreview

7
src/pages/CreateEditProfile/CreatEditProfile.tsx

@ -0,0 +1,7 @@
import React from 'react'
export const CreatEditProfile = () => {
return (
<div>CreatEditProfile</div>
)
}

14
src/pages/CreatePost/CreatePost-styles.ts

@ -0,0 +1,14 @@
import { styled } from '@mui/system'
import { Button } from '@mui/material'
export const BuilderButton = styled(Button)(({ theme }) => ({
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
fontFamily: 'Arial',
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
filter: "brightness(0.9)"
}
}));

194
src/pages/CreatePost/CreatePost.tsx

@ -0,0 +1,194 @@
import { Box, Button, Typography } from '@mui/material'
import React, { useMemo, useState } from 'react'
import { ReusableModal } from '../../components/modals/ReusableModal'
import { CreatePostBuilder } from './CreatePostBuilder'
import { CreatePostMinimal } from './CreatePostMinimal'
import HandymanRoundedIcon from '@mui/icons-material/HandymanRounded'
import HourglassFullRoundedIcon from '@mui/icons-material/HourglassFullRounded'
import { display } from '@mui/system'
import { useDispatch, useSelector } from 'react-redux'
import { setIsLoadingGlobal } from '../../state/features/globalSlice'
import { useParams } from 'react-router-dom'
import { checkStructure } from '../../utils/checkStructure'
import { RootState } from '../../state/store'
import {
addPrefix,
buildIdentifierFromCreateTitleIdAndId
} from '../../utils/blogIdformats'
import { Tipping } from '../../components/common/Tipping/Tipping'
type EditorType = 'minimal' | 'builder'
interface CreatePostProps {
mode?: string
}
export const CreatePost = ({ mode }: CreatePostProps) => {
const { user: username, postId, blog } = useParams()
const fullPostId = useMemo(() => {
if (!blog || !postId || mode !== 'edit') return ''
const formBlogId = addPrefix(blog)
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId)
return formPostId
}, [blog, postId, mode])
const { user } = useSelector((state: RootState) => state.auth)
const [toggleEditorType, setToggleEditorType] = useState<EditorType | null>(
null
)
const [blogContentForEdit, setBlogContentForEdit] = useState<any>(null)
const [blogMetadataForEdit, setBlogMetadataForEdit] = useState<any>(null)
const [editType, setEditType] = useState<EditorType | null>(null)
const [isOpen, setIsOpen] = useState<boolean>(false)
const dispatch = useDispatch()
React.useEffect(() => {
if (!toggleEditorType && mode !== 'edit') {
setIsOpen(true)
}
}, [setIsOpen, toggleEditorType])
const switchType = () => {
setIsOpen(true)
}
const getBlogPost = React.useCallback(async () => {
try {
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/BLOG_POST/${username}/${fullPostId}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
if (checkStructure(responseData)) {
// setNewPostContent(responseData.postContent)
// setTitle(responseData?.title || '')
// setBlogInfo(responseData)
const blogType = responseData?.layoutGeneralSettings?.blogPostType
if (blogType) {
setEditType(blogType)
setBlogContentForEdit(responseData)
}
//TODO - NAME SHOULD BE EXACT
// const url2 = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${fullPostId}&exactMatchNames=${username}&limit=1&includemetadata=true`
const url2 = `/arbitrary/resources?service=BLOG_POST&identifier=${fullPostId}&name=${username}&limit=1&includemetadata=true`
const responseBlogs = await fetch(url2, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const dataMetadata = await responseBlogs.json()
if (dataMetadata && dataMetadata.length > 0) {
setBlogMetadataForEdit(dataMetadata[0])
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [username, fullPostId])
React.useEffect(() => {
if (mode === 'edit') {
getBlogPost()
}
}, [mode])
return (
<>
{/* {toggleEditorType === 'minimal' && (
<Button onClick={() => switchType()}>Switch to Builder</Button>
)}
{toggleEditorType === 'builder' && (
<Button onClick={() => switchType()}>Switch to Minimal</Button>
)} */}
{isOpen && (
<ReusableModal
open={isOpen}
customStyles={{
maxWidth: '500px'
}}
>
{toggleEditorType && (
<Typography>
Switching editor type will delete your current progress
</Typography>
)}
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 2
}}
>
<Box
onClick={() => {
setToggleEditorType('minimal')
setIsOpen(false)
}}
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: '20px',
borderRadius: '6px',
border: '1px solid',
cursor: 'pointer'
}}
>
<Typography>Minimal Editor</Typography>
<HourglassFullRoundedIcon />
</Box>
<Box
onClick={() => {
setToggleEditorType('builder')
setIsOpen(false)
}}
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: '20px',
borderRadius: '6px',
border: '1px solid',
cursor: 'pointer'
}}
>
<Typography>Builder Editor</Typography>
<HandymanRoundedIcon />
</Box>
</Box>
<Button onClick={() => setIsOpen(false)}>Close</Button>
</ReusableModal>
)}
{toggleEditorType === 'minimal' && (
<CreatePostMinimal switchType={switchType} />
)}
{toggleEditorType === 'builder' && (
<CreatePostBuilder switchType={switchType} />
)}
{mode === 'edit' && editType === 'minimal' && (
<CreatePostMinimal
blogContentForEdit={blogContentForEdit}
postIdForEdit={fullPostId}
blogMetadataForEdit={blogMetadataForEdit}
/>
)}
{mode === 'edit' && editType === 'builder' && (
<CreatePostBuilder
blogContentForEdit={blogContentForEdit}
postIdForEdit={fullPostId}
blogMetadataForEdit={blogMetadataForEdit}
/>
)}
</>
)
}

1408
src/pages/CreatePost/CreatePostBuilder.tsx

File diff suppressed because it is too large Load Diff

1389
src/pages/CreatePost/CreatePostMinimal.tsx

File diff suppressed because it is too large Load Diff

261
src/pages/CreatePost/components/Navbar/NavbarBuilder.tsx

@ -0,0 +1,261 @@
import React, { useCallback, useEffect } from 'react'
import {
Button,
Box,
Typography,
Toolbar,
AppBar,
Select,
InputLabel,
FormControl,
MenuItem,
TextField,
SelectChangeEvent,
OutlinedInput,
List,
ListItem,
useTheme
} from '@mui/material'
import { styled } from '@mui/system'
import { useSelector } from 'react-redux'
import { RootState } from '../../../../state/store'
import ShortUniqueId from 'short-unique-id'
import DeleteIcon from '@mui/icons-material/Delete'
import { CustomIcon } from '../../../../components/common/CustomIcon'
const uid = new ShortUniqueId()
interface INavbar {
saveNav: (navMenu: any, navbarConfig: any) => void
removeNav: () => void
close: () => void
}
export const Navbar = ({ saveNav, removeNav, close }: INavbar) => {
const { user } = useSelector((state: RootState) => state.auth)
const { currentBlog } = useSelector((state: RootState) => state.global)
const theme = useTheme()
const [navTitle, setNavTitle] = React.useState<string>('')
const [blogPostOption, setBlogPostOption] = React.useState<any | null>(null)
const [options, setOptions] = React.useState<any>([])
const [navItems, setNavItems] = React.useState<any>([])
const handleOptionChange = (event: SelectChangeEvent<string>) => {
const optionId = event.target.value
const selectedOption = options.find((option: any) => option.id === optionId)
setBlogPostOption(selectedOption || null)
}
useEffect(() => {
if (currentBlog && currentBlog?.navbarConfig) {
const { navItems } = currentBlog.navbarConfig
if (!navItems || !Array.isArray(navItems)) return
setNavItems(navItems)
}
}, [currentBlog])
const getOptions = useCallback(async () => {
if (!user || !currentBlog) return
const name = user?.name
const blog = currentBlog?.blogId
try {
//TODO - NAME SHOULD BE EXACT
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=${blog}-post-&exactmatchnames=true&name=${name}&includemetadata=true&reverse=true&limit=0`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const formatOptions = responseData.map((option: any) => {
return {
id: option.identifier,
name: option?.metadata.title
}
})
setOptions(formatOptions)
} catch (error) {}
}, [])
useEffect(() => {
getOptions()
}, [getOptions])
const addToNav = () => {
if (!navTitle || !blogPostOption) return
setNavItems((prev: any) => [
...prev,
{
id: uid(),
name: navTitle,
postId: blogPostOption.id,
postName: blogPostOption.name
}
])
}
const handleSaveNav = () => {
if (!currentBlog) return
saveNav(navItems, currentBlog?.navbarConfig || {})
}
return (
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}
>
<Box>
<TextField
label="Nav Item name"
variant="outlined"
fullWidth
value={navTitle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNavTitle(e.target.value)
}
inputProps={{ maxLength: 40 }}
sx={{
marginBottom: 2,
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
border: `1px solid ${theme.palette.text.primary}`
}}
/>
</Box>
<Box>
<FormControl
fullWidth
sx={{
marginBottom: 2,
width: '150px',
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
border: `1px solid ${theme.palette.text.primary}`
}}
>
<InputLabel sx={{ color: theme.palette.text.primary }} id="Post">
Select a Post
</InputLabel>
<Select
labelId="Post"
input={<OutlinedInput label="Select a Post" />}
value={blogPostOption?.id || ''}
onChange={handleOptionChange}
MenuProps={{
sx: {
maxHeight: '300px' // Adjust this value to set the max height,
}
}}
>
{options.map((option: any) => (
<MenuItem
sx={{ color: theme.palette.text.primary }}
key={option.id}
value={option.id}
>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Box>
<Box>
<Button
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
border: `1px solid ${theme.palette.text.primary}`
}}
onClick={addToNav}
>
Add
</Button>
</Box>
</Box>
<Box>
<List
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
flex: '1',
overflow: 'auto'
}}
>
{navItems.map((navItem: any) => (
<ListItem
key={navItem.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: '10px'
}}
>
<Typography
sx={{
fontWeight: 'bold'
}}
>
{navItem.name}
</Typography>{' '}
<Typography>{navItem.postName}</Typography>{' '}
<CustomIcon
component={DeleteIcon}
onClick={() =>
setNavItems((prev: any) =>
prev.filter((item: any) => item.id !== navItem.id)
)
}
/>
</ListItem>
))}
</List>
</Box>
<Button
sx={{
backgroundColor: theme.palette.primary.dark,
color: theme.palette.text.primary,
fontFamily: 'Arial'
}}
onClick={handleSaveNav}
>
Save Navbar
</Button>
<Button
sx={{
backgroundColor: theme.palette.primary.dark,
color: theme.palette.text.primary,
fontFamily: 'Arial'
}}
onClick={removeNav}
>
Remove Navbar
</Button>
<Button
sx={{
backgroundColor: theme.palette.primary.dark,
color: theme.palette.text.primary,
fontFamily: 'Arial'
}}
onClick={close}
>
Close
</Button>
</>
)
}

157
src/pages/CreatePost/components/Toolbar/EditorToolbar.tsx

@ -0,0 +1,157 @@
import React from 'react'
import TextFieldsIcon from '@mui/icons-material/TextFields'
import Slider from '@mui/material/Slider'
import { AudioPanel } from '../../../../components/common/AudioPanel'
import { Box, Toolbar, AppBar, useTheme } from '@mui/material'
import { styled } from '@mui/system'
import ImageUploader from '../../../../components/common/ImageUploader'
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'
import { VideoPanel } from '../../../../components/common/VideoPanel'
import MenuOpenIcon from '@mui/icons-material/MenuOpen'
import HandymanRoundedIcon from '@mui/icons-material/HandymanRounded'
import Tooltip from '@mui/material/Tooltip'
import { FilePanel } from '../../../../components/common/FilePanel'
const CustomToolbar = styled(Toolbar)({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
})
const CustomAppBar = styled(AppBar)(({ theme }) => ({
backgroundColor:
theme.palette.mode === 'light'
? theme.palette.background.default
: '#19191b'
}))
interface IEditorToolbar {
setIsOpenAddTextModal: (val: boolean) => void
addImage: (base64: string) => void
onSelectVideo: (video: any) => void
onSelectAudio: (audio: any) => void
onSelectFile: (file: any) => void
paddingValue: number
onChangePadding: (padding: number) => void
isMinimal?: boolean
addNav?: () => void
switchType?: () => void
}
export const EditorToolbar = ({
setIsOpenAddTextModal,
addImage,
onSelectVideo,
onSelectAudio,
onSelectFile,
paddingValue,
onChangePadding,
isMinimal = false,
addNav,
switchType
}: IEditorToolbar) => {
const theme = useTheme()
return (
<CustomAppBar position="sticky">
<CustomToolbar variant="dense">
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
width: '100%',
flexWrap: 'wrap',
alignItems: 'center'
}}
>
<Box
sx={{
display: 'flex',
gap: '10px'
}}
>
<Tooltip title="Add Text" arrow>
<TextFieldsIcon
onClick={() => setIsOpenAddTextModal(true)}
sx={{
cursor: 'pointer',
width: 'auto',
height: '30px'
}}
/>
</Tooltip>
<ImageUploader onPick={addImage}>
<Tooltip title="Add an image" arrow>
<AddPhotoAlternateIcon
sx={{
cursor: 'pointer',
width: 'auto',
height: '30px'
}}
/>
</Tooltip>
</ImageUploader>
<VideoPanel onSelect={onSelectVideo} />
<AudioPanel onSelect={onSelectAudio} />
<FilePanel onSelect={onSelectFile} />
</Box>
<Box
sx={{
display: 'flex',
gap: '10px'
}}
>
{!isMinimal && (
<Tooltip title="Adjust padding between elements" arrow>
<Box>
<Slider
size="small"
value={paddingValue}
onChange={(event: any) =>
onChangePadding(event.target.value)
}
defaultValue={5}
aria-label="Default"
valueLabelDisplay="auto"
min={0}
max={40}
sx={{
color: theme.palette.text.primary,
width: '100px'
}}
/>
</Box>
</Tooltip>
)}
{!isMinimal && (
<Tooltip title="Manage your custom navbar links" arrow>
<MenuOpenIcon
onClick={addNav}
sx={{
cursor: 'pointer',
width: 'auto',
height: '30px'
}}
/>
</Tooltip>
)}
{switchType && (
<Tooltip title="Switch editor type" arrow>
<HandymanRoundedIcon
onClick={switchType}
sx={{
cursor: 'pointer',
width: 'auto',
height: '30px'
}}
/>
</Tooltip>
)}
</Box>
</Box>
</CustomToolbar>
</CustomAppBar>
)
}

562
src/pages/EditPost/EditPost.tsx

@ -0,0 +1,562 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import BlogEditor from '../../components/editor/BlogEditor'
import ShortUniqueId from 'short-unique-id'
import { Button, TextField } from '@mui/material'
import ReadOnlySlate from '../../components/editor/ReadOnlySlate'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { Box } from '@mui/material'
import ImageUploader from '../../components/common/ImageUploader'
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'
import { checkStructure } from '../../utils/checkStructure'
import { BlogContent } from '../../interfaces/interfaces'
import PostAddIcon from '@mui/icons-material/PostAdd'
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle'
import EditIcon from '@mui/icons-material/Edit'
import { createEditor, Descendant, Editor, Transforms } from 'slate'
import { styled } from '@mui/system'
import { setIsLoadingGlobal } from '../../state/features/globalSlice'
import { extractTextFromSlate } from '../../utils/extractTextFromSlate'
import { VideoContent } from '../../components/common/VideoContent'
import { VideoPanel } from '../../components/common/VideoPanel'
const initialValue: Descendant[] = [
{
type: 'paragraph',
children: [
{ text: "Start writing your blog post... Don't forget to add a title :)" }
]
}
]
const BlogTitleInput = styled(TextField)(({ theme }) => ({
'& .MuiInputBase-input': {
fontSize: '28px',
height: '28px',
'&::placeholder': {
fontSize: '28px',
color: theme.palette.text.secondary
}
},
'& .MuiInputLabel-root': {
fontSize: '28px'
}
}))
interface IaddVideo {
name: string
identifier: string
service: string
title: string
description: string
}
const uid = new ShortUniqueId()
export const EditPost = () => {
const { user: username, postId } = useParams()
const { user } = useSelector((state: RootState) => state.auth)
const [newPostContent, setNewPostContent] = React.useState<any[]>([])
const [blogInfo, setBlogInfo] = React.useState<BlogContent | null>(null)
const [editingSection, setEditingSection] = React.useState<any>(null)
const [value, setValue] = React.useState(initialValue)
const [value2, setValue2] = React.useState(initialValue)
const [title, setTitle] = React.useState('')
const dispatch = useDispatch()
const addPostSection = React.useCallback((content: any) => {
const section = {
type: 'editor',
version: 1,
content,
id: uid()
}
setNewPostContent((prev) => [...prev, section])
}, [])
const editPostSection = React.useCallback(
(content: any, section: any) => {
const findSectionIndex = newPostContent.findIndex(
(s) => s.id === section.id
)
if (findSectionIndex !== -1) {
const copyNewPostContent = [...newPostContent]
copyNewPostContent[findSectionIndex] = {
...section,
content
}
setNewPostContent(copyNewPostContent)
}
setEditingSection(null)
},
[newPostContent]
)
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)
})
}
const addImage = (base64: string) => {
const section = {
type: 'image',
version: 1,
content: {
image: base64,
caption: ''
},
id: uid()
}
setNewPostContent((prev) => [...prev, section])
}
async function getNameInfo(address: string) {
const response = await fetch('/names/address/' + address)
const nameData = await response.json()
if (nameData?.length > 0) {
return nameData[0].name
} else {
return ''
}
}
async function publishQDNResource() {
let address
let name
try {
if (!user || !user.address) return
address = user.address
} catch (error) {}
if (!address) return
try {
name = await getNameInfo(address)
} catch (error) {}
if (!name) return
if (!blogInfo) return
try {
const postObject = {
...blogInfo,
title,
postContent: newPostContent
}
const blogPostToBase64 = await objectToBase64(postObject)
let description = ''
const findText = newPostContent.find((data) => data?.type === 'editor')
if (findText && findText.content) {
description = extractTextFromSlate(findText?.content)
description = description.slice(0, 180)
}
const resourceResponse = await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE',
name: name,
service: 'BLOG_POST',
data64: blogPostToBase64,
title: title,
description: description,
category: 'TECHNOLOGY',
tags: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'],
metaData: 'description=destriptontest&category=catTest',
identifier: postId
})
} catch (error) {
console.error(error)
}
}
const addSection = () => {
addPostSection(value2)
}
const getBlogPost = React.useCallback(async () => {
try {
dispatch(setIsLoadingGlobal(true))
const url = `/arbitrary/BLOG_POST/${username}/${postId}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
if (checkStructure(responseData)) {
setNewPostContent(responseData.postContent)
setTitle(responseData?.title || '')
setBlogInfo(responseData)
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [user, postId])
React.useEffect(() => {
getBlogPost()
}, [])
const editSection = (section: any) => {
setEditingSection(section)
setValue(section.content)
}
const removeSection = (section: any) => {
const newContent = newPostContent.filter((s) => s.id !== section.id)
setNewPostContent(newContent)
}
const editImage = (base64: string, section: any) => {
const newSection = {
...section,
content: {
image: base64,
caption: section.content.caption
}
}
const findSectionIndex = newPostContent.findIndex(
(s) => s.id === section.id
)
if (findSectionIndex !== -1) {
const copyNewPostContent = [...newPostContent]
copyNewPostContent[findSectionIndex] = newSection
setNewPostContent(copyNewPostContent)
}
}
const editVideo = (
{ name, identifier, service, description, title }: IaddVideo,
section: any
) => {
const newSection = {
...section,
content: {
name: name,
identifier: identifier,
service: service,
description,
title
}
}
const findSectionIndex = newPostContent.findIndex(
(s) => s.id === section.id
)
if (findSectionIndex !== -1) {
const copyNewPostContent = [...newPostContent]
copyNewPostContent[findSectionIndex] = newSection
setNewPostContent(copyNewPostContent)
}
}
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column'
}}
>
<Box
sx={{
maxWidth: '700px',
margin: '15px',
width: '100%'
}}
>
<BlogTitleInput
id="modal-title-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
placeholder="Title"
variant="filled"
multiline
maxRows={2}
InputLabelProps={{ shrink: false }}
/>
{newPostContent.map((section: any) => {
if (section.type === 'editor') {
return (
<Box key={section.id}>
{editingSection && editingSection.id === section.id ? (
<BlogEditor
editPostSection={editPostSection}
defaultValue={section.content}
section={section}
value={value}
setValue={setValue}
/>
) : (
<Box
sx={{
position: 'relative'
}}
>
<ReadOnlySlate key={section.id} content={section.content} />
<Box
sx={{
position: 'absolute',
right: '5px',
zIndex: 5,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
// flexDirection: 'column',
gap: 2,
background: 'white',
padding: '5px',
borderRadius: '5px'
}}
>
<RemoveCircleIcon
onClick={() => removeSection(section)}
sx={{
cursor: 'pointer'
}}
/>
<EditIcon
onClick={() => editSection(section)}
sx={{
cursor: 'pointer'
}}
/>
</Box>
</Box>
)}
{editingSection && editingSection.id === section.id ? (
<Box
sx={{
display: 'flex',
width: '100%',
justifyContent: 'flex-end'
}}
>
<Button onClick={() => setEditingSection(null)}>
Close
</Button>
</Box>
) : (
<></>
)}
</Box>
)
}
if (section.type === 'image') {
return (
<Box key={section.id}>
{editingSection && editingSection.id === section.id ? (
<ImageUploader
onPick={(base64) => editImage(base64, section)}
>
Add Image
<AddPhotoAlternateIcon />
</ImageUploader>
) : (
<Box
sx={{
position: 'relative'
}}
>
<img
src={section.content.image}
className="post-image"
style={{
marginTop: '20px'
}}
/>
<Box
sx={{
position: 'absolute',
right: '5px',
zIndex: 5,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
flexDirection: 'column',
gap: 2,
background: 'white',
padding: '5px',
borderRadius: '5px'
}}
>
<RemoveCircleIcon
onClick={() => removeSection(section)}
sx={{
cursor: 'pointer'
}}
/>
<ImageUploader
onPick={(base64) => editImage(base64, section)}
>
<EditIcon
sx={{
cursor: 'pointer'
}}
/>
</ImageUploader>
</Box>
</Box>
)}
{editingSection && editingSection.id === section.id ? (
<Button onClick={() => setEditingSection(null)}>Close</Button>
) : (
<></>
)}
</Box>
)
}
if (section.type === 'video') {
return (
<Box key={section.id}>
{editingSection && editingSection.id === section.id ? (
<VideoPanel
width="24px"
height="24px"
onSelect={(video) =>
editVideo(
{
name: video.name,
identifier: video.identifier,
service: video.service,
title: video?.metadata?.title,
description: video?.metadata?.description
},
section
)
}
/>
) : (
<Box
sx={{
position: 'relative'
}}
>
<VideoContent
title={section.content?.title}
description={section.content?.description}
/>
<Box
sx={{
position: 'absolute',
right: '5px',
zIndex: 5,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
flexDirection: 'column',
gap: 2,
background: 'white',
padding: '5px',
borderRadius: '5px'
}}
>
<RemoveCircleIcon
onClick={() => removeSection(section)}
sx={{
cursor: 'pointer'
}}
/>
<VideoPanel
width="24px"
height="24px"
onSelect={(video) =>
editVideo(
{
name: video.name,
identifier: video.identifier,
service: video.service,
title: video?.metadata?.title,
description: video?.metadata?.description
},
section
)
}
/>
</Box>
</Box>
)}
{editingSection && editingSection.id === section.id ? (
<Button onClick={() => setEditingSection(null)}>Close</Button>
) : (
<></>
)}
</Box>
)
}
})}
<BlogEditor
addPostSection={addPostSection}
value={value2}
setValue={setValue2}
/>
<Box
sx={{
display: 'flex'
}}
>
<PostAddIcon
onClick={addSection}
sx={{
cursor: 'pointer',
width: '50px',
height: '50px'
}}
/>
<ImageUploader onPick={addImage}>
<AddPhotoAlternateIcon
sx={{
cursor: 'pointer',
width: '50px',
height: '50px'
}}
/>
</ImageUploader>
</Box>
</Box>
<Box
sx={{
position: 'fixed',
bottom: '30px',
right: '30px',
zIndex: 15,
background: 'deepskyblue',
padding: '10px',
borderRadius: '5px'
}}
>
<Button onClick={publishQDNResource}>PUBLISH UPDATE</Button>
</Box>
</Box>
)
}

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

Loading…
Cancel
Save