Browse Source

Initial q-blog commit in its own repo

pull/1/head
Justin Ferrari 10 months ago
commit
7391940b13
  1. 24
      .gitignore
  2. 10
      .prettierrc.json
  3. 14
      index.html
  4. 6695
      package-lock.json
  5. 54
      package.json
  6. BIN
      public/favicon.ico
  7. 67
      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/qBlogLogo.png
  14. BIN
      src/assets/img/qort.png
  15. BIN
      src/assets/img/rvn.png
  16. 1
      src/assets/react.svg
  17. 25
      src/assets/svgs/AccountCircleSVG.tsx
  18. 21
      src/assets/svgs/AlignCenterSVG.tsx
  19. 17
      src/assets/svgs/AlignLeftSVG.tsx
  20. 17
      src/assets/svgs/AlignRightSVG.tsx
  21. 17
      src/assets/svgs/BoldSVG.tsx
  22. 17
      src/assets/svgs/CodeBlockSVG.tsx
  23. 17
      src/assets/svgs/H2SVG.tsx
  24. 17
      src/assets/svgs/H3SVG.tsx
  25. 17
      src/assets/svgs/ItalicSVG.tsx
  26. 17
      src/assets/svgs/LinkSVG.tsx
  27. 25
      src/assets/svgs/NewWindowSVG.tsx
  28. 17
      src/assets/svgs/UnderlineSVG.tsx
  29. 1
      src/assets/svgs/accountCircle.svg
  30. 5
      src/assets/svgs/interfaces.ts
  31. 230
      src/components/AudioElement.tsx
  32. 96
      src/components/DynamicHeightItem.tsx
  33. 39
      src/components/DynamicHeightItemMinimal.tsx
  34. 445
      src/components/FileElement.tsx
  35. 253
      src/components/common/AudioPanel.tsx
  36. 192
      src/components/common/AudioPlayer.tsx
  37. 366
      src/components/common/AudioPublishModal.tsx
  38. 28
      src/components/common/BlockedNamesModal/BlockedNamesModal-styles.ts
  39. 100
      src/components/common/BlockedNamesModal/BlockedNamesModal.tsx
  40. 336
      src/components/common/Comments/Comment.tsx
  41. 258
      src/components/common/Comments/CommentEditor.tsx
  42. 386
      src/components/common/Comments/CommentSection.tsx
  43. 82
      src/components/common/ContextMenu/ContextMenuResource.tsx
  44. 16
      src/components/common/CustomIcon.tsx
  45. 289
      src/components/common/DownloadTaskManager.tsx
  46. 55
      src/components/common/DraggableResizableGrid.tsx
  47. 36
      src/components/common/ErrorBoundary.tsx
  48. 257
      src/components/common/FilePanel.tsx
  49. 317
      src/components/common/GenericPublishModal.tsx
  50. 89
      src/components/common/ImageUploader.tsx
  51. 47
      src/components/common/LazyLoad.tsx
  52. 86
      src/components/common/Notification/Notification.tsx
  53. 43
      src/components/common/PageLoader.tsx
  54. 25
      src/components/common/Portal.tsx
  55. 281
      src/components/common/PostPublishModal.tsx
  56. 111
      src/components/common/PublishAudio.tsx
  57. 120
      src/components/common/PublishGeneric.tsx
  58. 112
      src/components/common/PublishVideo.tsx
  59. 124
      src/components/common/ResponsiveImage.tsx
  60. 289
      src/components/common/Tipping/Tipping.tsx
  61. 55
      src/components/common/UserNavbar/UserNavbar-styles.ts
  62. 135
      src/components/common/UserNavbar/UserNavbar.tsx
  63. 51
      src/components/common/VideoContent.tsx
  64. 284
      src/components/common/VideoPanel.tsx
  65. 832
      src/components/common/VideoPlayer.tsx
  66. 287
      src/components/common/VideoPublishModal.tsx
  67. 78
      src/components/editor/BlogEditor.css
  68. 574
      src/components/editor/BlogEditor.tsx
  69. 25
      src/components/editor/ReadOnlySlate.tsx
  70. 47
      src/components/editor/customTypes.ts
  71. 112
      src/components/layout/Navbar/Navbar-styles.ts
  72. 490
      src/components/layout/Navbar/Navbar.tsx
  73. 70
      src/components/modals/ConsentModal.tsx
  74. 247
      src/components/modals/EditBlogModal.tsx
  75. 281
      src/components/modals/PublishBlogModal.tsx
  76. 47
      src/components/modals/ReusableModal.tsx
  77. 3
      src/constants/mail.ts
  78. 61
      src/global.d.ts
  79. 469
      src/hooks/useFetchMail.tsx
  80. 362
      src/hooks/useFetchPosts.tsx
  81. 162
      src/index.css
  82. 9
      src/index.d.ts
  83. 8
      src/interfaces/interfaces.ts
  84. 19
      src/main.tsx
  85. 951
      src/pages/BlogIndividualPost/BlogIndividualPost.tsx
  86. 301
      src/pages/BlogIndividualProfile/BlogIndividualProfile.tsx
  87. 225
      src/pages/BlogList/BlogList.tsx
  88. 134
      src/pages/BlogList/PostPreview-styles.ts
  89. 320
      src/pages/BlogList/PostPreview.tsx
  90. 7
      src/pages/CreateEditProfile/CreatEditProfile.tsx
  91. 14
      src/pages/CreatePost/CreatePost-styles.ts
  92. 194
      src/pages/CreatePost/CreatePost.tsx
  93. 1409
      src/pages/CreatePost/CreatePostBuilder.tsx
  94. 1390
      src/pages/CreatePost/CreatePostMinimal.tsx
  95. 261
      src/pages/CreatePost/components/Navbar/NavbarBuilder.tsx
  96. 157
      src/pages/CreatePost/components/Toolbar/EditorToolbar.tsx
  97. 562
      src/pages/EditPost/EditPost.tsx
  98. 7
      src/pages/Home/Home.tsx
  99. 279
      src/pages/Mail/AliasMail.tsx
  100. 342
      src/pages/Mail/Mail.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
}

14
index.html

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

6695
package-lock.json generated

File diff suppressed because it is too large Load Diff

54
package.json

@ -0,0 +1,54 @@
{
"name": "q-blog",
"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",
"@types/react-grid-layout": "^1.3.2",
"axios": "^1.3.4",
"compressorjs": "^1.2.1",
"localforage": "^1.10.0",
"moment": "^2.29.4",
"philliplm-react-modern-audio-player": "^1.4.6",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.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-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",
"ts-key-enum": "^2.0.12"
},
"devDependencies": {
"@mui/types": "^7.2.3",
"@types/react": "^18.0.28",
"@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react-swc": "^3.2.0",
"prettier": "^2.8.6",
"typescript": "^4.9.3",
"vite": "^4.2.0",
"worker-loader": "^3.0.8"
}
}

BIN
public/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

67
src/App.tsx

@ -0,0 +1,67 @@
// @ts-nocheck
import { Routes, Route } from 'react-router-dom'
import { BlogIndividualPost } from './pages/BlogIndividualPost/BlogIndividualPost'
import { BlogIndividualProfile } from './pages/BlogIndividualProfile/BlogIndividualProfile'
import { BlogList } from './pages/BlogList/BlogList'
import { CreatePost } from './pages/CreatePost/CreatePost'
import { CreatEditProfile } from './pages/CreateEditProfile/CreatEditProfile'
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 { useState } from 'react'
import { Mail } from './pages/Mail/Mail'
function App() {
const themeColor = window._qdnTheme
// const [colorTheme, setColorTheme] = useState('dark')
// const toggleDarkMode = () => {
// setIsDarkMode("dark");
// }
return (
<Provider store={store}>
<ThemeProvider theme={themeColor === 'light' ? lightTheme : darkTheme}>
<Notification />
<DownloadWrapper>
<GlobalWrapper>
<CssBaseline />
<Routes>
<Route
path="/:user/:blog/:postId"
element={<BlogIndividualPost />}
/>
<Route
path="/:user/:blog/:postId/edit"
element={<CreatePost mode="edit" />}
/>
<Route path="/:user/:blog" element={<BlogIndividualProfile />} />
<Route path="/post/new" element={<CreatePost />} />
<Route path="/profile/new" element={<CreatEditProfile />} />
<Route
path="/favorites"
element={<BlogList mode="favorites" />}
/>
<Route
path="/subscriptions"
element={<BlogList mode="subscriptions" />}
/>
<Route path="/mail" element={<Mail />} />
<Route path="/" element={<BlogList />} />
</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/qBlogLogo.png

Binary file not shown.

After

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

230
src/components/AudioElement.tsx

@ -0,0 +1,230 @@
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 reDownload = React.useRef<boolean>(false)
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])
React.useEffect(() => {
if (
resourceStatus?.status === 'DOWNLOADED' &&
reDownload?.current === false
) {
handlePlay()
reDownload.current = true
}
}, [handlePlay, 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 25 seconds</>
</>
) : 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>
)
}

445
src/components/FileElement.tsx

@ -0,0 +1,445 @@
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 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
if (
resourceStatus?.status === 'READY' &&
download?.url &&
download?.blogPost?.filename
) {
if (downloadLoader) return
dispatch(
setNotification({
msg: 'Saving file... please wait',
alertType: 'info'
})
)
try {
const { name, service, identifier } = fileInfo
setDownloadLoader(true)
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)
})
.finally(() => {
setDownloadLoader(false)
})
} 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 {
if (mode === 'mail') {
setDownloadLoader(false)
}
}
return
}
if (!postId) 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) {
dispatch(
setNotification({
msg: error?.message || 'Error with download. Please try again',
alertType: 'error'
})
)
}
}
if (!filename) return
setIsLoading(true)
downloadVideo({
name,
service,
identifier,
blogPost: {
postId,
user,
audioTitle: title,
audioDescription: description,
audioAuthor: author,
filename,
mimeType
}
})
}
React.useEffect(() => {
if (
resourceStatus?.status === 'READY' &&
download?.url &&
download?.blogPost?.filename
) {
setIsLoading(false)
}
}, [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>
)
}

253
src/components/common/AudioPanel.tsx

@ -0,0 +1,253 @@
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 [editVideoIdentifier, setEditVideoIdentifier] = useState<
string | null | undefined
>()
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
// })
const res2 = await fetch(
`/arbitrary/resources?&service=AUDIO&name=${user.name}&includemetadata=true&limit=100&offset=0&reverse=true`
)
const resData = await res2.json()
if (Array.isArray(resData)) {
res = resData
}
} 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>
<Button
size="small"
variant="contained"
onClick={() => {
setEditVideoIdentifier(video.identifier)
setIsOpenVideoModal(true)
}}
>
Edit
</Button>
</ListItem>
))}
</List>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
flex: '0 0 50px'
}}
>
<PublishButton
variant="contained"
onClick={() => {
setEditVideoIdentifier(null)
setIsOpenVideoModal(true)
}}
>
Publish new audio file
</PublishButton>
</Box>
</Panel>
</Drawer>
<AudioModal
onClose={() => {
setIsOpenVideoModal(false)
setEditVideoIdentifier(null)
}}
open={isOpenVideoModal}
onPublish={(value) => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
setIsOpenVideoModal(false)
}}
editVideoIdentifier={editVideoIdentifier}
/>
</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>
)
}

366
src/components/common/AudioPublishModal.tsx

@ -0,0 +1,366 @@
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
editVideoIdentifier?: string | null | undefined
}
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,
editVideoIdentifier
}) => {
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({
editVideoIdentifier,
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>
{editVideoIdentifier && (
<Typography variant="h6">
You are editing: {editVideoIdentifier}
</Typography>
)}
<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>
)
}

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

@ -0,0 +1,336 @@
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'
import { formatDate } from '../../../utils/time'
interface CommentProps {
comment: any
postId: string
postName: string
onSubmit: (obj?: any, isEdit?: boolean) => void
}
export const Comment = ({
comment,
postId,
postName,
onSubmit
}: CommentProps) => {
const [isReplying, setIsReplying] = useState<boolean>(false)
const [isEditing, setIsEditing] = useState<boolean>(false)
const { user } = useSelector((state: RootState) => state.auth)
const [currentEdit, setCurrentEdit] = useState<any>(null)
const theme = useTheme()
const handleSubmit = useCallback((comment: any, isEdit?: boolean) => {
onSubmit(comment, isEdit)
setCurrentEdit(null)
setIsReplying(false)
}, [])
return (
<Box
id={comment?.identifier}
sx={{
display: 'flex',
width: '100%',
flexDirection: 'column'
}}
>
{currentEdit && (
<Portal>
<Dialog
open={!!currentEdit}
onClose={() => setCurrentEdit(null)}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title"></DialogTitle>
<DialogContent>
<Box
sx={{
width: '300px',
display: 'flex',
justifyContent: 'center'
}}
>
<CommentEditor
onSubmit={(obj) => handleSubmit(obj, true)}
postId={postId}
postName={postName}
isEdit
commentId={currentEdit?.identifier}
commentMessage={currentEdit?.message}
/>
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setCurrentEdit(null)}>
Close
</Button>
</DialogActions>
</Dialog>
</Portal>
)}
<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: 'space-between'
}}
>
{comment?.created && (
<Typography
variant="h6"
sx={{
fontSize: '12px',
marginLeft: '5px'
}}
color={theme.palette.text.primary}
>
{formatDate(+comment?.created)}
</Typography>
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '5px'
}}
>
<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>
</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}
postName={postName}
isReply
commentId={comment.identifier}
/>
)}
</Box>
</Box>
)
}
const CommentCard = ({
message,
created,
name,
replies,
children,
setCurrentEdit
}: any) => {
const [avatarUrl, setAvatarUrl] = React.useState<string>('')
const { user } = useSelector((state: RootState) => state.auth)
const theme = useTheme()
const getAvatar = React.useCallback(async (author: string) => {
try {
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',
wordBreak: 'break-word'
}}
>
{message}
</Typography>
</StyledCardContentComment>
<Box
sx={{
paddingLeft: '15px',
display: 'flex',
flexDirection: 'column'
}}
>
{replies?.map((reply: any) => {
return (
<Box
key={reply?.identifier}
id={reply?.identifier}
sx={{
display: 'flex',
border: '1px solid grey',
borderRadius: '10px',
marginTop: '8px'
}}
>
<CommentCard
name={reply?.name}
message={reply?.message}
setCurrentEdit={setCurrentEdit}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '5px',
justifyContent: 'space-between'
}}
>
{reply?.created && (
<Typography
variant="h6"
sx={{
fontSize: '12px',
marginLeft: '5px'
}}
color={theme.palette.text.primary}
>
{formatDate(+reply?.created)}
</Typography>
)}
{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>
) : (
<Box />
)}
</Box>
</CommentCard>
{/* <Typography variant="body2"> {reply?.message}</Typography> */}
</Box>
)
})}
</Box>
{children}
</CardContentContainerComment>
)
}

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

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

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

@ -0,0 +1,386 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CommentEditor, addItem, updateItemDate } 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'
import { useNavigate, useLocation } from 'react-router-dom'
interface CommentSectionProps {
postId: string
postName: string
}
const Panel = styled('div')`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
padding-bottom: 10px;
height: 100%;
overflow: hidden;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #555;
}
`
export const CommentSection = ({ postId, postName }: CommentSectionProps) => {
const navigate = useNavigate()
const location = useLocation()
const [listComments, setListComments] = useState<any[]>([])
const [isOpen, setIsOpen] = useState<boolean>(false)
const { user } = useSelector((state: RootState) => state.auth)
const [newMessages, setNewMessages] = useState(0)
const notifications = useSelector(
(state: RootState) => state.global.notifications
)
const notificationCreatorComment = useSelector(
(state: RootState) => state.global.notificationCreatorComment
)
const fullNotifications = useMemo(() => {
return [...notificationCreatorComment, ...notifications].sort(
(a, b) => b.created - a.created
)
}, [notificationCreatorComment, notifications])
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
}
])
}
useEffect(() => {
const query = new URLSearchParams(location.search)
let commentVar = query?.get('comment')
if (commentVar) {
if (commentVar && commentVar.endsWith('/')) {
commentVar = commentVar.slice(0, -1)
}
setIsOpen(true)
if (listComments.length > 0) {
const el = document.getElementById(commentVar)
if (el) {
el.scrollIntoView()
el.classList.add('glow')
setTimeout(() => {
el.classList.remove('glow')
}, 2000)
}
navigate(location.pathname, { replace: true })
}
}
}, [navigate, location, listComments])
const 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=false&offset=${offset}&reverse=false&excludeblocked=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
let comments: any[] = []
for (const comment of responseData) {
if (comment.identifier && comment.name) {
const url = `/arbitrary/BLOG_COMMENT/${comment.name}/${comment.identifier}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData2 = await response.text()
if (responseData) {
comments.push({
message: responseData2,
...comment
})
}
}
}
if (isNewMessages) {
setListComments((prev) => [...prev, ...comments])
setNewMessages(0)
} else {
setListComments(comments)
}
try {
} catch (error) {}
},
[postId]
)
const checkAndUpdateNotification = async () => {
const filteredNotifications = fullNotifications.filter(
(notification) =>
postId.includes(notification?.partialPostId) ||
notification?.postId === postId
)
filteredNotifications.forEach((notification) => {
if (postId) {
updateItemDate({
id: notification?.identifier,
lastSeen: Date.now(),
postId
})
}
})
}
useEffect(() => {
if (fullNotifications && isOpen) {
checkAndUpdateNotification()
}
}, [fullNotifications, isOpen])
useEffect(() => {
getComments()
}, [getComments, postId])
const structuredCommentList = useMemo(() => {
return listComments.reduce((acc, curr, index, array) => {
if (curr?.identifier?.includes('_reply_')) {
return acc
}
acc.push({
...curr,
replies: array.filter((comment) =>
comment.identifier.includes(`_reply_${curr.identifier.slice(-6)}`)
)
})
return acc
}, [])
}, [listComments])
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=false&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={() => {
// addItem({
// id: notification.identifier,
// lastSeen: Date.now(),
// postId
// })
updateItemDate({
id: '',
lastSeen: Date.now(),
postId
})
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}
postName={postName}
/>
)
})}
</Box>
</Box>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
flex: '0 0 100px'
}}
>
<CommentEditor
onSubmit={onSubmit}
postId={postId}
postName={postName}
/>
</Box>
</Panel>
</Drawer>
</>
)
}

82
src/components/common/ContextMenu/ContextMenuResource.tsx

@ -0,0 +1,82 @@
import * as React from 'react'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import Typography from '@mui/material/Typography'
import { CopyToClipboard } from 'react-copy-to-clipboard'
import { useDispatch } from 'react-redux'
import { setNotification } from '../../../state/features/notificationsSlice'
import { Box } from '@mui/material'
export default function ContextMenuResource({
children,
name,
service,
identifier,
link
}: any) {
const [contextMenu, setContextMenu] = React.useState<{
mouseX: number
mouseY: number
} | null>(null)
const dispatch = useDispatch()
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault()
setContextMenu(
contextMenu === null
? {
mouseX: event.clientX + 2,
mouseY: event.clientY - 6
}
: // repeated contextmenu when it is already open closes it with Chrome 84 on Ubuntu
// Other native context menus might behave different.
// With this behavior we prevent contextmenu from the backdrop to re-locale existing context menus.
null
)
}
const handleClose = () => {
setContextMenu(null)
}
return (
<div
onContextMenu={handleContextMenu}
style={{ cursor: 'context-menu', width: '100%' }}
>
{children}
<Menu
open={contextMenu !== null}
onClose={handleClose}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
>
<MenuItem>
<CopyToClipboard
text={link}
onCopy={() => {
handleClose()
dispatch(
setNotification({
msg: 'Copied to clipboard!',
alertType: 'success'
})
)
}}
>
<Box
sx={{
fontSize: '16px'
}}
>
Copy Link
</Box>
</CopyToClipboard>
</MenuItem>
</Menu>
</div>
)
}

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

257
src/components/common/FilePanel.tsx

@ -0,0 +1,257 @@
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 [editVideoIdentifier, setEditVideoIdentifier] = useState<
string | null | undefined
>()
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
// })
const res2 = await fetch(
`/arbitrary/resources?&service=FILE&name=${user.name}&includemetadata=true&limit=100&offset=0&reverse=true`
)
const resData = await res2.json()
if (Array.isArray(resData)) {
res = resData
}
} 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>
<Button
size="small"
variant="contained"
onClick={() => {
setEditVideoIdentifier(video.identifier)
setIsOpenVideoModal(true)
}}
>
Edit
</Button>
</ListItem>
))}
</List>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
flex: '0 0 50px'
}}
>
<PublishButton
variant="contained"
onClick={() => {
setEditVideoIdentifier(null)
setIsOpenVideoModal(true)
}}
>
Publish new file
</PublishButton>
</Box>
</Panel>
</Drawer>
<GenericModal
service="FILE"
identifierPrefix="qfile_qblog"
onClose={() => {
setIsOpenVideoModal(false)
setEditVideoIdentifier(null)
}}
open={isOpenVideoModal}
onPublish={(value) => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
setIsOpenVideoModal(false)
}}
editVideoIdentifier={editVideoIdentifier}
/>
</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
}

317
src/components/common/GenericPublishModal.tsx

@ -0,0 +1,317 @@
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
editVideoIdentifier?: string | null | undefined
}
interface SelectOption {
id: string
name: string
}
const maxSize = 500 * 1024 * 1024
export const GenericModal: React.FC<GenericModalProps> = ({
open,
onClose,
onPublish,
acceptedFileType,
acceptedFileTypes,
service,
identifierPrefix,
editVideoIdentifier
}) => {
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({
editVideoIdentifier,
service,
identifierPrefix,
title,
description,
// base64: base64String,
file,
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>
{editVideoIdentifier && (
<Typography variant="h6">
You are editing: {editVideoIdentifier}
</Typography>
)}
<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>
)
}

89
src/components/common/ImageUploader.tsx

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

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

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

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

43
src/components/common/PageLoader.tsx

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

25
src/components/common/Portal.tsx

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

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

111
src/components/common/PublishAudio.tsx

@ -0,0 +1,111 @@
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
editVideoIdentifier?: string | null | undefined
}
export const usePublishAudio = () => {
const { user } = useSelector((state: RootState) => state.auth)
const dispatch = useDispatch()
const publishAudio = async ({
editVideoIdentifier,
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()
let identifier = `qaudio_qblog_${id}`
if(editVideoIdentifier){
identifier = editVideoIdentifier
}
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
}
}

120
src/components/common/PublishGeneric.tsx

@ -0,0 +1,120 @@
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
file?: File
category: string
service: string
identifierPrefix: string
filename: string
editVideoIdentifier?: string | null | undefined
}
export const usePublishGeneric = () => {
const { user } = useSelector((state: RootState) => state.auth)
const dispatch = useDispatch()
const publishGeneric = async ({
editVideoIdentifier,
service,
identifierPrefix,
filename,
title,
description,
base64,
file,
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()
let identifier = `${identifierPrefix}_${id}`
if (editVideoIdentifier) {
identifier = editVideoIdentifier
}
const resourceResponse = await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE',
name: name,
service: service,
file,
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
}
}

112
src/components/common/PublishVideo.tsx

@ -0,0 +1,112 @@
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
editVideoIdentifier?: string | null | undefined
file?: File
}
export const usePublishVideo = () => {
const { user } = useSelector((state: RootState) => state.auth)
const dispatch = useDispatch()
const publishVideo = async ({
file,
editVideoIdentifier,
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()
let identifier = `qvideo_qblog_${id}`
if (editVideoIdentifier) {
identifier = editVideoIdentifier
}
const resourceResponse = await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE',
name: name,
service: 'VIDEO',
// data64: base64,
file: file,
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
}
}

124
src/components/common/ResponsiveImage.tsx

@ -0,0 +1,124 @@
import React, { useState, useEffect, CSSProperties } from 'react'
import Skeleton from '@mui/material/Skeleton'
import { Box } from '@mui/material'
interface ResponsiveImageProps {
src: string
dimensions: string
alt?: string
className?: string
style?: CSSProperties
}
const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
src,
dimensions,
alt,
className,
style
}) => {
const [loading, setLoading] = useState(true)
const matchResult = dimensions?.match(/v1\.(\d+(\.\d+)?)x(\d+)/)
const width = matchResult ? parseFloat(matchResult[1]) : 1 // Default width value
const height = matchResult ? parseInt(matchResult[3], 10) : 1 // Default height value
const aspectRatio = (height / width) * 100
useEffect(() => {
if (dimensions === 'v1.0x0') {
setLoading(false)
return
}
}, [dimensions])
if (dimensions === 'v1.0x0' || !dimensions) {
return null
}
const imageStyle: CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'cover'
}
const wrapperStyle: CSSProperties = {
position: 'relative',
paddingBottom: `${aspectRatio}%`,
overflow: 'hidden',
...style
}
return (
<Box
sx={{
padding: '2px'
}}
>
{/* <img
onLoad={() => setLoading(false)}
src={src}
style={{
width: '100%',
height: 'auto',
borderRadius: '8px'
}}
/> */}
{loading && (
<Skeleton
variant="rectangular"
style={{
width: '100%',
height: 0,
paddingBottom: `${(height / width) * 100}%`,
objectFit: 'contain',
visibility: loading ? 'visible' : 'hidden',
borderRadius: '8px'
}}
/>
)}
<img
onLoad={() => setLoading(false)}
src={src}
style={{
width: '100%',
height: 'auto',
borderRadius: '8px',
visibility: loading ? 'hidden' : 'visible',
position: loading ? 'absolute' : 'unset'
}}
/>
</Box>
)
return (
<div style={wrapperStyle} className={className}>
{loading ? (
<Skeleton
variant="rectangular"
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0
}}
/>
) : (
<img
src={src}
alt={alt}
style={{
...imageStyle,
position: 'absolute',
top: 0,
left: 0
}}
/>
)}
</div>
)
}
export default ResponsiveImage

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

284
src/components/common/VideoPanel.tsx

@ -0,0 +1,284 @@
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 { useDispatch, useSelector } from 'react-redux'
import { CopyToClipboard } from 'react-copy-to-clipboard'
import { RootState } from '../../state/store'
import LinkIcon from '@mui/icons-material/Link'
import { setNotification } from '../../state/features/notificationsSlice'
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 [editVideoIdentifier, setEditVideoIdentifier] = useState<
string | null | undefined
>()
const dispatch = useDispatch()
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
// })
const res2 = await fetch(
`/arbitrary/resources?&service=VIDEO&name=${user.name}&includemetadata=true&limit=100&offset=0&reverse=true`
)
const resData = await res2.json()
if (Array.isArray(resData)) {
res = resData
}
} catch (error) {
// const res2 = await fetch(
// '/arbitrary/resources?&service=VIDEO&name=Phil&includemetadata=true&limit=100&offset=0&reverse=true'
// )
// res = await res2.json()
}
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>
<Box
sx={{
display: 'flex',
gap: '5px'
}}
>
<Button
size="small"
variant="contained"
onClick={() => {
setEditVideoIdentifier(video.identifier)
setIsOpenVideoModal(true)
}}
>
Edit
</Button>
<CopyToClipboard
text={`qortal://${video.service}/${video.name}/${video.identifier}`}
onCopy={() => {
dispatch(
setNotification({
msg: 'Copied to clipboard!',
alertType: 'success'
})
)
}}
>
<LinkIcon
sx={{
fontSize: '14px',
cursor: 'pointer'
}}
/>
</CopyToClipboard>
</Box>
</ListItem>
))}
</List>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
flex: '0 0 50px'
}}
>
<PublishButton
variant="contained"
onClick={() => {
setEditVideoIdentifier(null)
setIsOpenVideoModal(true)
}}
>
Publish new video
</PublishButton>
</Box>
</Panel>
</Drawer>
<VideoModal
onClose={() => {
setIsOpenVideoModal(false)
setEditVideoIdentifier(null)
}}
open={isOpenVideoModal}
onPublish={(value) => {
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos))
setIsOpenVideoModal(false)
}}
editVideoIdentifier={editVideoIdentifier}
/>
</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
}

832
src/components/common/VideoPlayer.tsx

@ -0,0 +1,832 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Box, IconButton, Slider } from '@mui/material'
import { CircularProgress, Typography } from '@mui/material'
import { Key } from 'ts-key-enum'
import {
PlayArrow,
Pause,
VolumeUp,
Fullscreen,
PictureInPicture, VolumeOff
} from '@mui/icons-material'
import { styled } from '@mui/system'
import { MyContext } from '../../wrappers/DownloadWrapper'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { Refresh } from '@mui/icons-material'
import { Menu, MenuItem } from '@mui/material'
import { MoreVert as MoreIcon } from '@mui/icons-material'
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 [mutedVolume, setMutedVolume] = useState(1)
const [isMuted, setIsMuted] = useState(false)
const [progress, setProgress] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [canPlay, setCanPlay] = useState(false)
const [startPlay, setStartPlay] = useState(false)
const [isMobileView, setIsMobileView] = useState(false)
const [playbackRate, setPlaybackRate] = useState(1)
const [anchorEl, setAnchorEl] = useState(null)
const [consoleLog, setConsoleLog] = useState('Console Log Here')
const [debug, setDebug] = useState(false)
const reDownload = useRef<boolean>(false)
const { downloads } = useSelector((state: RootState) => state.global)
const download = useMemo(() => {
if (!downloads || !identifier) return {}
const findDownload = downloads[identifier]
if (!findDownload) return {}
return findDownload
}, [downloads, identifier])
const src = useMemo(() => {
return download?.url || ''
}, [download?.url])
const resourceStatus = useMemo(() => {
return download?.status || {}
}, [download])
const minSpeed = 0.25;
const maxSpeed = 4.0;
const speedChange = 0.25;
const updatePlaybackRate = (newSpeed: number) => {
if(videoRef.current) {
if(newSpeed > maxSpeed || newSpeed < minSpeed)
newSpeed = minSpeed
videoRef.current.playbackRate = newSpeed
setPlaybackRate(newSpeed)
}
}
const increaseSpeed = (wrapOverflow = true) => {
const changedSpeed = playbackRate + speedChange
let newSpeed = wrapOverflow ? changedSpeed: Math.min(changedSpeed, maxSpeed)
if (videoRef.current) {
updatePlaybackRate(newSpeed);
}
}
const decreaseSpeed = () => {
if (videoRef.current) {
updatePlaybackRate(playbackRate - speedChange);
}
}
const 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)
setIsMuted(false)
}
const onProgressChange = (_: any, value: number | number[]) => {
if (!videoRef.current) return
videoRef.current.currentTime = value as number
setProgress(value as number)
if (!playing) {
videoRef.current.play()
setPlaying(true)
}
}
const handleEnded = () => {
setPlaying(false)
}
const updateProgress = () => {
if (!videoRef.current) return
setProgress(videoRef.current.currentTime)
}
const [isFullscreen, setIsFullscreen] = useState(false)
const enterFullscreen = () => {
if (!videoRef.current) return
if (videoRef.current.requestFullscreen) {
videoRef.current.requestFullscreen()
}
}
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
const toggleFullscreen = () => {
isFullscreen ? exitFullscreen(): enterFullscreen()
}
const togglePictureInPicture = async () => {
if (!videoRef.current) return
if (document.pictureInPictureElement === videoRef.current) {
await document.exitPictureInPicture()
} else {
await videoRef.current.requestPictureInPicture()
}
}
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, [])
const handleLoadedMetadata = () => {
setIsLoading(false)
}
const handleCanPlay = () => {
if (setCount) {
setCount()
}
setIsLoading(false)
setCanPlay(true)
}
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])
useEffect(() => {
const videoElement = videoRef.current
const handleLeavePictureInPicture = async (event: any) => {
const target = event?.target
if (target) {
target.pause()
if (setPlaying) {
setPlaying(false)
}
}
}
if (videoElement) {
videoElement.addEventListener(
'leavepictureinpicture',
handleLeavePictureInPicture
)
}
return () => {
if (videoElement) {
videoElement.removeEventListener(
'leavepictureinpicture',
handleLeavePictureInPicture
)
}
}
}, [])
useEffect(() => {
const videoElement = videoRef.current
const minimizeVideo = async () => {
if (!videoElement) return
const handleClose = () => {
if (videoElement && videoElement.parentElement) {
const el = document.getElementById('videoWrapper')
if (el) {
el?.parentElement?.removeChild(el)
}
}
}
const createCloseButton = (): HTMLButtonElement => {
const closeButton = document.createElement('button')
closeButton.textContent = 'X'
closeButton.style.position = 'absolute'
closeButton.style.top = '0'
closeButton.style.right = '0'
closeButton.style.backgroundColor = 'rgba(255, 255, 255, 0.7)'
closeButton.style.border = 'none'
closeButton.style.fontWeight = 'bold'
closeButton.style.fontSize = '1.2rem'
closeButton.style.cursor = 'pointer'
closeButton.style.padding = '2px 8px'
closeButton.style.borderRadius = '0 0 0 4px'
closeButton.addEventListener('click', handleClose)
return closeButton
}
const buttonClose = createCloseButton()
const videoWrapper = document.createElement('div')
videoWrapper.id = 'videoWrapper'
videoWrapper.style.position = 'fixed'
videoWrapper.style.zIndex = '900000009'
videoWrapper.style.bottom = '0px'
videoWrapper.style.right = '0px'
videoElement.parentElement?.insertBefore(videoWrapper, videoElement)
videoWrapper.appendChild(videoElement)
videoWrapper.appendChild(buttonClose)
videoElement.controls = true
videoElement.style.height = 'auto'
videoElement.style.width = '300px'
document.body.appendChild(videoWrapper)
}
return () => {
if (videoElement) {
if (videoElement && !videoElement.paused && !videoElement.ended) {
minimizeVideo()
}
}
}
}, [])
function formatTime(seconds: number): string {
seconds = Math.floor(seconds)
let minutes: number | string = Math.floor(seconds / 60)
let hours: number | string = Math.floor(minutes / 60)
let remainingSeconds: number | string = seconds % 60
let remainingMinutes: number | string = minutes % 60
if (remainingSeconds < 10) {
remainingSeconds = '0' + remainingSeconds
}
if (remainingMinutes < 10) {
remainingMinutes = '0' + remainingMinutes
}
if(hours === 0){
hours = ''
}
else
{
hours = hours + ':'
}
return hours + remainingMinutes + ':' + remainingSeconds
}
const reloadVideo = () => {
if (!videoRef.current) return
const currentTime = videoRef.current.currentTime
videoRef.current.src = src
videoRef.current.load()
videoRef.current.currentTime = currentTime
if (playing) {
videoRef.current.play()
}
}
useEffect(() => {
if (
resourceStatus?.status === 'DOWNLOADED' &&
reDownload?.current === false
) {
getSrc()
reDownload.current = true
}
}, [getSrc, resourceStatus])
const handleMenuOpen = (event: any) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
useEffect(() => {
const videoWidth = videoRef?.current?.offsetWidth
if (videoWidth && videoWidth <= 600) {
setIsMobileView(true)
}
}, [canPlay])
const getDownloadProgress = (current: number, total: number) => {
const progress = current /total * 100;
return Number.isNaN(progress) ? '': progress.toFixed(0)+'%'
}
const mute = () => {
setIsMuted(true)
setMutedVolume(volume)
setVolume(0)
if(videoRef.current) videoRef.current.volume = 0
}
const unMute = () => {
setIsMuted(false)
setVolume(mutedVolume)
if(videoRef.current) videoRef.current.volume = mutedVolume
}
const toggleMute = () => {
isMuted ? unMute() : mute();
}
const changeVolume = (volumeChange: number) =>
{
if(videoRef.current){
const minVolume = 0;
const maxVolume = 1;
let newVolume = volumeChange + volume
newVolume = Math.max(newVolume, minVolume)
newVolume = Math.min(newVolume, maxVolume)
setIsMuted(false)
setMutedVolume(newVolume)
videoRef.current.volume = newVolume
setVolume(newVolume);
}
}
const setProgressRelative = (secondsChange: number) => {
if(videoRef.current){
const currentTime = videoRef.current?.currentTime
const minTime = 0
const maxTime = videoRef.current?.duration || 100
let newTime = currentTime + secondsChange;
newTime = Math.max(newTime, minTime)
newTime = Math.min(newTime, maxTime)
videoRef.current.currentTime = newTime;
setProgress(newTime);
}
}
const setProgressAbsolute = (videoPercent: number) => {
if(videoRef.current){
videoPercent = Math.min(videoPercent, 100)
videoPercent = Math.max(videoPercent, 0)
const finalTime = videoRef.current?.duration*videoPercent / 100
videoRef.current.currentTime = finalTime
setProgress(finalTime);
}
}
const keyboardShortcutsDown = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
e.preventDefault()
//setConsoleLog(`Alt: ${e.altKey} Shift: ${e.shiftKey} Control: ${e.ctrlKey} Key: ${e.key}`)
switch(e.key) {
case Key.Add: increaseSpeed(false); break;
case '+': increaseSpeed(false); break;
case '>': increaseSpeed(false); break;
case Key.Subtract: decreaseSpeed(); break;
case '-': decreaseSpeed(); break;
case '<': decreaseSpeed(); break;
case Key.ArrowLeft: {
if(e.shiftKey) setProgressRelative(-300);
else if(e.ctrlKey) setProgressRelative(-60);
else if(e.altKey) setProgressRelative(-10);
else setProgressRelative(-5);
} break;
case Key.ArrowRight: {
if(e.shiftKey) setProgressRelative(300);
else if(e.ctrlKey) setProgressRelative(60);
else if(e.altKey) setProgressRelative(10);
else setProgressRelative(5);
} break;
case Key.ArrowDown: changeVolume(-0.05) ; break;
case Key.ArrowUp: changeVolume(0.05) ; break;
}
}
const keyboardShortcutsUp = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
e.preventDefault()
//setConsoleLog(`Alt: ${e.altKey} Shift: ${e.shiftKey} Control: ${e.ctrlKey} Key: ${e.key}`)
switch(e.key) {
case ' ': togglePlay(); break;
case 'm': toggleMute(); break;
case 'f': enterFullscreen(); break;
case Key.Escape: exitFullscreen(); break;
case '0': setProgressAbsolute(0); break;
case '1': setProgressAbsolute(10); break;
case '2': setProgressAbsolute(20); break;
case '3': setProgressAbsolute(30); break;
case '4': setProgressAbsolute(40); break;
case '5': setProgressAbsolute(50); break;
case '6': setProgressAbsolute(60); break;
case '7': setProgressAbsolute(70); break;
case '8': setProgressAbsolute(80); break;
case '9': setProgressAbsolute(90); break;
}
}
return (
<VideoContainer
tabIndex={0}
onKeyUp={keyboardShortcutsUp}
onKeyDown={keyboardShortcutsDown}
style={{
padding: from === 'create' ? '8px' : 0
}}
>
{/* <Box
sx={{
position: 'absolute',
top: '-30px',
right: '-15px'
}}
>
<CopyToClipboard
text={`qortal://${service}/${name}/${identifier}`}
onCopy={() => {
dispatch(
setNotification({
msg: 'Copied to clipboard!',
alertType: 'success'
})
)
}}
>
<LinkIcon
sx={{
fontSize: '14px',
cursor: 'pointer'
}}
/>
</CopyToClipboard>
</Box> */}
{isLoading && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={resourceStatus?.status === 'READY' ? '55px ' : 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: '15px',
textAlign: 'center'
}}
>
{resourceStatus?.status === 'REFETCHING' ? (
<>
<>
{getDownloadProgress(resourceStatus?.localChunkCount,resourceStatus?.totalChunkCount)}
</>
<> Refetching in 25 seconds</>
</>
) : resourceStatus?.status === 'DOWNLOADED' ? (
<>Download Completed: building video...</>
) : resourceStatus?.status !== 'READY' ? (
<>
{getDownloadProgress(resourceStatus?.localChunkCount,resourceStatus?.totalChunkCount)}
</>
) : (
<>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 ? '' : resourceStatus?.status === 'READY' ? src : ''}
poster={poster}
onTimeUpdate={updateProgress}
autoPlay={autoplay}
onClick={togglePlay}
onEnded={handleEnded}
// onLoadedMetadata={handleLoadedMetadata}
onCanPlay={handleCanPlay}
preload="metadata"
style={{
...customStyle
}}
/>
<ControlsContainer
style={{
bottom: from === 'create' ? '15px' : 0
}}
>
{isMobileView && canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<IconButton
edge="end"
color="inherit"
aria-label="menu"
onClick={handleMenuOpen}
>
<MoreIcon />
</IconButton>
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
style: {
width: '250px'
}
}}
>
<MenuItem>
<VolumeUp />
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}/>
</MenuItem>
<MenuItem onClick={() => increaseSpeed()}>
<Typography
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px'
}}
>
Speed: {playbackRate}x
</Typography>
</MenuItem>
<MenuItem onClick={togglePictureInPicture}>
<PictureInPicture />
</MenuItem>
<MenuItem onClick={toggleFullscreen}>
<Fullscreen />
</MenuItem>
</Menu>
</>
) : canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<Typography
sx={{
fontSize: '14px',
marginRight: '5px',
color: 'rgba(255, 255, 255, 0.7)',
visibility:
!videoRef.current?.duration || !progress
? 'hidden'
: 'visible'
}}
>
{progress && videoRef.current?.duration && formatTime(progress)}/
{progress &&
videoRef.current?.duration &&
formatTime(videoRef.current?.duration)}
</Typography>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginRight: '10px'
}}
onClick={toggleMute}
>
{isMuted ? <VolumeOff/>:<VolumeUp/>}
</IconButton>
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
/>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px',
marginLeft: '5px'
}}
onClick={(e) => increaseSpeed()}
>
Speed: {playbackRate}x
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
ref={toggleRef}
onClick={togglePictureInPicture}
>
<PictureInPicture />
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={toggleFullscreen}
>
<Fullscreen />
</IconButton>
</>
) : null}
</ControlsContainer>
{debug ? <span>{consoleLog}</span>: <></>}
</VideoContainer>
)
}

287
src/components/common/VideoPublishModal.tsx

@ -0,0 +1,287 @@
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
editVideoIdentifier?: string | null | undefined
}
interface SelectOption {
id: string
name: string
}
const VideoModal: React.FC<VideoModalProps> = ({
open,
onClose,
onPublish,
editVideoIdentifier
}) => {
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]
// if (!file) return
const res = await publishVideo({
file: file,
editVideoIdentifier,
title,
description,
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>
{editVideoIdentifier && (
<Typography variant="h6">
You are editing: {editVideoIdentifier}
</Typography>
)}
<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;
}

574
src/components/editor/BlogEditor.tsx

@ -0,0 +1,574 @@
// 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
}
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
}) => {
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}
/>
</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",
}));

490
src/components/layout/Navbar/Navbar.tsx

@ -0,0 +1,490 @@
import React, { useMemo, useRef, useState } from 'react'
import {
Typography,
Box,
Popover,
useTheme,
Button,
Input,
List,
ListItem,
ListItemText
} from '@mui/material'
import AccountCircle from '@mui/icons-material/AccountCircle'
import AddBoxIcon from '@mui/icons-material/AddBox'
import Badge from '@mui/material/Badge'
import NotificationsIcon from '@mui/icons-material/Notifications'
import ExitToAppIcon from '@mui/icons-material/ExitToApp'
import { useNavigate } from 'react-router-dom'
import { togglePublishBlogModal } from '../../../state/features/globalSlice'
import { useDispatch, useSelector } from 'react-redux'
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
import { RootState } from '../../../state/store'
import { UserNavbar } from '../../common/UserNavbar/UserNavbar'
import { removePrefix } from '../../../utils/blogIdformats'
import { useLocation } from 'react-router-dom'
import BookmarkIcon from '@mui/icons-material/Bookmark'
import SubscriptionsIcon from '@mui/icons-material/Subscriptions'
import { BlockedNamesModal } from '../../common/BlockedNamesModal/BlockedNamesModal'
import SearchIcon from '@mui/icons-material/Search'
import EmailIcon from '@mui/icons-material/Email'
import localforage from 'localforage'
const notification = localforage.createInstance({
name: 'notification'
})
import BackspaceIcon from '@mui/icons-material/Backspace'
import {
AvatarContainer,
CreateBlogButton,
CustomAppBar,
CustomToolbar,
DropdownContainer,
DropdownText,
QblogLogoContainer,
StyledButton,
AuthenticateButton,
NavbarName
} from './Navbar-styles'
import { AccountCircleSVG } from '../../../assets/svgs/AccountCircleSVG'
import QblogLogo from '../../../assets/img/qBlogLogo.png'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import PersonOffIcon from '@mui/icons-material/PersonOff'
import { NewWindowSVG } from '../../../assets/svgs/NewWindowSVG'
import {
addFilteredPosts,
setFilterValue,
setIsFiltering
} from '../../../state/features/blogSlice'
import { Item } from '../../common/Comments/CommentEditor'
import { formatDate } from '../../../utils/time'
interface Props {
isAuthenticated: boolean
hasBlog: boolean
userName: string | null
userAvatar: string
blog: any
authenticate: () => void
hasAttemptedToFetchBlogInitial: boolean
}
function useQuery() {
return new URLSearchParams(useLocation().search)
}
const NavBar: React.FC<Props> = ({
isAuthenticated,
hasBlog,
userName,
userAvatar,
blog,
authenticate,
hasAttemptedToFetchBlogInitial
}) => {
const navigate = useNavigate()
const dispatch = useDispatch()
const theme = useTheme()
const query = useQuery()
const { visitingBlog } = useSelector((state: RootState) => state.global)
const notifications = useSelector(
(state: RootState) => state.global.notifications
)
const notificationCreatorComment = useSelector(
(state: RootState) => state.global.notificationCreatorComment
)
const fullNotifications = useMemo(() => {
return [...notificationCreatorComment, ...notifications].sort(
(a, b) => b.created - a.created
)
}, [notificationCreatorComment, notifications])
const location = useLocation()
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null)
const [anchorElNotification, setAnchorElNotification] =
React.useState<HTMLButtonElement | null>(null)
const [isOpenModal, setIsOpenModal] = React.useState<boolean>(false)
const [searchVal, setSearchVal] = useState<string>('')
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 openNotificationPopover = (event: any) => {
const target = event.currentTarget as unknown as HTMLButtonElement | null
setAnchorElNotification(target)
}
const closeNotificationPopover = () => {
setAnchorElNotification(null)
}
const handleClose = () => {
setAnchorEl(null)
}
const onClose = () => {
setIsOpenModal(false)
}
const open = Boolean(anchorEl)
const id = open ? 'simple-popover' : undefined
const openPopover = Boolean(anchorElNotification)
const idNotification = openPopover ? 'simple-popover-notification' : undefined
return (
<CustomAppBar position="sticky" elevation={2}>
<CustomToolbar variant="dense">
<QblogLogoContainer
src={QblogLogo}
alt="Qblog 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',
gap: 1
}}
>
<Input
id="standard-adornment-name"
inputRef={inputRef}
onChange={(e) => {
searchValRef.current = e.target.value
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.keyCode === 13) {
if (!searchValRef.current) {
dispatch(setIsFiltering(false))
dispatch(setFilterValue(''))
dispatch(addFilteredPosts([]))
searchValRef.current = ''
if (!inputRef.current) return
inputRef.current.value = ''
return
}
navigate('/')
dispatch(setIsFiltering(true))
dispatch(addFilteredPosts([]))
dispatch(setFilterValue(searchValRef.current))
}
}}
placeholder="Filter by name"
sx={{
'&&:before': {
borderBottom: 'none'
},
'&&:after': {
borderBottom: 'none'
},
'&&:hover:before': {
borderBottom: 'none'
},
'&&.Mui-focused:before': {
borderBottom: 'none'
},
'&&.Mui-focused': {
outline: 'none'
},
fontSize: '18px'
}}
/>
<SearchIcon
sx={{
cursor: 'pointer'
}}
onClick={() => {
if (!searchValRef.current) {
dispatch(setIsFiltering(false))
dispatch(setFilterValue(''))
dispatch(addFilteredPosts([]))
searchValRef.current = ''
if (!inputRef.current) return
inputRef.current.value = ''
return
}
navigate('/')
dispatch(setIsFiltering(true))
dispatch(addFilteredPosts([]))
dispatch(setFilterValue(searchValRef.current))
}}
/>
<BackspaceIcon
sx={{
cursor: 'pointer'
}}
onClick={() => {
dispatch(setIsFiltering(false))
dispatch(setFilterValue(''))
dispatch(addFilteredPosts([]))
searchValRef.current = ''
if (!inputRef.current) return
inputRef.current.value = ''
}}
/>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center'
}}
>
{/* Add isAuthenticated && before username and wrap StyledButton in this condition*/}
{!isAuthenticated && (
<AuthenticateButton onClick={authenticate}>
<ExitToAppIcon />
Authenticate
</AuthenticateButton>
)}
<Badge
badgeContent={fullNotifications.length}
color="primary"
sx={{
margin: '0px 12px'
}}
>
<Button
onClick={(e) => {
openNotificationPopover(e)
}}
sx={{
margin: '0px',
padding: '0px',
height: 'auto',
width: 'auto',
minWidth: 'unset'
}}
>
<NotificationsIcon color="action" />
</Button>
</Badge>
<Popover
id={idNotification}
open={openPopover}
anchorEl={anchorElNotification}
onClose={closeNotificationPopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
>
<Box>
<List
sx={{
maxHeight: '300px',
overflow: 'auto'
}}
>
{fullNotifications.map((notification: any, index: number) => (
<ListItem
key={index}
divider
sx={{
cursor: 'pointer'
}}
onClick={async () => {
const str = notification.postId
const arr = str.split('-post-')
const str1 = arr[0]
const str2 = arr[1]
const blogId = removePrefix(str1)
navigate(
`/${notification.postName}/${blogId}/${str2}?comment=${notification.identifier}`
)
}}
>
<ListItemText
primary={
<React.Fragment>
<Typography
component="span"
variant="body1"
color="textPrimary"
>
From {notification.name}
</Typography>
</React.Fragment>
}
secondary={
<React.Fragment>
<Typography
component="span"
variant="body2"
color="textSecondary"
>
{formatDate(notification.created)}
</Typography>
<Typography
component="span"
variant="body2"
color="textSecondary"
>
{' -comment'}
</Typography>
</React.Fragment>
}
/>
</ListItem>
))}
</List>
</Box>
</Popover>
{/* <button
onClick={async () => {
await qortalRequest({
action: 'SET_TAB_NOTIFICATIONS',
count: 2
})
}}
>
add notification
</button> */}
{isAuthenticated &&
userName &&
hasAttemptedToFetchBlogInitial &&
!hasBlog && (
<CreateBlogButton
onClick={() => {
dispatch(togglePublishBlogModal(true))
}}
>
<NewWindowSVG color="#fff" width="18" height="18" />
Create Blog
</CreateBlogButton>
)}
{isAuthenticated && userName && hasBlog && (
<>
<StyledButton
color="primary"
startIcon={<AddBoxIcon />}
onClick={() => {
navigate(`/post/new`)
}}
>
Create Post
</StyledButton>
<StyledButton
color="primary"
startIcon={<AutoStoriesIcon />}
onClick={() => {
navigate(`/${userName}/${blog.blogId}`)
}}
>
My Blog
</StyledButton>
</>
)}
{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={() => navigate('/favorites')}>
<BookmarkIcon
sx={{
color: '#50e3c2'
}}
/>
<DropdownText>Favorites</DropdownText>
</DropdownContainer>
<DropdownContainer onClick={() => navigate('/subscriptions')}>
<SubscriptionsIcon
sx={{
color: '#5f50e3'
}}
/>
<DropdownText>Subscriptions</DropdownText>
</DropdownContainer>
<DropdownContainer
onClick={() => {
setIsOpenModal(true)
handleClose()
}}
>
<PersonOffIcon
sx={{
color: '#e35050'
}}
/>
<DropdownText>Blocked Names</DropdownText>
</DropdownContainer>
<DropdownContainer>
<a
href="qortal://APP/Q-Mail"
className="qortal-link"
style={{
width: '100%',
display: 'flex',
gap: '5px',
alignItems: 'center'
}}
>
<EmailIcon
sx={{
color: '#50e3c2'
}}
/>
<DropdownText>Q-Mail</DropdownText>
</a>
</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: 20 }}
/>
<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
file?: File
count?: number
query?: string
exactMatchNames?: boolean
excludeBlocked?: boolean
mode?: 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>
}
}

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

162
src/index.css

@ -0,0 +1,162 @@
@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;
}
.qortal-link {
text-decoration: none; /* Removes the underline */
color: inherit; /* Inherits the color of the parent element */
}
.qortal-link:hover,
a:focus {
text-decoration: underline; /* Adds underline on hover and focus for accessibility */
}
.glow {
box-shadow: 0 0 10px #9ecaed, 0 0 20px #9ecaed, 0 0 30px #9ecaed,
0 0 40px #9ecaed;
}

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
}

19
src/main.tsx

@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import { HashRouter, BrowserRouter } from 'react-router-dom'
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>
)

951
src/pages/BlogIndividualPost/BlogIndividualPost.tsx

@ -0,0 +1,951 @@
import React, { useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import {
Button,
Box,
Typography,
CardHeader,
Avatar,
useTheme,
Tooltip
} 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 ShareIcon from '@mui/icons-material/Share'
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,
removePrefix
} 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'
import { CopyToClipboard } from 'react-copy-to-clipboard'
import { setNotification } from '../../state/features/notificationsSlice'
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource'
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: postIdTemp, blog:blogTemp } = useParams()
const blog = React.useMemo(()=> {
if(postIdTemp && postIdTemp?.includes('-post-')){
const str = postIdTemp
const arr = str.split('-post-')
const str1 = arr[0]
const blogId = removePrefix(str1)
return blogId
} else {
return blogTemp
}
}, [postIdTemp])
const postId = React.useMemo(()=> {
if(postIdTemp && postIdTemp?.includes('-post-')){
const str = postIdTemp
const arr = str.split('-post-')
const str2 = arr[1]
return str2
} else {
return postIdTemp
}
}, [postIdTemp])
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>
<Tooltip title={`Copy post link`} arrow>
<Box
sx={{
cursor: 'pointer'
}}
>
<CopyToClipboard
text={`qortal://APP/Q-Blog/${user}/${blog}/${postId}`}
onCopy={() => {
dispatch(
setNotification({
msg: 'Copied to clipboard!',
alertType: 'success'
})
)
}}
>
<ShareIcon />
</CopyToClipboard>
</Box>
</Tooltip>
<CommentSection postId={fullPostId} postName={user || ''} />
</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}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<VideoPlayer
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
setCount={handleCount}
user={user}
postId={fullPostId}
/>
</ContextMenuResource>
</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}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<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=""
/>
</ContextMenuResource>
</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}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<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=""
/>
</ContextMenuResource>
</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}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<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>
</ContextMenuResource>
</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}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<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=""
/>
</ContextMenuResource>
</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}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<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=""
/>
</ContextMenuResource>
</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
}

301
src/pages/BlogIndividualProfile/BlogIndividualProfile.tsx

@ -0,0 +1,301 @@
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'
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource'
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
}
const str = blogPost.id
const arr = str.split('-post-')
const str1 = arr[0]
const blogId = removePrefix(str1)
const str2 = arr[1]
return (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
width: 'auto',
position: 'relative',
' @media (max-width: 450px)': {
width: '100%'
}
}}
>
<ContextMenuResource
name={blogPost.user}
service="BLOG_POST"
identifier={blogPost.id}
link={`qortal://APP/Q-Blog/${blogPost.user}/${blogId}/${str2}`}
>
<BlogPostPreview
onClick={() => {
navigate(`/${blogPost.user}/${blogId}/${str2}`)
}}
description={blogPost?.description}
title={blogPost?.title}
createdAt={blogPost?.createdAt}
author={blogPost.user}
postImage={blogPost?.postImage}
blogPost={blogPost}
tags={blogPost?.tags}
/>
</ContextMenuResource>
{blogPost.user === user?.name && (
<EditIcon
className="edit-btn"
sx={{
position: 'absolute',
zIndex: 10,
bottom: '25px',
right: '25px',
cursor: 'pointer'
}}
onClick={() => {
navigate(`/${blogPost.user}/${blogId}/${str2}/edit`)
}}
/>
)}
</Box>
)
})}
</Masonry>
<LazyLoad onLoadMore={getPosts}></LazyLoad>
</>
)
}

225
src/pages/BlogList/BlogList.tsx

@ -0,0 +1,225 @@
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'
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource'
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 (
<>
{!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
}
const str = blogPost.id
const arr = str.split('-post-')
const str1 = arr[0]
const str2 = arr[1]
const blogId = removePrefix(str1)
return (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
width: 'auto',
position: 'relative',
' @media (max-width: 450px)': {
width: '100%'
}
}}
key={blogPost.id}
>
<ContextMenuResource
name={blogPost.user}
service="BLOG_POST"
identifier={blogPost.id}
link={`qortal://APP/Q-Blog/${blogPost.user}/${blogId}/${str2}`}
>
<BlogPostPreview
onClick={() => {
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}
tags={blogPost?.tags}
/>
</ContextMenuResource>
{blogPost.user === user?.name && (
<EditIcon
className="edit-btn"
sx={{
position: 'absolute',
zIndex: 10,
bottom: '25px',
right: '25px',
cursor: 'pointer'
}}
onClick={() => {
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)",
}
})

320
src/pages/BlogList/PostPreview.tsx

@ -0,0 +1,320 @@
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'
import ResponsiveImage from '../../components/common/ResponsiveImage'
import { formatDate } from '../../utils/time'
interface BlogPostPreviewProps {
title: string
createdAt: number | string
author: string
postImage?: string
description: any
blogPost: BlogPost
onClick?: () => void
isValid?: boolean
tags?: string[]
}
const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
title,
createdAt,
author,
postImage,
description,
onClick,
blogPost,
isValid,
tags
}) => {
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)
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)
}
const dimensions = useMemo(() => {
if (Array.isArray(tags)) {
const imgDimensions = tags[tags.length - 2]
if (!imgDimensions?.includes('v1.')) return ''
return imgDimensions
}
return ''
}, [tags])
return (
<>
<StyledCard
onClick={continueToPost}
onMouseEnter={() => setShowIcons(true)}
onMouseLeave={() => setShowIcons(false)}
>
<ResponsiveImage src={postImage || ''} dimensions={dimensions} />
{/* {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}
sx={{
wordBreak: 'break-word'
}}
>
{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}
/>
)}
</>
)
}

1409
src/pages/CreatePost/CreatePostBuilder.tsx

File diff suppressed because it is too large Load Diff

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

7
src/pages/Home/Home.tsx

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

279
src/pages/Mail/AliasMail.tsx

@ -0,0 +1,279 @@
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState
} 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, Input, Typography, useTheme } from '@mui/material'
import { useFetchPosts } from '../../hooks/useFetchPosts'
import LazyLoad from '../../components/common/LazyLoad'
import { removePrefix } from '../../utils/blogIdformats'
import { NewMessage } from './NewMessage'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import { useFetchMail } from '../../hooks/useFetchMail'
import { ShowMessage } from './ShowMessage'
import { fetchAndEvaluateMail } from '../../utils/fetchMail'
import { addToHashMapMail } from '../../state/features/mailSlice'
import {
setIsLoadingGlobal,
setUserAvatarHash
} from '../../state/features/globalSlice'
import SimpleTable from './MailTable'
import { MAIL_SERVICE_TYPE } from '../../constants/mail'
import { BlogPost } from '../../state/features/blogSlice'
interface AliasMailProps {
value: string
}
export const AliasMail = ({ value }: AliasMailProps) => {
const theme = useTheme()
const { user } = useSelector((state: RootState) => state.auth)
const [isOpen, setIsOpen] = useState<boolean>(false)
const [message, setMessage] = useState<any>(null)
const [replyTo, setReplyTo] = useState<any>(null)
const [valueTab, setValueTab] = React.useState(0)
const [aliasValue, setAliasValue] = useState('')
const [alias, setAlias] = useState<string[]>([])
const hashMapPosts = useSelector(
(state: RootState) => state.blog.hashMapPosts
)
const [mailMessages, setMailMessages] = useState<any[]>([])
const hashMapMailMessages = useSelector(
(state: RootState) => state.mail.hashMapMailMessages
)
const fullMailMessages = useMemo(() => {
return mailMessages.map((msg) => {
let message = msg
const existingMessage = hashMapMailMessages[msg.id]
if (existingMessage) {
message = existingMessage
}
return message
})
}, [mailMessages, hashMapMailMessages])
const dispatch = useDispatch()
const navigate = useNavigate()
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 checkNewMessages = React.useCallback(
async (recipientName: string, recipientAddress: string) => {
try {
const query = `qortal_qmail_${value}_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
}
})
setMailMessages((prev) => {
const updatedMessages = [...prev]
structureData.forEach((newMessage: any) => {
const existingIndex = updatedMessages.findIndex(
(prevMessage) => prevMessage.id === newMessage.id
)
if (existingIndex !== -1) {
// Replace existing message
updatedMessages[existingIndex] = newMessage
} else {
// Add new message
updatedMessages.unshift(newMessage)
}
})
return updatedMessages
})
return
} catch (error) {}
},
[mailMessages]
)
const getMailMessages = React.useCallback(
async (recipientName: string, recipientAddress: string) => {
try {
const offset = mailMessages.length
dispatch(setIsLoadingGlobal(true))
const query = `qortal_qmail_${value}_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
}
})
setMailMessages((prev) => {
const updatedMessages = [...prev]
structureData.forEach((newMessage: any) => {
const existingIndex = updatedMessages.findIndex(
(prevMessage) => prevMessage.id === newMessage.id
)
if (existingIndex !== -1) {
// Replace existing message
updatedMessages[existingIndex] = newMessage
} else {
// Add new message
updatedMessages.push(newMessage)
}
})
return updatedMessages
})
for (const content of structureData) {
if (content.user && content.id) {
getAvatar(content.user)
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
},
[mailMessages, hashMapMailMessages]
)
const getMessages = React.useCallback(async () => {
if (!user?.name || !user?.address) return
await getMailMessages(user.name, user.address)
}, [getMailMessages, user])
const interval = useRef<any>(null)
const checkNewMessagesFunc = useCallback(() => {
if (!user?.name || !user?.address) return
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling || !user?.name || !user?.address) return
isCalling = true
const res = await checkNewMessages(user?.name, user.address)
isCalling = false
}, 30000)
}, [checkNewMessages, user])
useEffect(() => {
checkNewMessagesFunc()
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNewMessagesFunc])
const openMessage = async (
user: string,
messageIdentifier: string,
content: any
) => {
try {
const existingMessage = hashMapMailMessages[messageIdentifier]
if (existingMessage) {
setMessage(existingMessage)
}
dispatch(setIsLoadingGlobal(true))
const res = await fetchAndEvaluateMail({
user,
messageIdentifier,
content,
otherUser: user
})
setMessage(res)
dispatch(addToHashMapMail(res))
setIsOpen(true)
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}
const firstMount = useRef(false)
useEffect(() => {
if (user?.name && !firstMount.current) {
getMessages()
firstMount.current = true
}
}, [user])
return (
<>
<NewMessage replyTo={replyTo} setReplyTo={setReplyTo} alias={value} />
<ShowMessage
isOpen={isOpen}
setIsOpen={setIsOpen}
message={message}
setReplyTo={setReplyTo}
alias={value}
/>
<SimpleTable
openMessage={openMessage}
data={fullMailMessages}
></SimpleTable>
<LazyLoad onLoadMore={getMessages}></LazyLoad>
</>
)
}

342
src/pages/Mail/Mail.tsx

@ -0,0 +1,342 @@
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState
} 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 CloseIcon from '@mui/icons-material/Close'
import {
Box,
Button,
Input,
Typography,
useTheme,
IconButton
} from '@mui/material'
import { useFetchPosts } from '../../hooks/useFetchPosts'
import LazyLoad from '../../components/common/LazyLoad'
import { removePrefix } from '../../utils/blogIdformats'
import { NewMessage } from './NewMessage'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import { useFetchMail } from '../../hooks/useFetchMail'
import { ShowMessage } from './ShowMessage'
import { fetchAndEvaluateMail } from '../../utils/fetchMail'
import { addToHashMapMail } from '../../state/features/mailSlice'
import { setIsLoadingGlobal } from '../../state/features/globalSlice'
import SimpleTable from './MailTable'
import { AliasMail } from './AliasMail'
export const Mail = () => {
const theme = useTheme()
const { user } = useSelector((state: RootState) => state.auth)
const [isOpen, setIsOpen] = useState<boolean>(false)
const [message, setMessage] = useState<any>(null)
const [replyTo, setReplyTo] = useState<any>(null)
const [valueTab, setValueTab] = React.useState(0)
const [aliasValue, setAliasValue] = useState('')
const [alias, setAlias] = useState<string[]>([])
const hashMapPosts = useSelector(
(state: RootState) => state.blog.hashMapPosts
)
const hashMapMailMessages = useSelector(
(state: RootState) => state.mail.hashMapMailMessages
)
const mailMessages = useSelector(
(state: RootState) => state.mail.mailMessages
)
const fullMailMessages = useMemo(() => {
return mailMessages.map((msg) => {
let message = msg
const existingMessage = hashMapMailMessages[msg.id]
if (existingMessage) {
message = existingMessage
}
return message
})
}, [mailMessages, hashMapMailMessages])
const dispatch = useDispatch()
const navigate = useNavigate()
const { getMailMessages, checkNewMessages } = useFetchMail()
const getMessages = React.useCallback(async () => {
if (!user?.name || !user?.address) return
await getMailMessages(user.name, user.address)
}, [getMailMessages, user])
const interval = useRef<any>(null)
const checkNewMessagesFunc = useCallback(() => {
if (!user?.name || !user?.address) return
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling || !user?.name || !user?.address) return
isCalling = true
const res = await checkNewMessages(user?.name, user.address)
isCalling = false
}, 30000)
}, [checkNewMessages, user])
useEffect(() => {
checkNewMessagesFunc()
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNewMessagesFunc])
const openMessage = async (
user: string,
messageIdentifier: string,
content: any
) => {
try {
const existingMessage = hashMapMailMessages[messageIdentifier]
if (existingMessage) {
setMessage(existingMessage)
}
dispatch(setIsLoadingGlobal(true))
const res = await fetchAndEvaluateMail({
user,
messageIdentifier,
content,
otherUser: user
})
setMessage(res)
dispatch(addToHashMapMail(res))
setIsOpen(true)
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}
const firstMount = useRef(false)
useEffect(() => {
if (user?.name && !firstMount.current) {
getMessages()
firstMount.current = true
}
}, [user])
function a11yProps(index: number) {
return {
id: `mail-tabs-${index}`,
'aria-controls': `mail-tabs-${index}`
}
}
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValueTab(newValue)
}
function CustomTabLabel({ index, label }: any) {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<span>{label}</span>
<IconButton
edge="end"
color="inherit"
size="small"
onClick={(event) => {
setValueTab(0)
const newList = [...alias]
newList.splice(index, 1)
setAlias(newList)
}}
>
<CloseIcon fontSize="inherit" />
</IconButton>
</div>
)
}
return (
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'column',
backgroundColor: 'background.paper'
}}
>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider',
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'flex-start'
}}
>
<Tabs
value={valueTab}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab label={user?.name} {...a11yProps(0)} />
{alias.map((alia, index) => {
return (
<Tab
sx={{
'&.Mui-selected': {
color: theme.palette.text.primary,
fontWeight: theme.typography.fontWeightMedium
}
}}
key={alia}
label={<CustomTabLabel index={index} label={alia} />}
{...a11yProps(1 + index)}
/>
)
})}
</Tabs>
<Input
id="standard-adornment-alias"
onChange={(e) => {
setAliasValue(e.target.value)
}}
value={aliasValue}
placeholder="Type in alias"
sx={{
marginLeft: '20px',
'&&:before': {
borderBottom: 'none'
},
'&&:after': {
borderBottom: 'none'
},
'&&:hover:before': {
borderBottom: 'none'
},
'&&.Mui-focused:before': {
borderBottom: 'none'
},
'&&.Mui-focused': {
outline: 'none'
},
fontSize: '18px'
}}
/>
<Button
onClick={() => {
setAlias((prev) => [...prev, aliasValue])
setAliasValue('')
}}
variant="contained"
>
+ alias
</Button>
</Box>
<NewMessage replyTo={replyTo} setReplyTo={setReplyTo} />
<ShowMessage
isOpen={isOpen}
setIsOpen={setIsOpen}
message={message}
setReplyTo={setReplyTo}
/>
{/* {countNewPosts > 0 && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography>
{countNewPosts === 1
? `There is ${countNewPosts} new message`
: `There are ${countNewPosts} new messages`}
</Typography>
<Button
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.text.primary,
fontFamily: 'Arial'
}}
onClick={getNewPosts}
>
Load new Posts
</Button>
</Box>
)} */}
<TabPanel value={valueTab} index={0}>
<SimpleTable
openMessage={openMessage}
data={fullMailMessages}
></SimpleTable>
<LazyLoad onLoadMore={getMessages}></LazyLoad>
</TabPanel>
{alias.map((alia, index) => {
return (
<TabPanel key={alia} value={valueTab} index={1 + index}>
<AliasMail value={alia} />
</TabPanel>
)
})}
{/* <Box>
{mailMessages.map((message, index) => {
const existingMessage = hashMapMailMessages[message.id]
let mailMessage = message
if (existingMessage) {
mailMessage = existingMessage
}
return (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
width: 'auto',
position: 'relative',
' @media (max-width: 450px)': {
width: '100%'
}
}}
key={mailMessage.id}
>
hello
</Box>
)
})}
</Box> */}
</Box>
)
}
interface TabPanelProps {
children?: React.ReactNode
index: number
value: number
}
export function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props
return (
<div
role="tabpanel"
hidden={value !== index}
id={`mail-tabs-${index}`}
aria-labelledby={`mail-tabs-${index}`}
{...other}
style={{
width: '100%'
}}
>
{value === index && children}
</div>
)
}

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

Loading…
Cancel
Save