Browse Source

initial commit

pull/2/head
PhilReact 9 months ago
commit
87d902414c
  1. 14
      .eslintrc.cjs
  2. 25
      .gitignore
  3. 10
      .prettierrc
  4. 13
      index.html
  5. 9506
      package-lock.json
  6. 55
      package.json
  7. 43
      src/App.css
  8. 40
      src/App.tsx
  9. BIN
      src/assets/images/CoverImageDefault.webp
  10. BIN
      src/assets/images/QFundDarkLogo.png
  11. BIN
      src/assets/images/QFundLightLogo.png
  12. BIN
      src/assets/img/qort.png
  13. 1
      src/assets/react.svg
  14. 25
      src/assets/svgs/AccountCircleSVG.tsx
  15. 23
      src/assets/svgs/DarkModeSVG.tsx
  16. 21
      src/assets/svgs/DonateSVG.tsx
  17. 13
      src/assets/svgs/DownloadedLight.tsx
  18. 13
      src/assets/svgs/DownloadingLight.tsx
  19. 21
      src/assets/svgs/ExploreSVG.tsx
  20. 7
      src/assets/svgs/IconTypes.ts
  21. 23
      src/assets/svgs/LightModeSVG.tsx
  22. 21
      src/assets/svgs/PiggybankSVG.tsx
  23. 46
      src/assets/svgs/QortalSVG.tsx
  24. 21
      src/assets/svgs/StarSVG.tsx
  25. 23
      src/assets/svgs/TimesSVG.tsx
  26. 21
      src/assets/svgs/TrackSVG.tsx
  27. 583
      src/components/Crowdfund/Crowdfund-styles.tsx
  28. 84
      src/components/Crowdfund/FileAttachment.tsx
  29. 569
      src/components/Crowdfund/NewCrowdfund.tsx
  30. 369
      src/components/Crowdfund/NewUpdate.tsx
  31. 89
      src/components/ImageUploader.tsx
  32. 56
      src/components/ResponsiveImage.tsx
  33. 236
      src/components/common/AudioPlayer.tsx
  34. 28
      src/components/common/BlockedNamesModal/BlockedNamesModal-styles.ts
  35. 100
      src/components/common/BlockedNamesModal/BlockedNamesModal.tsx
  36. 295
      src/components/common/Comments/Comment.tsx
  37. 254
      src/components/common/Comments/CommentEditor.tsx
  38. 274
      src/components/common/Comments/CommentSection.tsx
  39. 280
      src/components/common/Comments/Comments-styles.tsx
  40. 63
      src/components/common/Countdown/Countdown-styles.tsx
  41. 142
      src/components/common/Countdown/Countdown.tsx
  42. 29
      src/components/common/DisplayHtml.tsx
  43. 48
      src/components/common/Donate/Donate-styles.tsx
  44. 235
      src/components/common/Donate/Donate.tsx
  45. 90
      src/components/common/Donate/DonorInfo.tsx
  46. 82
      src/components/common/Donate/DonorModal.tsx
  47. 204
      src/components/common/DownloadTaskManager.tsx
  48. 419
      src/components/common/FileElement.tsx
  49. 48
      src/components/common/LazyLoad.tsx
  50. 86
      src/components/common/Notification/Notification.tsx
  51. 42
      src/components/common/PageLoader.tsx
  52. 25
      src/components/common/Portal.tsx
  53. 92
      src/components/common/Progress/Progress-styles.tsx
  54. 65
      src/components/common/Progress/Progress.tsx
  55. 46
      src/components/common/Reviews/AddReview/AddReview-styles.tsx
  56. 295
      src/components/common/Reviews/AddReview/AddReview.tsx
  57. 86
      src/components/common/Reviews/QFundOwnerReviewCard.tsx
  58. 283
      src/components/common/Reviews/QFundOwnerReviews-styles.tsx
  59. 245
      src/components/common/Reviews/QFundOwnerReviews.tsx
  60. 812
      src/components/common/VideoPlayer.tsx
  61. 648
      src/components/common/VideoPlayerGlobal.tsx
  62. 117
      src/components/layout/Navbar/Navbar-styles.tsx
  63. 129
      src/components/layout/Navbar/Navbar.tsx
  64. 72
      src/components/modals/ConsentModal.tsx
  65. 50
      src/components/modals/ReusableModal.tsx
  66. 19
      src/constants/index.ts
  67. 113
      src/global.d.ts
  68. 239
      src/hooks/useFetchCrowdfundStatus.tsx
  69. 102
      src/hooks/useFetchCrowdfunds.tsx
  70. 55
      src/hooks/useFetchOwnerReviews.tsx
  71. 25
      src/hooks/useWindowSize.tsx
  72. 269
      src/index.css
  73. 17
      src/main.tsx
  74. 721
      src/pages/Crowdfund/Crowdfund.tsx
  75. 16
      src/pages/Crowdfund/CrowdfundLoader.tsx
  76. 96
      src/pages/Crowdfund/Update-styles.tsx
  77. 216
      src/pages/Crowdfund/Update.tsx
  78. 188
      src/pages/Home/CrowdfundList.tsx
  79. 329
      src/pages/Home/Home-styles.tsx
  80. 122
      src/pages/Home/Home.tsx
  81. 27
      src/state/features/authSlice.ts
  82. 94
      src/state/features/crowdfundSlice.ts
  83. 90
      src/state/features/globalSlice.ts
  84. 73
      src/state/features/notificationsSlice.ts
  85. 27
      src/state/store.ts
  86. BIN
      src/styles/fonts/Cairo.ttf
  87. BIN
      src/styles/fonts/Cambon-Light.ttf
  88. BIN
      src/styles/fonts/Catamaran.ttf
  89. BIN
      src/styles/fonts/Copse.ttf
  90. BIN
      src/styles/fonts/Karla.ttf
  91. BIN
      src/styles/fonts/Livvic.ttf
  92. BIN
      src/styles/fonts/Merriweather Sans.ttf
  93. BIN
      src/styles/fonts/Montserrat.ttf
  94. BIN
      src/styles/fonts/Mulish.ttf
  95. BIN
      src/styles/fonts/Oxygen.ttf
  96. BIN
      src/styles/fonts/ProximaNova.otf
  97. BIN
      src/styles/fonts/Raleway.ttf
  98. 236
      src/styles/theme.tsx
  99. BIN
      src/test/download.gif
  100. BIN
      src/test/mockimg.jpg
  101. Some files were not shown because too many files have changed in this diff Show More

14
.eslintrc.cjs

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

25
.gitignore vendored

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

10
.prettierrc

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

13
index.html

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

9506
package-lock.json generated

File diff suppressed because it is too large Load Diff

55
package.json

@ -0,0 +1,55 @@
{
"name": "q-fund",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.13",
"@mui/system": "^5.14.5",
"@mui/x-date-pickers": "^6.12.0",
"@reduxjs/toolkit": "^1.9.3",
"bs58": "^5.0.0",
"colorsys": "github:netbeast/colorsys",
"compressorjs": "^1.2.1",
"dayjs": "^1.11.9",
"dompurify": "^3.0.5",
"localforage": "^1.10.0",
"moment": "^2.29.4",
"qortal-app-utils": "latest",
"quill-image-resize-module-react": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-intersection-observer": "^9.4.3",
"react-quill": "^2.0.0",
"react-redux": "^8.0.5",
"react-rnd": "^10.4.1",
"react-router-dom": "^6.9.0",
"react-toastify": "^9.1.2",
"short-unique-id": "^4.4.4",
"ts-key-enum": "^2.0.12"
},
"devDependencies": {
"@mui/types": "^7.2.3",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"prettier": "^3.0.2",
"typescript": "^5.0.2",
"vite": "^4.3.2"
}
}

43
src/App.css

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

40
src/App.tsx

@ -0,0 +1,40 @@
import { useState } from 'react';
import { Route, Routes } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { darkTheme, lightTheme } from './styles/theme';
import { store } from './state/store';
import { Provider } from 'react-redux';
import GlobalWrapper from './wrappers/GlobalWrapper';
import Notification from './components/common/Notification/Notification';
import { Home } from './pages/Home/Home';
import DownloadWrapper from './wrappers/DownloadWrapper';
import { Crowdfund } from './pages/Crowdfund/Crowdfund';
function App() {
// const themeColor = window._qdnTheme
const [theme, setTheme] = useState('dark');
return (
<Provider store={store}>
<ThemeProvider theme={theme === 'light' ? lightTheme : darkTheme}>
<Notification />
<DownloadWrapper>
<GlobalWrapper setTheme={(val: string) => setTheme(val)}>
<CssBaseline />
<Routes>
<Route
path="/"
element={<Home setTheme={(val: string) => setTheme(val)} />}
/>
<Route path="/crowdfund/:name/:id" element={<Crowdfund />} />
</Routes>
</GlobalWrapper>
</DownloadWrapper>
</ThemeProvider>
</Provider>
);
}
export default App;

BIN
src/assets/images/CoverImageDefault.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
src/assets/images/QFundDarkLogo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
src/assets/images/QFundLightLogo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
src/assets/img/qort.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

1
src/assets/react.svg

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

After

Width:  |  Height:  |  Size: 4.0 KiB

25
src/assets/svgs/AccountCircleSVG.tsx

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

23
src/assets/svgs/DarkModeSVG.tsx

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

21
src/assets/svgs/DonateSVG.tsx

@ -0,0 +1,21 @@
import { IconTypes } from "./IconTypes";
export const DonateSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
}) => {
return (
<svg
className={className}
fill={color}
height={height}
width={width}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
>
<path d="M640-440q-46-42-89.5-82.5t-77-79.5Q440-641 420-677.5T400-748q0-56 38-94t94-38q32 0 60 13.5t48 36.5q20-23 48-36.5t60-13.5q56 0 94 38t38 94q0 34-20 70.5T806.5-602Q773-563 730-522.5T640-440Zm0-108q59-56 109.5-111.5T800-748q0-23-14.5-37.5T748-800q-14 0-26.5 5.5T700-778l-60 72-60-72q-9-11-21.5-16.5T532-800q-23 0-37.5 14.5T480-748q0 33 50.5 88.5T640-548ZM560-60l-280-78v58H40v-440h318l248 92q33 12 53.5 42t20.5 66h80q50 0 85 33t35 87v40L560-60ZM120-160h80v-280h-80v280Zm438 16 238-74q-3-11-13.5-16.5T760-240H568q-31 0-56-4t-54-14l-69-24 23-76 80 26q18 6 42 9t66 3q0-11-6.5-21T578-354l-234-86h-64v220l278 76ZM200-300Zm400-20Zm-400 20Zm80 0Zm360-374Z" />
</svg>
);
};

13
src/assets/svgs/DownloadedLight.tsx

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

13
src/assets/svgs/DownloadingLight.tsx

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

21
src/assets/svgs/ExploreSVG.tsx

@ -0,0 +1,21 @@
import { IconTypes } from "./IconTypes";
export const ExploreSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
}) => {
return (
<svg
className={className}
fill={color}
height={height}
width={width}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
>
<path d="M458-280q18 0 35.5-4.5T526-298l98 98 56-56-98-98q9-15 13.5-32.5T600-422q0-58-41-98t-99-40q-58 0-99 41t-41 99q0 58 40 99t98 41Zm2-80q-25 0-42.5-17.5T400-420q0-25 17.5-42.5T460-480q25 0 42.5 17.5T520-420q0 25-17.5 42.5T460-360ZM240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z" />
</svg>
);
};

7
src/assets/svgs/IconTypes.ts

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

23
src/assets/svgs/LightModeSVG.tsx

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

21
src/assets/svgs/PiggybankSVG.tsx

@ -0,0 +1,21 @@
import { IconTypes } from "./IconTypes";
export const PiggybankSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
}) => {
return (
<svg
className={className}
fill={color}
height={height}
width={width}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
>
<path d="M640-520q17 0 28.5-11.5T680-560q0-17-11.5-28.5T640-600q-17 0-28.5 11.5T600-560q0 17 11.5 28.5T640-520Zm-320-80h200v-80H320v80ZM180-120q-34-114-67-227.5T80-580q0-92 64-156t156-64h200q29-38 70.5-59t89.5-21q25 0 42.5 17.5T720-820q0 6-1.5 12t-3.5 11q-4 11-7.5 22.5T702-751l91 91h87v279l-113 37-67 224H480v-80h-80v80H180Zm60-80h80v-80h240v80h80l62-206 98-33v-141h-40L620-720q0-20 2.5-38.5T630-796q-29 8-51 27.5T547-720H300q-58 0-99 41t-41 99q0 98 27 191.5T240-200Zm240-298Z" />
</svg>
);
};

46
src/assets/svgs/QortalSVG.tsx

@ -0,0 +1,46 @@
import { IconTypes } from "./IconTypes";
export const QortalSVG: React.FC<IconTypes> = ({
color,
height,
width,
className
}) => {
return (
<svg
className={className}
fill={color}
version="1.0"
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 695.000000 754.000000"
preserveAspectRatio="xMidYMid meet"
>
<g
transform="translate(0.000000,754.000000) scale(0.100000,-0.100000)"
stroke="none"
>
<path
d="M3035 7289 c-374 -216 -536 -309 -1090 -629 -409 -236 -1129 -652
-1280 -739 -82 -48 -228 -132 -322 -186 l-173 -100 0 -1882 0 -1883 38 -24
c20 -13 228 -134 462 -269 389 -223 1779 -1026 2335 -1347 127 -73 268 -155
314 -182 56 -32 95 -48 118 -48 33 0 207 97 991 552 l102 60 0 779 c0 428 -2
779 -4 779 -3 0 -247 -140 -543 -311 -296 -170 -544 -308 -553 -306 -8 2 -188
104 -400 226 -212 123 -636 368 -942 544 l-558 322 0 1105 c0 1042 1 1106 18
1116 9 6 107 63 217 126 110 64 421 243 690 398 270 156 601 347 736 425 l247
142 363 -210 c200 -115 551 -317 779 -449 228 -132 495 -286 594 -341 l178
-102 -6 -1889 -6 -1888 23 14 c12 8 318 185 680 393 l657 379 0 1887 0 1886
-77 46 c-43 25 -458 264 -923 532 -465 268 -1047 605 -1295 748 -646 373 -965
557 -968 557 -1 0 -182 -104 -402 -231z"
/>
<path
d="M3010 4769 c-228 -133 -471 -274 -540 -313 l-125 -72 0 -633 0 -632
295 -171 c162 -94 407 -235 544 -315 137 -79 255 -142 261 -139 6 2 200 113
431 247 230 133 471 272 534 308 l115 66 2 635 3 635 -536 309 c-294 169 -543
310 -552 312 -9 2 -204 -105 -432 -237z"
/>
</g>
</svg>
);
};

21
src/assets/svgs/StarSVG.tsx

@ -0,0 +1,21 @@
import { IconTypes } from "./IconTypes";
export const StarSVG: React.FC<IconTypes> = ({
color,
height,
width,
className
}) => {
return (
<svg
fill={color}
height={height}
width={width}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
>
<path d="m233-80 65-281L80-550l288-25 112-265 112 265 288 25-218 189 65 281-247-149L233-80Z" />
</svg>
);
};

23
src/assets/svgs/TimesSVG.tsx

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

21
src/assets/svgs/TrackSVG.tsx

@ -0,0 +1,21 @@
import { IconTypes } from "./IconTypes";
export const TrackSVG: React.FC<IconTypes> = ({
color,
height,
width,
className,
}) => {
return (
<svg
className={className}
fill={color}
height={height}
width={width}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
>
<path d="M120-240q-33 0-56.5-23.5T40-320q0-33 23.5-56.5T120-400h10.5q4.5 0 9.5 2l182-182q-2-5-2-9.5V-600q0-33 23.5-56.5T400-680q33 0 56.5 23.5T480-600q0 2-2 20l102 102q5-2 9.5-2h21q4.5 0 9.5 2l142-142q-2-5-2-9.5V-640q0-33 23.5-56.5T840-720q33 0 56.5 23.5T920-640q0 33-23.5 56.5T840-560h-10.5q-4.5 0-9.5-2L678-420q2 5 2 9.5v10.5q0 33-23.5 56.5T600-320q-33 0-56.5-23.5T520-400v-10.5q0-4.5 2-9.5L420-522q-5 2-9.5 2H400q-2 0-20-2L198-340q2 5 2 9.5v10.5q0 33-23.5 56.5T120-240Z" />
</svg>
);
};

583
src/components/Crowdfund/Crowdfund-styles.tsx

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

84
src/components/Crowdfund/FileAttachment.tsx

@ -0,0 +1,84 @@
import { Box, Typography } from "@mui/material";
import React from "react";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import { useDropzone } from "react-dropzone";
import { useDispatch } from "react-redux";
import { setNotification } from "../../state/features/notificationsSlice";
import CloseIcon from "@mui/icons-material/Close";
const maxSize = 25 * 1024 * 1024; // 25 MB in bytes
export const FileAttachment = ({ setAttachments, attachments }) => {
const dispatch = useDispatch();
const { getRootProps, getInputProps } = useDropzone({
maxSize,
onDrop: acceptedFiles => {
setAttachments(prev => [...prev, ...acceptedFiles]);
},
onDropRejected: rejectedFiles => {
dispatch(
setNotification({
msg: "One of your files is over the 50mb limit",
alertType: "error",
})
);
},
});
return (
<>
<Box
{...getRootProps()}
sx={{
border: "1px dashed gray",
padding: 2,
textAlign: "center",
marginBottom: 2,
}}
>
<input {...getInputProps()} />
<AttachFileIcon
sx={{
height: "20px",
width: "auto",
cursor: "pointer",
}}
></AttachFileIcon>
</Box>
<Box>
{attachments.map((file, index) => {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
key={file.name + index}
>
<Typography
sx={{
fontSize: "16px",
}}
>
{file?.filename || file?.name}
</Typography>
<CloseIcon
onClick={() =>
setAttachments(prev =>
prev.filter((item, itemIndex) => itemIndex !== index)
)
}
sx={{
height: "16px",
width: "auto",
cursor: "pointer",
}}
/>
</Box>
);
})}
</Box>
</>
);
};

569
src/components/Crowdfund/NewCrowdfund.tsx

@ -0,0 +1,569 @@
import React, { useEffect, useState } from "react";
import {
AddCoverImageButton,
AddCrowdFundButton,
AddLogoIcon,
CATContainer,
CoverImagePreview,
CrowdfundActionButton,
CrowdfundActionButtonRow,
CrowdfundCardTitle,
CustomBoundedTextField,
CustomInputField,
LogoPreviewRow,
ModalBody,
NewCrowdFundFont,
NewCrowdfundTimeDescription,
NewCrowdfundTitle,
TimesIcon,
} from "./Crowdfund-styles";
import { Box, Modal, useTheme } from "@mui/material";
import ReactQuill, { Quill } from "react-quill";
import ImageResize from "quill-image-resize-module-react";
import ShortUniqueId from "short-unique-id";
import "react-quill/dist/quill.snow.css";
import { FileAttachment } from "./FileAttachment";
import { useDispatch, useSelector } from "react-redux";
import { setNotification } from "../../state/features/notificationsSlice";
import { objectToBase64, uint8ArrayToBase64 } from "../../utils/toBase64";
import { RootState } from "../../state/store";
import { ATTACHMENT_BASE, CROWDFUND_BASE } from "../../constants";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween"; // Import the plugin
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import duration from "dayjs/plugin/duration";
import bs58 from "bs58";
import {
addCrowdfundToBeginning,
addToHashMap,
upsertCrowdfunds,
} from "../../state/features/crowdfundSlice";
import ImageUploader from "../ImageUploader";
import { DesktopDateTimePicker } from "@mui/x-date-pickers";
import { PiggybankSVG } from "../../assets/svgs/PiggybankSVG";
dayjs.extend(isBetween);
dayjs.extend(duration);
Quill.register("modules/imageResize", ImageResize);
const uid = new ShortUniqueId();
const modules = {
imageResize: {
parchment: Quill.import("parchment"),
modules: ["Resize", "DisplaySize"],
},
toolbar: [
["bold", "italic", "underline", "strike"], // styled text
["blockquote", "code-block"], // blocks
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: "ordered" }, { list: "bullet" }], // lists
[{ script: "sub" }, { script: "super" }], // superscript/subscript
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
[{ direction: "rtl" }], // text direction
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }], // custom button values
[{ color: [] }, { background: [] }], // dropdown with defaults
[{ font: [] }], // font family
[{ align: [] }], // text align
["clean"], // remove formatting
["image"], // image
],
};
interface NewCrowdfundProps {
editId?: string;
editContent?: null | {
title: string;
inlineContent: string;
attachments: any[];
user: string;
coverImage: string | null;
};
}
export const NewCrowdfund = ({ editId, editContent }: NewCrowdfundProps) => {
const theme = useTheme();
const [value, setValue] = React.useState<Dayjs | null>(dayjs().add(5, "day"));
const [goalValue, setGoalValue] = useState<number | string>("");
const dispatch = useDispatch();
const username = useSelector((state: RootState) => state.auth?.user?.name);
const userAddress = useSelector(
(state: RootState) => state.auth?.user?.address
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [inlineContent, setInlineContent] = useState("");
const [attachments, setAttachments] = useState<any[]>([]);
const [coverImage, setCoverImage] = useState<string | null>(null);
const minGoal = 1;
const maxGoal = 1_000_000;
useEffect(() => {
if (editContent) {
setTitle(editContent?.title);
setInlineContent(editContent?.inlineContent);
setAttachments(editContent?.attachments);
setCoverImage(editContent?.coverImage || null);
}
}, [editContent]);
const onClose = () => {
setIsOpen(false);
};
const diffInMins = React.useMemo(() => {
const differenceInMinutes = dayjs().diff(value, "minute");
return differenceInMinutes * -1;
}, [value]);
// Define the type for your POST request body
interface PostRequestBody {
ciyamAtVersion: number;
codeBytesBase64: string | undefined;
dataBytesBase64: string | undefined;
numCallStackPages: number;
numUserStackPages: number;
minActivationAmount: number;
}
// Define the function to make the POST request
async function fetchPostRequest(
url: string,
body: PostRequestBody
): Promise<string> {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`);
}
const text = await response.text();
return text;
} catch (error: any) {
console.error(
"There was an error with the fetch operation:",
error.message
);
throw error;
}
}
const dataBytePlaceholder = [0, 0, 0, 0, 0, 0, 0, 60, 0, 0, 0, 0, 61, -3, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 58, -72, -68, -80, 127, 99, 68, -76, 42, -80, 66, 80, -56, 106, 110, -117, 117, -45, -3, -69, -58, 86, -107, -110, 93, 0, 0, 0, 0, 0, 0, 0]
function adjustByteValue(byteValue) {
return (byteValue + 256) % 256;
}
function setLongValue(array, position, value) {
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setUint32(0, Math.floor(value / 0x100000000));
view.setUint32(4, value >>> 0);
for (let i = 0; i < 8; i++) {
array[position + i] = view.getInt8(i) & 0xff; // Correctly handle the byte value
}
}
// Function to replace a value at a given position in the original array with an array
function replaceArraySlice(originalArray, position, newArray) {
for (let i = 0; i < newArray.length; i++) {
originalArray[position + i] = newArray[i];
}
}
const codeBytes =
"NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAGMgQDKDA4AR8AAAAAAAAABjIEAyg=";
const createBytes = (goalAmount: number, blocks: number, address: string) => {
try {
const newArray = [...dataBytePlaceholder];
setLongValue(newArray, 0, blocks);
const adjustedInput = goalAmount * 1e8;
setLongValue(newArray, 8, adjustedInput);
const decodedAwardeeAddress = bs58.decode(address).map(adjustByteValue);
replaceArraySlice(newArray, 48, decodedAwardeeAddress);
const byteArray: Uint8Array = new Uint8Array(newArray);
const encodedString: string = uint8ArrayToBase64(byteArray);
return encodedString;
} catch (error) {
console.error(error);
}
};
async function publishQDNResource() {
try {
if (!userAddress) throw new Error("Unable to locate user address");
let errorMsg = "";
let name = "";
if (username) {
name = username;
}
if (!name) {
errorMsg =
"Cannot publish without access to your name. Please authenticate.";
}
if (!title) {
errorMsg = "Cannot publish without a title";
}
if (editId && editContent?.user !== name) {
errorMsg = "Cannot publish another user's resource";
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: "error",
})
);
return;
}
const sanitizeTitle = title
.replace(/[^a-zA-Z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim()
.toLowerCase();
const requestBody: PostRequestBody = {
ciyamAtVersion: 2,
codeBytesBase64: undefined,
dataBytesBase64: undefined,
numCallStackPages: 0,
numUserStackPages: 0,
minActivationAmount: 0,
};
// CHANGE BACK AFTER TESTING
// const blocksToGoal = 20;
const differenceInMinutes = dayjs().diff(value, "minute");
const blocksToGoal = differenceInMinutes * -1;
if (blocksToGoal < 29 || blocksToGoal > 43200)
throw new Error("end of crowdfund needs to be between 2880 and 43200");
if (!goalValue) throw new Error("Goal amount must be one or greater!");
requestBody.dataBytesBase64 = createBytes(
+goalValue,
blocksToGoal,
userAddress
);
requestBody.codeBytesBase64 = codeBytes;
const creationBytes = await fetchPostRequest("/at/create", requestBody);
const response = await qortalRequest({
action: "DEPLOY_AT",
creationBytes,
name: "q-fund crowdfund",
description: sanitizeTitle.slice(0, 30),
type: "crowdfund",
tags: "q-fund",
amount: 0.2,
assetId: 0,
});
const crowdfundObject: any = {
title,
createdAt: Date.now(),
version: 1,
attachments: [],
description,
inlineContent,
coverImage,
deployedAT: {
...response,
blocksToGoal,
goalValue: +goalValue,
userAddress,
},
};
const id = uid();
const attachmentArray: any[] = [];
const attachmentArrayToSave: any[] = [];
for (const attachment of attachments) {
const alreadyExits = !!attachment?.identifier;
if (alreadyExits) {
attachmentArray.push(attachment);
continue;
}
const id = uid();
const id2 = uid();
const identifier = `${ATTACHMENT_BASE}${id}_${id2}`;
const fileExtension = attachment?.name?.split(".")?.pop();
if (!fileExtension) {
throw new Error("One of your attachments does not have an extension");
}
let service = "FILE";
const type = attachment?.type;
if (type.startsWith("audio/")) {
service = "AUDIO";
}
if (type.startsWith("video/")) {
service = "VIDEO";
}
const obj: any = {
name,
service,
filename: attachment.name,
identifier,
file: attachment,
type: attachment?.type,
size: attachment?.size,
};
attachmentArray.push(obj);
attachmentArrayToSave.push(obj);
}
crowdfundObject.attachments = attachmentArray;
if (attachmentArrayToSave.length > 0) {
const multiplePublish = {
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
resources: [...attachmentArrayToSave],
};
await qortalRequest(multiplePublish);
}
const identifier = editId
? editId
: `${CROWDFUND_BASE}${sanitizeTitle.slice(0, 30)}_${id}`;
const crowdfundObjectToBase64 = await objectToBase64(crowdfundObject);
// Description is obtained from raw data
const requestBody2: any = {
action: "PUBLISH_QDN_RESOURCE",
name: name,
service: "DOCUMENT",
data64: crowdfundObjectToBase64,
title: title.slice(0, 50),
identifier,
};
await qortalRequest(requestBody2);
dispatch(
setNotification({
msg: "Crowdfund deployed and published",
alertType: "success",
})
);
const objToStore: any = {
...crowdfundObject,
title: title,
id: identifier,
user: name,
created: Date.now(),
updated: Date.now(),
};
if (!editId) {
dispatch(addCrowdfundToBeginning(objToStore));
} else {
dispatch(upsertCrowdfunds([objToStore]));
}
dispatch(addToHashMap(objToStore));
setTitle("");
setInlineContent("");
setAttachments([]);
setCoverImage(null);
setIsOpen(false);
setGoalValue("");
setValue(dayjs().add(5, "day"));
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to publish crowdfund",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to publish crowdfund",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to publish crowdfund",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
throw new Error("Failed to publish crowdfund");
}
}
const formatDuration = (totalMinutes: number) => {
const durationObj = dayjs.duration(totalMinutes, "minutes");
const days = durationObj.days();
const hours = durationObj.hours();
const minutes = durationObj.minutes();
return `${days > 0 ? days + " days, " : ""}${
hours > 0 ? hours + " hours, " : ""
}${minutes} minutes`;
};
const minDateTime = dayjs().add(2, "day");
const maxDateTime = dayjs().add(30, "day");
return (
<>
{username && (
<>
{editId ? null : (
<CATContainer>
<AddCrowdFundButton onClick={() => setIsOpen(true)}>
<PiggybankSVG height={"24"} width={"24"} color={"#ffffff"} />
<CrowdfundCardTitle>Start a Q-Fund</CrowdfundCardTitle>
</AddCrowdFundButton>
</CATContainer>
)}
</>
)}
<Modal
open={isOpen}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<ModalBody>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
{editId ? (
<NewCrowdfundTitle>Update Crowdfund</NewCrowdfundTitle>
) : (
<NewCrowdfundTitle>Create Crowdfund</NewCrowdfundTitle>
)}
{!coverImage ? (
<ImageUploader onPick={(img: string) => setCoverImage(img)}>
<AddCoverImageButton variant="contained">
Add Cover Image
<AddLogoIcon
sx={{
height: "25px",
width: "auto",
}}
></AddLogoIcon>
</AddCoverImageButton>
</ImageUploader>
) : (
<LogoPreviewRow>
<CoverImagePreview src={coverImage} alt="logo" />
<TimesIcon
color={theme.palette.text.primary}
onClickFunc={() => setCoverImage(null)}
height={"32"}
width={"32"}
></TimesIcon>
</LogoPreviewRow>
)}
</Box>
<CustomInputField
name="title"
label="Title of crowdfund"
variant="filled"
value={title}
onChange={e => setTitle(e.target.value)}
inputProps={{ maxLength: 180 }}
multiline
maxRows={3}
required
/>
<CustomInputField
name="description"
label="Describe your crowdfund in a few words"
variant="filled"
value={description}
onChange={e => setDescription(e.target.value)}
inputProps={{ maxLength: 180 }}
multiline
maxRows={3}
required
/>
<CustomBoundedTextField
label="Goal Amount (QORT)"
variant="filled"
value={goalValue}
onChange={value =>
value ? setGoalValue(+value) : setGoalValue("")
}
minValue={minGoal}
maxValue={maxGoal}
addIconButtons={true}
allowDecimals={false}
required
/>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DesktopDateTimePicker
label="End date of crowdfund. Min 2 days Max 30 days"
value={value}
onChange={newValue => setValue(newValue)}
minDateTime={minDateTime}
maxDateTime={maxDateTime}
/>
</LocalizationProvider>
<NewCrowdfundTimeDescription>
Length of crowdfund: {diffInMins} blocks ~{" "}
{formatDuration(diffInMins)}
</NewCrowdfundTimeDescription>
<NewCrowdFundFont>Add necessary files - optional</NewCrowdFundFont>
<FileAttachment
setAttachments={setAttachments}
attachments={attachments}
/>
<NewCrowdFundFont>Describe your objective in depth</NewCrowdFundFont>
<ReactQuill
theme="snow"
value={inlineContent}
onChange={setInlineContent}
modules={modules}
/>
<CrowdfundActionButtonRow>
<CrowdfundActionButton
onClick={() => {
onClose();
}}
variant="contained"
color="error"
>
Cancel
</CrowdfundActionButton>
<CrowdfundActionButton
variant="contained"
onClick={() => {
publishQDNResource();
}}
>
Publish
</CrowdfundActionButton>
</CrowdfundActionButtonRow>
</ModalBody>
</Modal>
</>
);
};

369
src/components/Crowdfund/NewUpdate.tsx

@ -0,0 +1,369 @@
import { useEffect, useState } from "react";
import CreateIcon from "@mui/icons-material/Create";
import {
AddCrowdFundButton,
CATContainer,
CrowdfundCardTitle,
CustomInputField,
EditCrowdFundButton,
ModalBody,
NewCrowdFundFont,
NewCrowdfundTitle,
CrowdfundActionButton,
CrowdfundActionButtonRow,
} from "./Crowdfund-styles";
import { Box, Button, Modal, useTheme } from "@mui/material";
import ReactQuill, { Quill } from "react-quill";
import ImageResize from "quill-image-resize-module-react";
import ShortUniqueId from "short-unique-id";
import AddIcon from "@mui/icons-material/Add";
import "react-quill/dist/quill.snow.css";
import { FileAttachment } from "./FileAttachment";
import { useDispatch, useSelector } from "react-redux";
import { setNotification } from "../../state/features/notificationsSlice";
import { objectToBase64 } from "../../utils/toBase64";
import { RootState } from "../../state/store";
import { ATTACHMENT_BASE, UPDATE_BASE } from "../../constants";
import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween"; // Import the plugin
import duration from "dayjs/plugin/duration";
import { addToHashMap } from "../../state/features/crowdfundSlice";
import { CloseNewUpdateModal } from "../../pages/Crowdfund/Update-styles";
dayjs.extend(isBetween);
dayjs.extend(duration);
Quill.register("modules/imageResize", ImageResize);
const uid = new ShortUniqueId();
const modules = {
imageResize: {
parchment: Quill.import("parchment"),
modules: ["Resize", "DisplaySize"],
},
toolbar: [
["bold", "italic", "underline", "strike"], // styled text
["blockquote", "code-block"], // blocks
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: "ordered" }, { list: "bullet" }], // lists
[{ script: "sub" }, { script: "super" }], // superscript/subscript
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
[{ direction: "rtl" }], // text direction
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }], // custom button values
[{ color: [] }, { background: [] }], // dropdown with defaults
[{ font: [] }], // font family
[{ align: [] }], // text align
["clean"], // remove formatting
["image"], // image
],
};
interface NewUpdateProps {
editId?: string;
editContent?: null | {
title: string;
inlineContent: string;
attachments: any[];
user: string;
};
crowdfundId?: string;
onSubmit?: (content: any) => void;
crowdfundName: string;
}
export const NewUpdate = ({
editId,
editContent,
crowdfundId,
onSubmit,
crowdfundName,
}: NewUpdateProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const username = useSelector((state: RootState) => state.auth?.user?.name);
const userAddress = useSelector(
(state: RootState) => state.auth?.user?.address
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [title, setTitle] = useState<string>("");
const [inlineContent, setInlineContent] = useState("");
const [attachments, setAttachments] = useState<any[]>([]);
useEffect(() => {
if (editContent) {
setTitle(editContent?.title);
setInlineContent(editContent?.inlineContent);
setAttachments(editContent?.attachments);
}
}, [editContent]);
const onClose = () => {
setIsOpen(false);
};
async function publishQDNResource() {
try {
if (!crowdfundId && !editId)
throw new Error("unable to locate crowdfund id");
if (!userAddress) throw new Error("Unable to locate user address");
let errorMsg = "";
let name = "";
if (username) {
name = username;
}
if (!name) {
errorMsg =
"Cannot publish without access to your name. Please authenticate.";
}
if (!title) {
errorMsg = "Cannot publish without a title";
}
if (editId && editContent?.user !== name) {
errorMsg = "Cannot publish another user's resource";
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: "error",
})
);
return;
}
const crowdfundObject: any = {
title,
createdAt: Date.now(),
version: 1,
attachments: [],
inlineContent,
};
const id = uid();
const attachmentArray: any[] = [];
const attachmentArrayToSave: any[] = [];
for (const attachment of attachments) {
const alreadyExits = !!attachment?.identifier;
if (alreadyExits) {
attachmentArray.push(attachment);
continue;
}
const id = uid();
const id2 = uid();
const identifier = `${ATTACHMENT_BASE}${id}_${id2}`;
const fileExtension = attachment?.name?.split(".")?.pop();
if (!fileExtension) {
throw new Error("One of your attachments does not have an extension");
}
let service = "FILE";
const type = attachment?.type;
if (type.startsWith("audio/")) {
service = "AUDIO";
}
if (type.startsWith("video/")) {
service = "VIDEO";
}
const obj: any = {
name,
service,
filename: attachment.name,
identifier,
file: attachment,
type: attachment?.type,
size: attachment?.size,
};
attachmentArray.push(obj);
attachmentArrayToSave.push(obj);
}
crowdfundObject.attachments = attachmentArray;
if (attachmentArrayToSave.length > 0) {
const multiplePublish = {
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
resources: [...attachmentArrayToSave],
};
await qortalRequest(multiplePublish);
}
const identifier = editId
? editId
: `${UPDATE_BASE}${crowdfundId?.slice(-12)}_${id}`;
const crowdfundObjectToBase64 = await objectToBase64(crowdfundObject);
const requestBody2: any = {
action: "PUBLISH_QDN_RESOURCE",
name: name,
service: "DOCUMENT",
data64: crowdfundObjectToBase64,
title: title.slice(0, 50),
// description: description,
identifier,
};
await qortalRequest(requestBody2);
dispatch(
setNotification({
msg: "Update published",
alertType: "success",
})
);
const objToStore: any = {
...crowdfundObject,
title: title,
// description: description,
id: identifier,
user: name,
created: Date.now(),
updated: Date.now(),
};
if (editId && onSubmit) {
onSubmit(objToStore);
}
dispatch(addToHashMap(objToStore));
setTitle("");
setInlineContent("");
setAttachments([]);
setIsOpen(false);
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to publish crowdfund",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to publish crowdfund",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to publish crowdfund",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
throw new Error("Failed to publish crowdfund");
}
}
return (
<>
{username && username === crowdfundName && (
<>
<CATContainer
style={{
alignItems:
editId && editContent?.user === username
? "flex-start"
: "center",
}}
>
{editId && editContent?.user === username ? (
<EditCrowdFundButton onClick={() => setIsOpen(true)}>
<>
<CreateIcon fontSize="small" />{" "}
<CrowdfundCardTitle
style={{ marginBottom: 0, fontSize: "17px" }}
>
Edit update
</CrowdfundCardTitle>
</>
</EditCrowdFundButton>
) : (
<AddCrowdFundButton onClick={() => setIsOpen(true)}>
<>
<AddIcon fontSize="large" />{" "}
<CrowdfundCardTitle>Add an update</CrowdfundCardTitle>
</>
</AddCrowdFundButton>
)}
</CATContainer>
</>
)}
<Modal
open={isOpen}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<ModalBody>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
{editId ? (
<NewCrowdfundTitle>Edit update</NewCrowdfundTitle>
) : (
<NewCrowdfundTitle>Add an update</NewCrowdfundTitle>
)}
<CloseNewUpdateModal
height="25px"
width="25px"
color={theme.palette.text.primary}
onClickFunc={onClose}
/>
</Box>
<CustomInputField
name="title"
label="Title of update"
variant="filled"
value={title}
onChange={e => setTitle(e.target.value)}
inputProps={{ maxLength: 180 }}
multiline
maxRows={3}
required
/>
<NewCrowdFundFont>Add necessary files - optional</NewCrowdFundFont>
<FileAttachment
setAttachments={setAttachments}
attachments={attachments}
/>
<NewCrowdFundFont>Write out your update</NewCrowdFundFont>
<ReactQuill
theme="snow"
value={inlineContent}
onChange={setInlineContent}
modules={modules}
/>
<CrowdfundActionButtonRow>
<CrowdfundActionButton
onClick={() => {
onClose();
}}
variant="outlined"
color="error"
style={{ color: "#c92727ff" }}
>
Cancel
</CrowdfundActionButton>
<CrowdfundActionButton
variant="contained"
onClick={() => {
publishQDNResource();
}}
>
Publish
</CrowdfundActionButton>
</CrowdfundActionButtonRow>
</ModalBody>
</Modal>
</>
);
};

89
src/components/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

56
src/components/ResponsiveImage.tsx

@ -0,0 +1,56 @@
import React, { useState, useEffect, CSSProperties } from "react";
import Skeleton from "@mui/material/Skeleton";
import { Box } from "@mui/material";
interface ResponsiveImageProps {
src: string;
width: number;
height: number;
alt?: string;
className?: string;
styles?: CSSProperties;
}
const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
src,
width,
height,
alt,
className,
styles,
}) => {
const [loading, setLoading] = useState(true);
return (
<>
{loading && (
<Skeleton
variant="rectangular"
style={{
width: "100%",
height: 0,
paddingBottom: `${(height / width) * 100}%`,
objectFit: "cover",
visibility: loading ? "visible" : "hidden",
borderRadius: "8px 8px 0px 0px",
}}
/>
)}
<img
onLoad={() => setLoading(false)}
src={src}
style={{
width: "100%",
height: "auto",
borderRadius: "8px 8px 0px 0px",
visibility: loading ? "hidden" : "visible",
position: loading ? "absolute" : "unset",
...(styles || {}),
}}
/>
</>
);
};
export default ResponsiveImage;

236
src/components/common/AudioPlayer.tsx

@ -0,0 +1,236 @@
import React, { useRef, useState, useEffect, useMemo, useContext } from "react";
import { useSelector } from "react-redux";
import { RootState } from "../../state/store";
import { MyContext } from "../../wrappers/DownloadWrapper";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
import Slider from "@mui/material/Slider";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import DownloadIcon from "@mui/icons-material/Download";
import FileElement from "./FileElement";
import {
FileAttachmentContainer,
FileAttachmentFont,
PlayerBox,
} from "../../pages/Crowdfund/Update-styles";
interface AudioPlayerProps {
name: string;
identifier: string;
service: string;
jsonId: string;
user: string;
filename: string;
fullFile?: any;
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({
name,
identifier,
service,
jsonId,
user,
filename,
fullFile,
}) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const { downloadVideo } = useContext(MyContext);
const reDownload = useRef<boolean>(false);
const [volume, setVolume] = useState(0.5);
const [isLoading, setIsLoading] = useState(false);
const [canPlay, setCanPlay] = useState(false);
const [startPlay, setStartPlay] = useState(false);
const { downloads } = useSelector((state: RootState) => state.global);
const download = useMemo(() => {
if (!downloads || !identifier) return {};
const findDownload = downloads[identifier];
if (!findDownload) return {};
return findDownload;
}, [downloads, identifier]);
const src = useMemo(() => {
return download?.url || "";
}, [download?.url]);
const resourceStatus = useMemo(() => {
return download?.status || {};
}, [download]);
const getSrc = React.useCallback(async () => {
if (!name || !identifier || !service || !jsonId || !user) return;
try {
downloadVideo({
name,
service,
identifier,
properties: {
jsonId,
user,
...fullFile,
},
});
} catch (error) {
console.error(error);
}
}, [identifier, name, service, jsonId, user]);
useEffect(() => {
const audio = audioRef.current;
const updateProgress = () => {
if (audio) {
setProgress((audio.currentTime / audio.duration) * 100);
}
};
if (audio) {
audio.addEventListener("timeupdate", updateProgress);
}
return () => {
if (audio) {
audio.removeEventListener("timeupdate", updateProgress);
}
};
}, []);
const togglePlayPause = () => {
if (!audioRef.current) return;
const audio = audioRef.current;
setStartPlay(true);
if (!src || resourceStatus?.status !== "READY") {
setIsLoading(true);
getSrc();
}
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
setIsPlaying(!isPlaying);
};
useEffect(() => {
if (
resourceStatus?.status === "DOWNLOADED" &&
reDownload?.current === false
) {
getSrc();
reDownload.current = true;
}
}, [getSrc, resourceStatus]);
const handleCanPlay = () => {
setIsLoading(false);
setCanPlay(true);
};
const handleVolumeChange = (e: Event, newValue: number | number[]) => {
const volume = Array.isArray(newValue) ? newValue[0] : newValue;
setVolume(volume);
};
const handleProgressClick = (e: Event, newValue: number | number[]) => {
const audio = audioRef.current;
const clickPositionInPercentage = Array.isArray(newValue)
? newValue[0]
: newValue;
if (audio) {
audio.currentTime = (clickPositionInPercentage * audio.duration) / 100;
}
};
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume;
}
}, [volume]);
return (
<PlayerBox>
<FileAttachmentContainer>
<FileAttachmentFont>{filename}</FileAttachmentFont>
<audio
autoPlay={true}
src={!startPlay ? "" : resourceStatus?.status === "READY" ? src : ""}
onCanPlay={handleCanPlay}
ref={audioRef}
/>
</FileAttachmentContainer>
<Box sx={{ display: "flex", alignItems: "center", pl: 1, pr: 1 }}>
{isLoading ? (
<Box sx={{ display: "flex", alignItems: "center" }}>
<CircularProgress size={20} />
<Typography variant="body2">{`${Math.round(
resourceStatus?.percentLoaded || 0
).toFixed(0)}% loaded`}</Typography>
</Box>
) : (
<>
<IconButton
onClick={togglePlayPause}
sx={{
margin: "0px",
padding: "0px",
marginRight: "5px",
}}
>
{isPlaying ? (
<PauseCircleOutlineIcon fontSize="large" />
) : (
<PlayCircleOutlineIcon fontSize="large" />
)}
</IconButton>
<Slider
min={0}
max={100}
value={progress}
onChange={handleProgressClick}
sx={{ ml: 1, mr: 1 }}
/>
<Slider
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolumeChange}
sx={{ ml: 1, mr: 1, width: "35%" }}
/>
</>
)}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
pl: 1,
pr: 1,
}}
>
{fullFile && (
<FileElement
fileInfo={fullFile}
title={fullFile?.filename}
customStyles={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<DownloadIcon />
</FileElement>
)}
</Box>
</PlayerBox>
);
};
export default AudioPlayer;

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

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

100
src/components/common/BlockedNamesModal/BlockedNamesModal.tsx

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

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

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

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

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

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

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

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

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

63
src/components/common/Countdown/Countdown-styles.tsx

@ -0,0 +1,63 @@
import { styled } from "@mui/system";
import { Box, Typography } from "@mui/material";
export const CountdownCard = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "15px",
width: "fit-content",
maxWidth: "450px",
minWidth: "450px",
padding: "25px",
border: `1px solid ${theme.palette.primary.light}`,
borderRadius: "8px",
}));
export const CountdownRow = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
alignItems: "center",
}));
export const CountdownCol = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
gap: "0px",
padding: "0px 2px",
alignItems: "center",
}));
export const CountdownFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "18px",
letterSpacing: 0,
fontWeight: 400,
color: theme.palette.text.primary,
}));
export const CountdownFontNumber = styled(Typography)(({ theme }) => ({
fontFamily: "Montserrat",
fontWeight: 300,
fontSize: "40px",
letterSpacing: 0,
lineHeight: "45px",
color: theme.palette.text.primary,
}));
export const CountdownContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "5px",
}));
export const EstimatedTimeRemainingFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "16px",
color: theme.palette.text.primary,
fontWeight: 400,
letterSpacing: 0,
userSelect: "none",
}));

142
src/components/common/Countdown/Countdown.tsx

@ -0,0 +1,142 @@
import React, { useEffect, useState } from "react";
import moment from "moment";
import { CircularProgress } from "@mui/material";
import {
CountdownCard,
CountdownCol,
CountdownContainer,
CountdownFont,
CountdownFontNumber,
CountdownRow,
EstimatedTimeRemainingFont,
} from "./Countdown-styles";
interface CountdownProps {
endDate: moment.Moment;
blocksRemaining: number | null;
loadingAtInfo: boolean;
ATCompleted: boolean;
}
export const Countdown: React.FC<CountdownProps> = ({
endDate,
blocksRemaining,
loadingAtInfo,
ATCompleted,
}) => {
const [timeRemainingDays, setTimeRemainingDays] = useState<number | null>(
null
);
const [timeRemainingHours, setTimeRemainingHours] = useState<number | null>(
null
);
const [timeRemainingMinutes, setTimeRemainingMinutes] = useState<
number | null
>(null);
// useEffect that runs the countdown timer
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null;
const updateCountdown = () => {
const now = moment();
const duration = moment.duration(endDate.diff(now));
if (duration.asMilliseconds() <= 0) {
setTimeRemainingDays(0);
setTimeRemainingHours(0);
setTimeRemainingMinutes(0);
if (intervalId) clearInterval(intervalId);
return;
}
const totalMinutes = duration.asMinutes();
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = Math.floor(totalMinutes % 60);
setTimeRemainingDays(days);
setTimeRemainingHours(hours);
setTimeRemainingMinutes(minutes);
};
// Ensure the crowdfund has not ended before running the countdown
if (!endDate || !blocksRemaining) return;
updateCountdown();
intervalId = setInterval(updateCountdown, 1000);
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [blocksRemaining, endDate]);
return (
<>
{!loadingAtInfo ? (
<CountdownCard>
{!ATCompleted ? (
<>
<CountdownRow style={{ alignItems: "flex-start" }}>
<CountdownContainer>
<EstimatedTimeRemainingFont>
Estimated Time Remaining
</EstimatedTimeRemainingFont>
<CountdownRow style={{ alignItems: "flex-start" }}>
<CountdownCol>
<CountdownFontNumber>
{timeRemainingDays}
</CountdownFontNumber>
<CountdownFont>{`Day${
timeRemainingDays === 1 ? "" : "s"
}`}</CountdownFont>
</CountdownCol>
<CountdownCol>
<CountdownFontNumber>:</CountdownFontNumber>
</CountdownCol>
<CountdownCol>
<CountdownFontNumber>
{timeRemainingHours}
</CountdownFontNumber>
<CountdownFont>{`Hour${
timeRemainingHours === 1 ? "" : "s"
}`}</CountdownFont>
</CountdownCol>
<CountdownCol>
<CountdownFontNumber>:</CountdownFontNumber>
</CountdownCol>
<CountdownCol>
<CountdownFontNumber>
{timeRemainingMinutes}
</CountdownFontNumber>
<CountdownFont>{`Minute${
timeRemainingMinutes === 1 ? "" : "s"
}`}</CountdownFont>
</CountdownCol>
</CountdownRow>
</CountdownContainer>
</CountdownRow>
<CountdownRow>
<CountdownCol>
<CountdownFont style={{ fontSize: "21px" }}>
Blocks Remaining: {blocksRemaining}
</CountdownFont>
<CountdownFont style={{ fontSize: "21px" }}>
updated every 30 seconds
</CountdownFont>
</CountdownCol>
</CountdownRow>
</>
) : (
<CountdownFont style={{ fontSize: "21px" }}>
Crowdfunding has ended.
</CountdownFont>
)}
</CountdownCard>
) : (
<CircularProgress />
)}
</>
);
};

29
src/components/common/DisplayHtml.tsx

@ -0,0 +1,29 @@
import { useMemo } from "react";
import DOMPurify from "dompurify";
import "react-quill/dist/quill.snow.css";
import "react-quill/dist/quill.core.css";
import "react-quill/dist/quill.bubble.css";
import { convertQortalLinks } from "../../utils/convertQortalAnchor";
import { CrowdfundInlineContent } from "../Crowdfund/Crowdfund-styles";
export const DisplayHtml = ({ html }) => {
const cleanContent = useMemo(() => {
if (!html) return null;
const sanitize: string = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
const anchorQortal = convertQortalLinks(sanitize);
return anchorQortal;
}, [html]);
if (!cleanContent) return null;
return (
<CrowdfundInlineContent>
<div
className="ql-editor"
dangerouslySetInnerHTML={{ __html: cleanContent }}
/>
</CrowdfundInlineContent>
);
};

48
src/components/common/Donate/Donate-styles.tsx

@ -0,0 +1,48 @@
import { styled } from "@mui/material/styles";
import { Box, Button, InputLabel } from "@mui/material";
import { changeLightness } from "qortal-app-utils";
const ButtonStyle = styled(Button)({
fontFamily: "Mulish",
fontWeight: "800",
fontSize: "21px",
lineHeight: "1.75",
textTransform: "uppercase",
minWidth: "64px",
padding: "15px 25px",
color: "#ffffff",
"&:disabled": {
filter: "brightness(0.8)",
},
});
export const DonateModalCol = styled(Box)({
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "400px",
justifyContent: "center",
gap: "20px",
});
export const CrowdfundPageDonateButton = styled(ButtonStyle)(({ theme }) => ({
backgroundColor: theme.palette.primary.main,
"&:hover": {
backgroundColor: changeLightness(theme.palette.primary.main, -10),
},
}));
export const DonorDetailsButton = styled(ButtonStyle)(({ theme }) => ({
backgroundColor: theme.palette.secondary.main,
"&:hover": {
backgroundColor: changeLightness(theme.palette.secondary.main, -10),
},
}));
export const DonateModalLabel = styled(InputLabel)(({ theme }) => ({
fontFamily: "Copse",
fontSize: "27px",
letterSpacing: "1px",
color: theme.palette.text.primary,
fontWeight: 400,
}));

235
src/components/common/Donate/Donate.tsx

@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
Tooltip,
useTheme,
} from "@mui/material";
import { useDispatch } from "react-redux";
import Portal from "../Portal";
import { setNotification } from "../../../state/features/notificationsSlice";
import {
CrowdfundPageDonateButton,
DonateModalCol,
DonateModalLabel,
} from "./Donate-styles";
import { QortalSVG } from "../../../assets/svgs/QortalSVG";
import BoundedNumericTextField from "../../../utils/BoundedNumericTextField";
import { getUserBalance, truncateNumber } from "qortal-app-utils";
interface DonateProps {
atAddress: string;
ATDonationPossible: boolean;
onSubmit: () => void;
onClose: () => void;
}
export const Donate = ({
onSubmit,
onClose,
atAddress,
ATDonationPossible,
}: DonateProps) => {
const dispatch = useDispatch();
const theme = useTheme();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [amount, setAmount] = useState<number>(0);
const [currentBalance, setCurrentBalance] = useState<string>("");
const resetValues = () => {
setAmount(0);
setIsOpen(false);
};
const sendCoin = async () => {
try {
if (!atAddress) return;
if (isNaN(amount)) return;
// Check one last time if the AT has finished and if so, don't send the coin
const url = `/at/${atAddress}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearch = await response.json();
if (response.status !== 200 || responseDataSearch?.isFinished) {
dispatch(
setNotification({
msg: "This crowdfund has ended",
alertType: "error",
})
);
resetValues();
return;
}
// Prevent them from sending a coin if there's 4 blocks left or less to avoid timing issues
const url2 = `/blocks/height`;
const blockHeightResponse = await fetch(url2, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const blockHeight = await blockHeightResponse.json();
const diff = +responseDataSearch?.sleepUntilHeight - +blockHeight;
if (diff <= 4) {
dispatch(
setNotification({
msg: "This crowdfund has ended",
alertType: "error",
})
);
resetValues();
return;
}
await qortalRequest({
action: "SEND_COIN",
coin: "QORT",
destinationAddress: atAddress,
amount: amount,
});
dispatch(
setNotification({
msg: "Donation successfully sent",
alertType: "success",
})
);
resetValues();
onSubmit();
} catch (error: any) {
let notificationObj: any = 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));
}
};
useEffect(() => {
getUserBalance().then(foundBalance => {
setCurrentBalance(truncateNumber(foundBalance, 2));
});
}, []);
return (
<Box
sx={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<Tooltip
title={`Support this crowdfund`}
arrow
disableHoverListener={!ATDonationPossible}
>
<Box
sx={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 1,
cursor: "pointer",
}}
>
<CrowdfundPageDonateButton
onClick={() => setIsOpen(prev => !prev)}
disabled={!ATDonationPossible}
variant="contained"
>
Donate Now
</CrowdfundPageDonateButton>
</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>
<DonateModalCol>
<DonateModalLabel htmlFor="standard-adornment-amount">
Amount
</DonateModalLabel>
<BoundedNumericTextField
style={{ fontFamily: "Mulish" }}
minValue={1}
maxValue={Number.MAX_SAFE_INTEGER}
id="standard-adornment-amount"
value={amount}
onChange={value => setAmount(+value)}
variant={"standard"}
allowDecimals={false}
allowNegatives={false}
addIconButtons={true}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<QortalSVG
height="20px"
width="20px"
color={theme.palette.text.primary}
/>
</InputAdornment>
),
}}
/>
</DonateModalCol>
{currentBalance ? (
<div>You have {currentBalance} QORT</div>
) : (
<></>
)}
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="error"
onClick={() => {
setIsOpen(false);
resetValues();
onClose();
}}
>
Close
</Button>
<Button
variant="contained"
onClick={sendCoin}
sx={{ color: "white" }}
>
Send Coin
</Button>
</DialogActions>
</Dialog>
</Portal>
)}
</Box>
);
};

90
src/components/common/Donate/DonorInfo.tsx

@ -0,0 +1,90 @@
import { DonorDetailsButton } from "./Donate-styles";
import { Tooltip } from "@mui/material";
import {
addStringNumbers,
getAccountNames,
removeTrailingZeros,
SearchTransactionResponse,
} from "qortal-app-utils";
import React, { useEffect, useState } from "react";
import DonorModal from "./DonorModal";
interface DonorInfoProps {
rawDonorData: SearchTransactionResponse[];
aggregateDonorData?: boolean;
}
export type ViewableDonorData = {
nameIfExists: string;
address: string;
amount: string;
};
const DonorInfo = ({
rawDonorData,
aggregateDonorData = true,
}: DonorInfoProps) => {
const [displayModal, setDisplayModal] = useState<boolean>(false);
const [donorData, setDonorData] = useState<ViewableDonorData[]>([]);
const processOneDonor = (
donorArray: ViewableDonorData[],
donor: ViewableDonorData
) => {
const donorIndex = donorArray.findIndex(d => {
return d.address === donor.address;
});
if (aggregateDonorData && donorIndex >= 0) {
donorArray[donorIndex].amount = addStringNumbers(
donorArray[donorIndex].amount,
donor.amount
);
} else {
donorArray.push(donor);
}
};
const processAllDonors = async () => {
const processedDonorData: ViewableDonorData[] = [];
Promise.all(
rawDonorData.map(({ creatorAddress, amount }) => {
return getAccountNames(creatorAddress);
})
).then(responseArray => {
responseArray.map((response, index) => {
processOneDonor(processedDonorData, {
nameIfExists: response[0].name,
address: response[0].owner,
amount: removeTrailingZeros(rawDonorData[index].amount),
});
});
setDonorData(processedDonorData);
});
};
useEffect(() => {
processAllDonors();
}, [rawDonorData]);
return (
<>
<Tooltip title={`Show list of donors`} arrow placement="bottom">
<DonorDetailsButton
variant="contained"
onClick={() => {
setDisplayModal(true);
}}
>
Donor Details
</DonorDetailsButton>
</Tooltip>
<DonorModal
donorData={donorData}
open={displayModal}
closeModal={() => setDisplayModal(false)}
/>
</>
);
};
export default DonorInfo;

82
src/components/common/Donate/DonorModal.tsx

@ -0,0 +1,82 @@
import {
Button,
Modal,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import { ModalBody } from "../../Crowdfund/Crowdfund-styles";
import Box from "@mui/material/Box";
import { ViewableDonorData } from "./DonorInfo";
import { truncateNumber } from "qortal-app-utils";
interface DonorModalProps {
donorData: ViewableDonorData[];
closeModal: () => void;
open: boolean;
}
const DonorModal = ({ donorData, closeModal, open }: DonorModalProps) => {
const getAverageDonation = () => {
const donorCount = donorData.length;
if (donorCount === 0) return 0;
let donorSum = 0;
donorData.map(data => {
donorSum += Number(data.amount);
});
const average = donorSum / donorCount;
return truncateNumber(average, 2);
};
return (
<Modal
open={open}
aria-labelledby="modal-title"
aria-describedby="modal-description"
onClose={closeModal}
>
<ModalBody>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<TableContainer sx={{ maxHeight: "300px" }}>
<Table align="center" stickyHeader>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Address</TableCell>
</TableRow>
</TableHead>
<TableBody>
{donorData.map((donorData, index) => (
<TableRow key={donorData.address + index.toString()}>
<TableCell>{index + 1}</TableCell>
<TableCell>{donorData.nameIfExists}</TableCell>
<TableCell>{donorData.amount}</TableCell>
<TableCell>{donorData.address}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
<Stack>
<h4>Total # of Donations: {donorData.length}</h4>
<h4>Average Donation Amount: {getAverageDonation()}</h4>
</Stack>
<Button onClick={closeModal}>Close</Button>
</ModalBody>
</Modal>
);
};
export default DonorModal;

204
src/components/common/DownloadTaskManager.tsx

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

419
src/components/common/FileElement.tsx

@ -0,0 +1,419 @@
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 { 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 { base64ToUint8Array } from "../../utils/toBase64";
import { setNotification } from "../../state/features/notificationsSlice";
const Widget = styled("div")(({ theme }) => ({
padding: 8,
borderRadius: 10,
maxWidth: 350,
position: "relative",
zIndex: 1,
backdropFilter: "blur(40px)",
background: "skyblue",
transition: "0.2s all",
"&:hover": {
opacity: 0.75,
},
}));
const CoverImage = styled("div")({
width: 40,
height: 40,
objectFit: "cover",
overflow: "hidden",
flexShrink: 0,
borderRadius: 8,
backgroundColor: "rgba(0,0,0,0.08)",
"& > img": {
width: "100%",
},
});
interface IAudioElement {
title: string;
description?: string;
author?: string;
fileInfo?: any;
postId?: string;
user?: string;
children?: React.ReactNode;
mimeType?: string;
disable?: boolean;
mode?: string;
otherUser?: string;
customStyles?: any;
}
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,
customStyles,
}: 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 { downloads } = useSelector((state: RootState) => state.global);
const hasCommencedDownload = React.useRef(false);
const dispatch = useDispatch();
const download = React.useMemo(() => {
if (!downloads || !fileInfo?.identifier) return {};
const findDownload = downloads[fileInfo?.identifier];
if (!findDownload) return {};
return findDownload;
}, [downloads, fileInfo]);
const resourceStatus = React.useMemo(() => {
return download?.status || {};
}, [download]);
const retryDownload = React.useRef(0);
const handlePlay = async () => {
if (disable) return;
hasCommencedDownload.current = true;
if (
resourceStatus?.status === "READY" &&
download?.url &&
download?.properties?.filename
) {
if (downloadLoader) return;
dispatch(
setNotification({
msg: "Saving file... please wait",
alertType: "info",
})
);
setDownloadLoader(true);
try {
const { name, service, identifier } = fileInfo;
const url = `/arbitrary/${service}/${name}/${identifier}`;
fetch(url)
.then(response => response.blob())
.then(async blob => {
await qortalRequest({
action: "SAVE_FILE",
blob,
filename: download?.properties?.filename,
mimeType:
download?.properties?.mimeType ||
download?.properties?.type ||
"",
});
})
.catch(error => {
console.error("Error fetching the video:", error);
});
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to send message",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to send message",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to send message",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
} finally {
setDownloadLoader(false);
}
return;
}
const { name, service, identifier } = fileInfo;
let filename = fileProperties?.filename;
let mimeType = fileProperties?.mimeType;
if (!fileProperties) {
try {
dispatch(
setNotification({
msg: "Downloading file... please wait",
alertType: "info",
})
);
const 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) {
if (retryDownload.current === 0) {
handlePlay();
retryDownload.current = 1;
return;
}
setIsLoading(false);
dispatch(
setNotification({
msg: error?.message || "Error with download. Please try again",
alertType: "error",
})
);
}
}
if (!filename) return;
setIsLoading(true);
downloadVideo({
name,
service,
identifier,
properties: {
...fileInfo,
},
});
};
React.useEffect(() => {
if (
resourceStatus?.status === "READY" &&
download?.url &&
download?.properties?.filename &&
hasCommencedDownload.current
) {
setIsLoading(false);
dispatch(
setNotification({
msg: "Download completed. Click to save file",
alertType: "info",
})
);
}
}, [resourceStatus, download]);
return (
<Box
onClick={handlePlay}
sx={{
width: "100%",
overflow: "hidden",
position: "relative",
cursor: "pointer",
...(customStyles || {}),
}}
>
{children && (
<Box
sx={{
display: "flex",
alignItems: "center",
position: "relative",
gap: "7px",
}}
>
{children}{" "}
{(resourceStatus.status && resourceStatus?.status !== "READY") ||
isLoading ? (
<>
<CircularProgress color="secondary" size={14} />
<Typography variant="body2">{`${Math.round(
resourceStatus?.percentLoaded || 0
).toFixed(0)}% loaded`}</Typography>
</>
) : resourceStatus?.status === "READY" ? (
<>
<Typography
sx={{
fontSize: "14px",
}}
>
Ready to save: click here
</Typography>
{downloadLoader && (
<CircularProgress color="secondary" size={14} />
)}
</>
) : null}
</Box>
)}
{!children && (
<Widget>
<Box sx={{ display: "flex", alignItems: "center" }}>
<CoverImage>
<AttachFileIcon
sx={{
width: "90%",
height: "auto",
}}
/>
</CoverImage>
<Box sx={{ ml: 1.5, minWidth: 0 }}>
<Typography
variant="caption"
color="text.secondary"
fontWeight={500}
>
{author}
</Typography>
<Typography
noWrap
sx={{
fontSize: "16px",
}}
>
<b>{title}</b>
</Typography>
<Typography
noWrap
letterSpacing={-0.25}
sx={{
fontSize: "14px",
}}
>
{description}
</Typography>
{mimeType && (
<Typography
noWrap
letterSpacing={-0.25}
sx={{
fontSize: "12px",
}}
>
{mimeType}
</Typography>
)}
</Box>
</Box>
{((resourceStatus.status && resourceStatus?.status !== "READY") ||
isLoading) && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={4999}
bgcolor="rgba(0, 0, 0, 0.6)"
sx={{
display: "flex",
flexDirection: "column",
gap: "10px",
padding: "8px",
borderRadius: "10px",
}}
>
<CircularProgress color="secondary" />
{resourceStatus && (
<Typography
variant="subtitle2"
component="div"
sx={{
color: "white",
fontSize: "14px",
}}
>
{resourceStatus?.status === "REFETCHING" ? (
<>
<>
{(
(resourceStatus?.localChunkCount /
resourceStatus?.totalChunkCount) *
100
)?.toFixed(0)}
%
</>
<> Refetching in 2 minutes</>
</>
) : resourceStatus?.status === "DOWNLOADED" ? (
<>Download Completed: building file...</>
) : resourceStatus?.status !== "READY" ? (
<>
{(
(resourceStatus?.localChunkCount /
resourceStatus?.totalChunkCount) *
100
)?.toFixed(0)}
%
</>
) : (
<>Download Completed: fetching file...</>
)}
</Typography>
)}
</Box>
)}
{resourceStatus?.status === "READY" &&
download?.url &&
download?.properties?.filename && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={4999}
bgcolor="rgba(0, 0, 0, 0.6)"
sx={{
display: "flex",
flexDirection: "row",
gap: "10px",
padding: "8px",
borderRadius: "10px",
}}
>
<Typography
variant="subtitle2"
component="div"
sx={{
color: "white",
fontSize: "14px",
}}
>
Ready to save: click here
</Typography>
{downloadLoader && (
<CircularProgress color="secondary" size={14} />
)}
</Box>
)}
</Widget>
)}
</Box>
);
}

48
src/components/common/LazyLoad.tsx

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

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

42
src/components/common/PageLoader.tsx

@ -0,0 +1,42 @@
import React from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import { Box, 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

92
src/components/common/Progress/Progress-styles.tsx

@ -0,0 +1,92 @@
import { styled } from "@mui/material/styles";
import { Box, CircularProgress, Typography } from "@mui/material";
export const FundAmountsCol = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
gap: "7px",
}));
export const FundAmountsRow = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: "15px",
}));
export const FundAmount = styled(Typography)(({ theme }) => ({
fontFamily: "Montserrat",
fontWeight: 500,
fontSize: "28px",
letterSpacing: "0.2px",
userSelect: "none",
color:
theme.palette.mode === "light"
? theme.palette.primary.dark
: theme.palette.primary.light,
}));
export const FundAmountNumber = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "5px",
fontFamily: "Montserrat",
fontWeight: 500,
fontSize: "28px",
letterSpacing: "0.2px",
userSelect: "none",
color: theme.palette.text.primary,
"& span": {
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
maxWidth: "100%",
fontSize: "28px",
width: "100px",
},
}));
export const ProgressRow = styled(Box)(({ theme }) => ({
display: "grid",
gridTemplateColumns: "1fr 1fr",
alignItems: "center",
width: "fit-content",
minWidth: "450px",
maxWidth: "450px",
padding: "25px",
border: `1px solid ${theme.palette.primary.light}`,
borderRadius: "8px",
}));
export const CustomCircularProgress = styled(CircularProgress)(({ theme }) => ({
position: "relative",
color:
theme.palette.mode === "light"
? theme.palette.primary.dark
: theme.palette.primary.light,
justifySelf: "center",
"&::before": {
content: '""',
display: "block",
position: "absolute",
top: "50%",
left: "50%",
width: "calc(100% - 2px)",
height: "calc(100% - 2px)",
borderRadius: "50%",
background:
theme.palette.mode === "dark"
? `radial-gradient(circle at center, transparent 34%, #fffffff0 34%)`
: `radial-gradient(circle at center, transparent 34%, #e2e0e0ee 34%)`,
transform: "translate(-50%, -50%)",
zIndex: -1,
},
"& .MuiCircularProgress-circle": {
zIndex: 1,
},
}));

65
src/components/common/Progress/Progress.tsx

@ -0,0 +1,65 @@
import { useTheme } from "@mui/material";
import {
CustomCircularProgress,
FundAmount,
FundAmountNumber,
FundAmountsCol,
FundAmountsRow,
ProgressRow,
} from "./Progress-styles";
import { QortalSVG } from "../../../assets/svgs/QortalSVG";
interface CrowdfundProgressProps {
achieved?: number | null;
raised: number;
goal: number;
}
export const CrowdfundProgress: React.FC<CrowdfundProgressProps> = ({
achieved,
raised,
goal,
}) => {
const theme = useTheme();
const progress = achieved
? (+achieved / +goal) * 100
: (+raised / +goal) * 100;
return (
<ProgressRow>
<FundAmountsCol>
<FundAmountsRow>
<FundAmount>{achieved ? "Achieved:" : "Raised:"}</FundAmount>
<FundAmountNumber>
<QortalSVG
height={"22"}
width={"22"}
color={theme.palette.text.primary}
/>
<span>
{achieved ? +Math.round(achieved) : +Math.round(raised)}
</span>
</FundAmountNumber>
</FundAmountsRow>
<FundAmountsRow>
<FundAmount>Goal:</FundAmount>
<FundAmountNumber>
<QortalSVG
height={"22"}
width={"22"}
color={theme.palette.text.primary}
/>
<span>{+goal}</span>
</FundAmountNumber>
</FundAmountsRow>
</FundAmountsCol>
<CustomCircularProgress
size={115}
thickness={12}
variant="determinate"
// value less than 1 and greater than 0, dispaly 1, else display progress
value={progress < 1 && progress > 0 ? 1 : progress}
/>
</ProgressRow>
);
};

46
src/components/common/Reviews/AddReview/AddReview-styles.tsx

@ -0,0 +1,46 @@
import { styled } from "@mui/system";
import { Box, Button, TextareaAutosize } from "@mui/material";
export const AddReviewHeader = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
alignItems: "center",
}));
export const AddReviewContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "25px",
justifyContent: "flex-start",
flexGrow: 1,
width: "100%",
}));
export const AddReviewDescription = styled(TextareaAutosize)(({ theme }) => ({
width: "100%",
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: "0px",
lineHeight: "1.5",
padding: "12px",
borderRadius: "12px 12px 0 12px",
color: theme.palette.text.primary,
background: theme.palette.background.default,
resize: "none",
"& placeholder": {
color: theme.palette.mode === "light" ? "#808183" : "#edeef0",
fontFamily: "Mulish",
fontWeight: 400,
fontSize: "19px",
letterSpacing: "0px",
},
border: `1px solid ${theme.palette.background.paper}`,
"&:hover": {
borderColor: theme.palette.secondary.main,
},
"&:focus": {
borderColor: theme.palette.secondary.main,
},
}));

295
src/components/common/Reviews/AddReview/AddReview.tsx

@ -0,0 +1,295 @@
import { FC, useState, useMemo } from "react";
import {
AddReviewContainer,
AddReviewDescription,
AddReviewHeader,
} from "./AddReview-styles";
import { Rating } from "@mui/material";
import { CustomInputField } from "../../../Crowdfund/Crowdfund-styles";
import {
CreateButton,
CloseButton,
CloseButtonRow,
Divider,
OwnerName,
} from "../QFundOwnerReviews-styles";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../../../state/store";
import ShortUniqueId from "short-unique-id";
import { setNotification } from "../../../../state/features/notificationsSlice";
import { objectToBase64 } from "../../../../utils/toBase64";
import {
addToHashMapOwnerReviews,
addToReviews,
} from "../../../../state/features/globalSlice";
import { generateReviewId } from "../../../../utils/generateReviewId";
/* Reviews notes
Prevent them from adding a review to their own store
Filter their own review
Make sure user has at least one store order before being able to leave a review
Get first 100 reviews for the average (without metadata)
*/
interface AddReviewProps {
QFundId: string;
QFundOwner: string;
setOpenLeaveReview: (open: boolean) => void;
}
export const AddReview: FC<AddReviewProps> = ({
QFundId,
QFundOwner,
setOpenLeaveReview,
}) => {
const dispatch = useDispatch();
const username = useSelector((state: RootState) => state.auth?.user?.name);
const [rating, setRating] = useState<number | null>(null);
const [reviewTitle, setReviewTitle] = useState<string>("");
const [reviewDescription, setReviewDescription] = useState<string>("");
// Verify if review identifier already exists
const verifyIfReviewIdExists = async (
username: string,
identifier: string
) => {
try {
const response = await qortalRequest({
action: "LIST_QDN_RESOURCES",
service: "DOCUMENT",
name: username,
identifier: identifier,
includeMetadata: true,
limit: 1,
});
if (response?.resources?.length > 0) {
return true;
}
return false;
} catch (err) {
console.log(err);
return false;
}
};
// Add review to QDN
const addReviewFunc = async () => {
let ownerName = "";
let reviewId = "";
let errorMsg = "";
let userName = "";
let ownerRegistrationNumber;
if (QFundOwner) {
ownerName = QFundOwner;
}
if (username) {
userName = username;
}
// Get person's name registration number
try {
const QFundOwnerRegistration = await qortalRequest({
action: "GET_NAME_DATA",
name: ownerName,
});
if (Object.keys(QFundOwnerRegistration).length > 0) {
ownerRegistrationNumber = QFundOwnerRegistration.registered;
}
} catch (error) {
console.error(error);
}
// Validation
if (!ownerName) {
errorMsg =
"Cannot send a message without a access to the Q-Fund Owner's name!";
}
if (!userName) {
errorMsg = "Cannot add a review without having a name!";
}
if (!QFundId) {
errorMsg = "Cannot add a review without having a Q-Fund ID!";
}
if (!ownerRegistrationNumber) {
errorMsg =
"Cannot add a review without having a Q-Fund Owner's registration number!";
}
if (!rating || !reviewTitle || !reviewDescription) {
errorMsg =
"Cannot add a review without a rating, title, and description!";
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: "error",
})
);
throw new Error(errorMsg);
}
if (QFundOwner && QFundId && ownerRegistrationNumber && rating) {
// Create identifier for the review
reviewId = generateReviewId(
QFundOwner,
QFundId,
ownerRegistrationNumber,
rating
);
}
// Check if review identifier already exists
const doesExist = await verifyIfReviewIdExists(userName, reviewId);
if (doesExist) {
throw new Error(
"The review identifier already exists! Try changing your review's title"
);
}
// Resource raw data
const reviewObj = {
title: reviewTitle,
description: reviewDescription,
rating: rating,
created: Date.now(),
};
const reviewToBase64 = await objectToBase64(reviewObj);
try {
// Publish Review to QDN
const resourceResponse = await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
name: userName,
service: "DOCUMENT",
identifier: reviewId,
data64: reviewToBase64,
filename: "review.json",
// Resource metadata down here
title: reviewTitle.slice(0, 60),
description: reviewDescription.slice(0, 150),
});
setOpenLeaveReview(false);
dispatch(
addToReviews({
id: reviewId,
name: userName,
created: Date.now(),
updated: Date.now(),
title: reviewTitle.slice(0, 60),
description: reviewDescription.slice(0, 150),
rating: rating,
})
);
dispatch(
addToHashMapOwnerReviews({
id: reviewId,
name: userName,
created: Date.now(),
updated: Date.now(),
title: reviewTitle,
description: reviewDescription,
rating: rating,
isValid: true,
})
);
dispatch(
setNotification({
alertType: "success",
msg: "Added Review Successfully!",
})
);
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to create review",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to create review",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to create review",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
if (error instanceof Error) {
throw new Error(error.message);
} else {
throw new Error("An unknown error occurred");
}
}
};
return (
<AddReviewContainer>
<AddReviewHeader>
<OwnerName>{`Leave a review for ${QFundOwner}`}</OwnerName>
</AddReviewHeader>
<Divider />
<AddReviewContainer style={{ padding: 0 }}>
<AddReviewContainer style={{ padding: 0, gap: "15px" }}>
<Rating
onChange={(
e: React.ChangeEvent<object>,
newValue: number | null
) => {
setRating(newValue);
}}
precision={0.5}
value={rating}
style={{ fontSize: "55px", paddingTop: "5px" }}
/>
<CustomInputField
name="title"
label="Q-Fund Owner Review Title"
variant="filled"
value={reviewTitle}
onChange={e => setReviewTitle(e.target.value as string)}
inputProps={{ maxLength: 180 }}
required
style={{ width: "100%" }}
/>
<AddReviewDescription
aria-label="review-description"
draggable={false}
minRows={3}
maxRows={10}
placeholder="Write a Q-Fund owner review..."
value={reviewDescription}
onChange={e => setReviewDescription(e.target.value as string)}
required
/>
</AddReviewContainer>
<CloseButtonRow style={{ gap: "10px" }}>
<CloseButton
variant="outlined"
color="error"
onClick={() => {
setOpenLeaveReview(false);
}}
>
Close
</CloseButton>
<CreateButton variant="contained" onClick={addReviewFunc}>
Add Review
</CreateButton>
</CloseButtonRow>
</AddReviewContainer>
</AddReviewContainer>
);
};

86
src/components/common/Reviews/QFundOwnerReviewCard.tsx

@ -0,0 +1,86 @@
import { useState, FC, useEffect } from "react";
import { Rating } from "@mui/material";
import {
ReviewContainer,
ReviewDateFont,
ReviewDescriptionFont,
ReviewHeader,
ReviewTitleFont,
ReviewTitleRow,
ReviewUsernameFont,
} from "./QFundOwnerReviews-styles";
import moment from "moment";
import { useFetchOwnerReviews } from "../../../hooks/useFetchOwnerReviews";
import { useSelector } from "react-redux";
import { RootState } from "../../../state/store";
import { OwnerReview } from "../../../state/features/globalSlice";
interface QFundOwnerReviewCardProps {
review: OwnerReview;
}
export const QFundOwnerReviewCard: FC<QFundOwnerReviewCardProps> = ({
review,
}) => {
const [showCompleteReview, setShowCompleteReview] = useState<boolean>(false);
const [fullStoreTitle, setFullStoreTitle] = useState<string>("");
const [fullStoreDescription, setFullStoreDescription] = useState<string>("");
const hashMapOwnerReviews = useSelector(
(state: RootState) => state.global.hashMapOwnerReviews
);
const { created, name, title, rating, description, id, updated } = review;
const { getReview, checkAndUpdateResource } = useFetchOwnerReviews();
const handleFetchReviewRawData = async () => {
try {
if (name && id) {
// avoid fetching the same review twice on QDN if it's already in the hashmap
const res = checkAndUpdateResource({
id,
updated,
});
// if the review is not in the hashmap, fetch it from QDN
if (res) {
getReview(name, id, review);
}
}
} catch (error) {
console.error(error);
}
};
useEffect(() => {
Object.keys(hashMapOwnerReviews).find(key => {
if (key === review.id) {
setShowCompleteReview(true);
setFullStoreTitle(hashMapOwnerReviews[key].title);
setFullStoreDescription(hashMapOwnerReviews[key].description);
}
});
}, [hashMapOwnerReviews]);
return (
<ReviewContainer
onClick={() => {
setShowCompleteReview(true);
handleFetchReviewRawData();
}}
showCompleteReview={showCompleteReview ? true : false}
>
<ReviewHeader>
<ReviewUsernameFont>{name}</ReviewUsernameFont>
<ReviewTitleRow>
<ReviewTitleFont>{fullStoreTitle || title}</ReviewTitleFont>
<Rating precision={0.5} value={rating} readOnly />
</ReviewTitleRow>
<ReviewDateFont>{moment(created).format("llll")}</ReviewDateFont>
</ReviewHeader>
<ReviewDescriptionFont>
{fullStoreDescription || description}
</ReviewDescriptionFont>
</ReviewContainer>
);
};

283
src/components/common/Reviews/QFundOwnerReviews-styles.tsx

@ -0,0 +1,283 @@
import { styled } from "@mui/material/styles";
import { ReusableModal } from "../../modals/ReusableModal";
import { Box, Button, Typography } from "@mui/material";
import { TimesSVG } from "../../../assets/svgs/TimesSVG";
interface OwnerReviewsProps {
showCompleteReview: boolean;
}
export const AddReviewButton = styled(Button)(({ theme }) => ({
display: "flex",
alignItems: "center",
padding: "4px 15px",
gap: "10px",
fontFamily: "Livvic",
fontSize: "16px",
width: "auto",
color: theme.palette.mode === "dark" ? "#000000" : "#ffffff",
backgroundColor: theme.palette.mode === "dark" ? "#ffffff" : "#000000",
border: "none",
borderRadius: "5px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
backgroundColor: theme.palette.mode === "dark" ? "#ffffff" : "#000000",
boxShadow:
theme.palette.mode === "dark"
? "0px 8px 10px 1px hsla(0,0%,0%,0.14), 0px 3px 14px 2px hsla(0,0%,0%,0.12), 0px 5px 5px -3px hsla(0,0%,0%,0.2)"
: "rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;",
},
}));
export const AverageReviewContainer = styled(Box)({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
maxHeight: "200px",
width: "100%",
});
export const ReviewsFont = styled(Typography)(({ theme }) => ({
textAlign: "center",
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: "0px",
color: theme.palette.text.primary,
userSelect: "none",
marginBottom: "5px",
}));
export const AverageReviewNumber = styled(Typography)(({ theme }) => ({
fontFamily: "Raleway",
fontSize: "60px",
fontWeight: 600,
letterSpacing: "2px",
color: theme.palette.text.primary,
userSelect: "none",
lineHeight: "35px",
marginBottom: "25px",
}));
export const TotalReviewsFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
color: theme.palette.text.primary,
userSelect: "none",
opacity: 0.8,
letterSpacing: 0,
}));
export const ReviewContainer = styled(Box)<OwnerReviewsProps>(
({ theme, showCompleteReview }) => ({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "flex-start",
gap: "10px",
padding: "5px",
borderRadius: "5px",
width: "100%",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: showCompleteReview ? "auto" : "pointer",
backgroundColor: showCompleteReview
? "transparent"
: theme.palette.mode === "light"
? "#d3d3d3ac"
: "#aeabab1e",
},
})
);
export const ReviewHeader = styled(Box)({
display: "flex",
flexDirection: "column",
gap: "1px",
});
export const ReviewTitleRow = styled(Box)({
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: "15px",
});
export const ReviewUsernameFont = styled(Box)(({ theme }) => ({
fontFamily: "Montserrat",
fontSize: "17px",
fontWeight: 400,
letterSpacing: "0.3px",
color: theme.palette.text.primary,
}));
export const ReviewTitleFont = styled(Box)(({ theme }) => ({
fontFamily: "Montserrat, sans-serif",
fontSize: "18px",
fontWeight: 500,
letterSpacing: "-0.3px",
color: theme.palette.text.primary,
}));
export const ReviewDateFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "16px",
fontWeight: 400,
letterSpacing: "0px",
color: theme.palette.text.primary,
opacity: 0.8,
}));
export const ReviewDescriptionFont = styled(Box)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: "0px",
color: theme.palette.text.primary,
}));
export const OwnerReviewsContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
flexGrow: 1,
gap: "30px",
overflowY: "auto",
"&::-webkit-scrollbar-track": {
backgroundColor: "transparent",
},
"&::-webkit-scrollbar-track:hover": {
backgroundColor: "transparent",
},
"&::-webkit-scrollbar": {
width: "8px",
height: "10px",
backgroundColor: "transparent",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.mode === "light" ? "#d3d9e1" : "#414763",
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
},
"&::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.mode === "light" ? "#b7bcc4" : "#40455f",
},
}));
export const CloseIconModal = styled(TimesSVG)({
position: "absolute",
top: "15px",
right: "5px",
transition: "all 0.2s ease-in-out",
"&:hover": {
cursor: "pointer",
transform: "scale(1.1)",
},
});
export const ReusableModalStyled = styled(ReusableModal)(({ theme }) => ({
"& [class$='MuiBox-root']": {
"&::-webkit-scrollbar-track": {
backgroundColor: "transparent",
},
"&::-webkit-scrollbar-track:hover": {
backgroundColor: "transparent",
},
"&::-webkit-scrollbar": {
width: "8px",
height: "10px",
backgroundColor: "transparent",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.mode === "light" ? "#d3d9e1" : "#414763",
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
},
"&::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.mode === "light" ? "#b7bcc4" : "#40455f",
},
},
}));
export const OwnerAvatar = styled("img")({
width: "90px",
height: "90px",
borderRadius: "50%",
objectFit: "cover",
});
export const OwnerName = styled(Box)(({ theme }) => ({
display: "flex",
justifySelf: "center",
userSelect: "none",
fontFamily: "Copse",
fontWeight: 400,
fontSize: "25px",
letterSpacing: "0.5px",
color: theme.palette.text.primary,
}));
export const Divider = styled(Box)(({ theme }) => ({
width: "100%",
height: "2px",
backgroundColor: theme.palette.text.primary,
padding: "0 10px",
divider: 0.7,
}));
export const HeaderRow = styled(Box)(({ theme }) => ({
display: "grid",
gridTemplateColumns: "auto 1fr",
alignItems: "center",
justifyContent: "flex-start",
width: "100%",
padding: "10px 15px",
fontFamily: "Copse, sans-serif",
fontSize: "23px",
color: theme.palette.text.primary,
}));
export const CardDetailsContainer = styled(Box)({
display: "flex",
flexDirection: "column",
flexGrow: 1,
});
export const OwnerNameCol = styled(Box)({
display: "flex",
alignItems: "center",
flexDirection: "column",
});
export const CloseButtonRow = styled(Box)({
display: "flex",
gap: 1,
justifyContent: "flex-end",
});
export const CreateButton = styled(Button)({
fontFamily: "Montserrat",
fontWeight: 400,
letterSpacing: "0.2px",
textTransform: "uppercase",
fontSize: "15px",
backgroundColor: "#32d43a",
color: "black",
"&:hover": {
cursor: "pointer",
backgroundColor: "#2bb131",
},
});
export const CloseButton = styled(Button)({
fontFamily: "Montserrat",
fontWeight: 400,
letterSpacing: "0.2px",
textTransform: "uppercase",
fontSize: "15px",
});

245
src/components/common/Reviews/QFundOwnerReviews.tsx

@ -0,0 +1,245 @@
import { FC, useState, useCallback, useEffect, useMemo } from "react";
import { CircularProgress, Grid, Rating, useTheme } from "@mui/material";
import {
CardDetailsContainer,
Divider,
HeaderRow,
OwnerAvatar,
OwnerName,
OwnerNameCol,
OwnerReviewsContainer,
} from "./QFundOwnerReviews-styles";
import { StarSVG } from "../../../assets/svgs/StarSVG";
import {
AddReviewButton,
AverageReviewContainer,
AverageReviewNumber,
CloseIconModal,
ReviewsFont,
TotalReviewsFont,
} from "./QFundOwnerReviews-styles";
import { QFundOwnerReviewCard } from "./QFundOwnerReviewCard";
import { ReusableModal } from "../../modals/ReusableModal";
import { useDispatch, useSelector } from "react-redux";
import LazyLoad from "../../../components/common/LazyLoad";
import { RootState } from "../../../state/store";
import { AddReview } from "./AddReview/AddReview";
import { REVIEW_BASE } from "../../../constants";
import {
OwnerReview,
upsertReviews,
} from "../../../state/features/globalSlice";
import { AccountCircleSVG } from "../../../assets/svgs/AccountCircleSVG";
// Fetch 100 reviews from the crowdfund owner
// Average reviews from the crowdfund owner
interface QFundOwnerReviewsProps {
QFundId: string;
QFundOwner: string;
QFundOwnerAvatar: string;
QFundOwnerRegisteredNumber: number | null;
averageOwnerRating: number | null;
setOpenQFundOwnerReviews: (open: boolean) => void;
}
export const QFundOwnerReviews: FC<QFundOwnerReviewsProps> = ({
QFundId,
QFundOwner,
QFundOwnerAvatar,
QFundOwnerRegisteredNumber,
averageOwnerRating,
setOpenQFundOwnerReviews,
}) => {
const theme = useTheme();
const dispatch = useDispatch();
const ownerReviews = useSelector(
(state: RootState) => state.global.ownerReviews
);
const [openLeaveReview, setOpenLeaveReview] = useState<boolean>(false);
const [loadingReviews, setLoadingReviews] = useState<boolean>(false);
const [reviewIdentifier, setReviewIdentifier] = useState<string>("");
// Fetch all the owner's reviews (regardless of the Q-Fund) resources from QDN
const getQFundOwnerReviews = useCallback(async () => {
if (!QFundId || !QFundOwner) return;
try {
setLoadingReviews(true);
let ownerRegistrationNumber;
if (QFundOwnerRegisteredNumber) {
ownerRegistrationNumber = QFundOwnerRegisteredNumber;
} else {
throw new Error("No registered number found for QFund owner name");
}
const offset = ownerReviews.length;
const shortQFundOwner = QFundOwner.slice(0, 15);
// Those first three constants will remain the same no matter which crowdfund the owner made
const query = `${REVIEW_BASE}-${shortQFundOwner}-${ownerRegistrationNumber}`;
// Set the review identifier in the local state so we can filter only the reviews that are for the current Q-Fund
setReviewIdentifier(query);
// Since it the url includes /resources, you know you're fetching the resources and not the raw data
const url = `/arbitrary/resources/search?service=DOCUMENT&query=${query}&limit=10&includemetadata=true&mode=LATEST&offset=${offset}&reverse=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
// Modify resource into data that is more easily used on the front end
const structuredReviewData = responseData.map(
(review: any): OwnerReview => {
const splitIdentifier = review.identifier.split("-");
return {
id: review?.identifier,
name: review?.name,
created: review?.created,
updated: review?.updated,
title: review?.metadata?.title,
description: review?.metadata?.description,
rating: Number(splitIdentifier[splitIdentifier.length - 1]) / 10,
};
}
);
// Filter out duplicates by checking if the review id already exists in ownerReviews in global redux store
const copiedOwnerReviews: OwnerReview[] = [...ownerReviews];
structuredReviewData.forEach((review: OwnerReview) => {
const index = ownerReviews.findIndex(
(ownerReview: OwnerReview) => ownerReview.id === review.id
);
if (index !== -1) {
copiedOwnerReviews[index] = review;
} else {
copiedOwnerReviews.push(review);
}
});
dispatch(upsertReviews(copiedOwnerReviews));
} catch (error) {
console.error(error);
} finally {
setLoadingReviews(false);
}
}, [ownerReviews, QFundId, QFundOwner]);
// Pass this function down to lazy loader
const handleGetReviews = useCallback(async () => {
await getQFundOwnerReviews();
}, [getQFundOwnerReviews]);
return (
<>
<HeaderRow>
{QFundOwnerAvatar ? (
<OwnerAvatar src={QFundOwnerAvatar} alt={`${QFundOwner}-logo`} />
) : (
<AccountCircleSVG
color={theme.palette.text.primary}
width="90"
height="90"
/>
)}
<OwnerNameCol style={{ gap: "10px" }}>
<OwnerName>{QFundOwner}</OwnerName>
<AddReviewButton onClick={() => setOpenLeaveReview(true)}>
<StarSVG
color={theme.palette.mode === "dark" ? "#000000" : "#ffffff"}
height={"22"}
width={"22"}
/>{" "}
Add Review
</AddReviewButton>
</OwnerNameCol>
<CloseIconModal
onClickFunc={() => setOpenQFundOwnerReviews(false)}
color={theme.palette.text.primary}
height={"26"}
width={"26"}
/>
</HeaderRow>
<Divider />
<CardDetailsContainer>
<Grid
container
direction={"row"}
flexWrap={"nowrap"}
rowGap={2}
style={{ columnGap: "30px" }}
>
{averageOwnerRating && (
<Grid item xs={12} sm={2} justifyContent={"center"}>
<AverageReviewContainer>
<ReviewsFont>Average Review</ReviewsFont>
<AverageReviewNumber>
{averageOwnerRating || null}
</AverageReviewNumber>
<Rating
style={{ marginBottom: "8px" }}
precision={0.5}
value={averageOwnerRating || 0}
readOnly
/>
<TotalReviewsFont>
{`${ownerReviews.length} review${
ownerReviews.length === 1 ? "" : "s"
}`}
</TotalReviewsFont>
</AverageReviewContainer>
</Grid>
)}
<Grid
item
xs={12}
sm={averageOwnerRating ? 10 : 12}
style={{ position: "relative" }}
>
<OwnerReviewsContainer>
{ownerReviews.length === 0 ? (
<ReviewsFont>No reviews yet</ReviewsFont>
) : (
ownerReviews
.filter((review: OwnerReview) => {
// Change and add filter here to remove owner's own reviews
return review.id.includes(reviewIdentifier);
})
.map((review: OwnerReview) => {
return <QFundOwnerReviewCard review={review} />;
})
)}
</OwnerReviewsContainer>
<LazyLoad
onLoadMore={handleGetReviews}
isLoading={loadingReviews}
></LazyLoad>
</Grid>
</Grid>
</CardDetailsContainer>
<ReusableModal
customStyles={{
width: "96%",
maxWidth: 700,
height: "70%",
backgroundColor:
theme.palette.mode === "light" ? "#e8e8e8" : "#32333c",
position: "relative",
padding: "25px 40px",
borderRadius: "5px",
outline: "none",
overflowY: "scroll",
}}
open={openLeaveReview}
>
<AddReview
QFundId={QFundId}
QFundOwner={QFundOwner}
setOpenLeaveReview={setOpenLeaveReview}
/>
</ReusableModal>
</>
);
};

812
src/components/common/VideoPlayer.tsx

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

648
src/components/common/VideoPlayerGlobal.tsx

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

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

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

129
src/components/layout/Navbar/Navbar.tsx

@ -0,0 +1,129 @@
import React from "react";
import { Box, useTheme } from "@mui/material";
import {
CustomAppBar,
ThemeSelectRow,
LogoContainer,
LightModeIcon,
DarkModeIcon,
AuthenticateButton,
NavbarName,
AvatarContainer,
} from "./Navbar-styles";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import ExitToAppIcon from "@mui/icons-material/ExitToApp";
import QFundLogo from "../../../assets/images/QFundDarkLogo.png";
import QFundLogoLight from "../../../assets/images/QFundLightLogo.png";
import { RootState } from "../../../state/store";
import { AccountCircleSVG } from "../../../assets/svgs/AccountCircleSVG";
interface Props {
isAuthenticated: boolean;
authenticate: () => void;
setTheme: (val: string) => void;
fixed?: boolean;
}
const NavBar: React.FC<Props> = ({
isAuthenticated,
authenticate,
setTheme,
fixed,
}) => {
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
const username = useSelector((state: RootState) => state.auth.user?.name);
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
);
return (
<CustomAppBar
position={fixed ? "sticky" : "relative"}
elevation={2}
style={{
backgroundColor: !fixed
? "transparent"
: theme.palette.background.default,
}}
>
<ThemeSelectRow>
{theme.palette.mode === "dark" ? (
<LightModeIcon
onClickFunc={() => setTheme("light")}
color={!fixed ? "white" : theme.palette.text.primary}
height="22"
width="22"
/>
) : (
<DarkModeIcon
onClickFunc={() => setTheme("dark")}
color={!fixed ? "white" : theme.palette.text.primary}
height="22"
width="22"
/>
)}
<LogoContainer
src={theme.palette.mode === "dark" ? QFundLogo : QFundLogoLight}
alt="QFund Logo"
onClick={() => {
navigate(`/`);
}}
/>
</ThemeSelectRow>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{!isAuthenticated && (
<AuthenticateButton
style={{
backgroundColor: fixed ? theme.palette.secondary.main : "#A9D9D0",
}}
onClick={authenticate}
>
<ExitToAppIcon />
Authenticate
</AuthenticateButton>
)}
{isAuthenticated && username && (
<>
<AvatarContainer>
<NavbarName
style={{ color: !fixed ? "white" : theme.palette.text.primary }}
>
{username}
</NavbarName>
{!userAvatarHash[username] ? (
<AccountCircleSVG
color={!fixed ? "white" : theme.palette.text.primary}
width="32"
height="32"
/>
) : (
<img
src={userAvatarHash[username]}
alt="User Avatar"
width="32"
height="32"
style={{
borderRadius: "50%",
color: !fixed ? "white" : theme.palette.text.primary,
}}
/>
)}
</AvatarContainer>
</>
)}
</Box>
</CustomAppBar>
);
};
export default NavBar;

72
src/components/modals/ConsentModal.tsx

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

50
src/components/modals/ReusableModal.tsx

@ -0,0 +1,50 @@
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;
id?: string;
}
export const ReusableModal: React.FC<MyModalProps> = ({
id,
open,
onClose,
onSubmit,
children,
customStyles = {}
}) => {
const theme = useTheme();
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<Box
id={id}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "75%",
bgcolor: theme.palette.background.paper,
boxShadow: 24,
p: 4,
display: "flex",
flexDirection: "column",
gap: 2,
...customStyles
}}
>
{children}
</Box>
</Modal>
);
};

19
src/constants/index.ts

@ -0,0 +1,19 @@
const useTestIdentifiers = false;
export const CROWDFUND_BASE = useTestIdentifiers
? "MYTEST_crowdfund_"
: "q-fund_crowdfund_";
export const ATTACHMENT_BASE = useTestIdentifiers
? "attachments_MYTEST_"
: "attachments_q-fund_";
export const COMMENT_BASE = useTestIdentifiers
? "qcomment_v1_MYTEST_"
: "qcomment_v1_q-fund_";
export const UPDATE_BASE = useTestIdentifiers
? "MYTEST_update_crowdfund_"
: "q-fund_update_crowdfund_";
export const REVIEW_BASE = useTestIdentifiers ? "q-fund-testrw" : "q-fund-rw";

113
src/global.d.ts vendored

@ -0,0 +1,113 @@
// src/global.d.ts
type TransactionType =
| "GENESIS"
| "PAYMENT"
| "REGISTER_NAME"
| "UPDATE_NAME"
| "SELL_NAME"
| "CANCEL_SELL_NAME"
| "BUY_NAME"
| "CREATE_POLL"
| "VOTE_ON_POLL"
| "ARBITRARY"
| "ISSUE_ASSET"
| "TRANSFER_ASSET"
| "CREATE_ASSET_ORDER"
| "CANCEL_ASSET_ORDER"
| "MULTI_PAYMENT"
| "DEPLOY_AT"
| "MESSAGE"
| "CHAT"
| "PUBLICIZE"
| "AIRDROP"
| "AT"
| "CREATE_GROUP"
| "UPDATE_GROUP"
| "ADD_GROUP_ADMIN"
| "REMOVE_GROUP_ADMIN"
| "GROUP_BAN"
| "CANCEL_GROUP_BAN"
| "GROUP_KICK"
| "GROUP_INVITE"
| "CANCEL_GROUP_INVITE"
| "JOIN_GROUP"
| "LEAVE_GROUP"
| "GROUP_APPROVAL"
| "SET_GROUP"
| "UPDATE_ASSET"
| "ACCOUNT_FLAGS"
| "ENABLE_FORGING"
| "REWARD_SHARE"
| "ACCOUNT_LEVEL"
| "TRANSFER_PRIVS"
| "PRESENCE";
interface QortalRequestOptions {
action: string;
name?: string;
service?: string;
data64?: string;
title?: string;
description?: string;
category?: string;
tags?: string[] | string;
identifier?: string;
address?: string;
metaData?: string;
encoding?: string;
includeMetadata?: boolean;
limit?: number;
offset?: number;
reverse?: boolean;
resources?: any[];
filename?: string;
list_name?: string;
item?: string;
items?: string[];
tag1?: string;
tag2?: string;
tag3?: string;
tag4?: string;
tag5?: string;
coin?: string;
destinationAddress?: string;
amount?: number;
blob?: Blob;
mimeType?: string;
file?: File;
encryptedData?: string;
mode?: string;
query?: string;
excludeBlocked?: boolean;
exactMatchNames?: boolean;
creationBytes?: string;
type?: string;
assetId?: number;
txType?: TransactionType[];
confirmationStatus?: string;
startBlock?: number;
blockLimit?: number;
txGroupId?: number;
}
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>;
}
}

239
src/hooks/useFetchCrowdfundStatus.tsx

@ -0,0 +1,239 @@
import { useCallback, useEffect, useRef, useState } from "react";
export const useFetchCrowdfundStatus = (
crowdfundData: any,
atAddress: string,
blocksRemainingZero: boolean
) => {
const [ATDeployed, setATDeployed] = useState<boolean>(false);
const [ATCompleted, setATCompleted] = useState<boolean>(false);
const [ATLoadingStatus, setATLoadingStatus] = useState<string>(
"Verifying Deployment Status..."
);
const [ATStatus, setATStatus] = useState<string>("");
const [ATAmount, setATAmount] = useState<number | null>(null);
const [ATEnded, setATEnded] = useState<boolean>(false);
const [checkedATEnded, setCheckedATEnded] = useState<boolean>(false);
const interval = useRef<any>(null);
// First check if the crowdfund has been deployed.
// If it has, check if the AT is still active by making an API request with transaction/search, type AT and looking for property called "amount". If no response, then AT is still active. If there is a response, it is completed.
// We also need a useEffect in case the Q-Fund goes from in progress to completed. We do this by having a
// If it is completed, check if amount value is greater than or equal to the goal value. If it is, then the goal has been achieved. If it isn't, then the goal has not been achieved.
// Fetch AT Deployment Status using the AT Address
const fetchQFundDeploymentStatus = useCallback(async () => {
try {
if (!atAddress) return;
const res = await qortalRequest({
action: "SEARCH_TRANSACTIONS",
txType: ["DEPLOY_AT"],
confirmationStatus: "CONFIRMED",
address: atAddress,
limit: 1,
reverse: true,
});
if (res?.length > 0) {
// Check if AT is sleeping and isn't finished yet
const url = `/at/${atAddress}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.status === 200) {
const responseDataSearch = await response.json();
// if we get a 200 response from /at/address, as well as the sleepUntilHeight property and !isFinished, then AT is deployed and we can check if it's in progress, achieved, or not achieved.
if (
responseDataSearch?.sleepUntilHeight &&
!responseDataSearch?.isFinished
) {
setATDeployed(true);
setATLoadingStatus("Verifying Q-Fund Completion Status...");
return res;
// if we get a 200 response from /at/address, but we're missing both the sleepUntilHeight property and isFinished, then AT is still being deployed
} else if (
!responseDataSearch?.sleepUntilHeight &&
!responseDataSearch?.isFinished
) {
setATDeployed(false);
setATStatus("Q-Fund Being Deployed");
return [];
// if we get a 200 response from /at/address, and we're missing the sleepUntilHeight property, but isFinished is true, then the AT is completed.
} else if (
!responseDataSearch.sleepUntilHeight &&
responseDataSearch.isFinished
) {
setATDeployed(true);
setATLoadingStatus("Verifying Q-Fund Completion Status...");
return res;
}
// if we get a 204 response from /at/address, then AT is not deployed yet because we still don't have the sleepUntilHeight property
} else {
setATStatus("Q-Fund Being Deployed");
setATDeployed(false);
return [];
}
} else {
setATStatus("Q-Fund Being Deployed");
setATDeployed(false);
return [];
}
} catch (error) {
console.error(error);
setATLoadingStatus("Error when fetching Q-Fund Deployment Status");
}
}, [atAddress]);
// useEffect that checks whether a Q-Fund is currently in deployment or not. If it is, we prevent the user from donating to the Q-Fund. We do polling every 30 seconds.
useEffect(() => {
if (atAddress) {
let intervalId: NodeJS.Timeout | undefined;
const checkDeploymentStatus = async () => {
const checkStatus = async () => {
const ATFound = await fetchQFundDeploymentStatus();
if (ATFound?.length > 0) {
clearInterval(intervalId); // Stop the polling if AT becomes available
} else {
setATDeployed(false);
setATLoadingStatus("");
}
};
checkStatus();
intervalId = setInterval(checkStatus, 30000);
// Clear the interval when the component unmounts
return () => {
if (intervalId) clearInterval(intervalId);
};
};
checkDeploymentStatus();
}
}, [atAddress, fetchQFundDeploymentStatus]);
// See if AT is completed
const fetchQFundCompletionStatus = useCallback(async () => {
try {
if (!atAddress) return;
const res = await qortalRequest({
action: "SEARCH_TRANSACTIONS",
txType: ["AT"],
confirmationStatus: "CONFIRMED",
address: atAddress,
limit: 0,
reverse: true,
});
if (res?.length > 0 && ATEnded) {
const totalAmount: number = res.reduce(
(total: number, transaction) =>
total + parseFloat(transaction.amount),
0
);
setATCompleted(true);
setATLoadingStatus("");
setATAmount(totalAmount);
// Check if AT is achieved or not achieved
if (totalAmount >= crowdfundData?.deployedAT?.goalValue) {
setATStatus("Q-Fund Goal Achieved");
} else {
setATStatus("Q-Fund Goal Not Achieved");
}
} else if (res?.length === 0 && ATEnded) {
setATCompleted(true);
setATLoadingStatus("");
setATStatus(
"Q-Fund Completed! Check back later to see the achievement status."
);
} else if (res.length > 0 && !ATEnded) {
setATCompleted(true);
setATLoadingStatus("");
setATAmount(res[0]?.amount);
// Check if AT is achieved or not achieved
if (res[0]?.amount >= crowdfundData?.deployedAT?.goalValue) {
setATStatus("Q-Fund Goal Achieved");
} else {
setATStatus("Q-Fund Goal Not Achieved");
}
} else {
setATCompleted(false);
setATLoadingStatus("");
setATStatus("Q-Fund In Progress");
}
} catch (error) {
console.error(error);
setATLoadingStatus("Error when fetching Q-Fund Completion Status");
}
}, [atAddress, ATEnded]);
// useEffect that check if AT is completed or not. If it is completed, we then check if it is achieved or not achieved based on the amount value. If it receives an ATEnded prop, recall the useEffect to see the achievement status of the AT.
useEffect(() => {
if (ATDeployed && atAddress) {
const checkCompletionStatus = async () => {
await fetchQFundCompletionStatus();
};
checkCompletionStatus();
}
}, [ATDeployed, atAddress, fetchQFundCompletionStatus, checkedATEnded]);
// Check if the crowdfund has ended by checking /at/address for isFinished property inside the response object
const hasQFundEnded = useCallback(async (atAddress: string) => {
try {
const url = `/at/${atAddress}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.status === 200) {
const responseDataSearch = await response.json();
if (
Object.keys(responseDataSearch).length > 0 &&
responseDataSearch?.isFinished
) {
setATEnded(true);
setCheckedATEnded(true);
return responseDataSearch;
} else {
setATEnded(false);
setCheckedATEnded(true);
return responseDataSearch;
}
}
} catch (error) {
console.log(error);
}
}, []);
// Poll every 5 seconds to check if the crowdfund has ended when blocksRemaining is 0
useEffect(() => {
if (blocksRemainingZero && !checkedATEnded) {
let isCalling = false;
interval.current = setInterval(async () => {
if (isCalling) return;
isCalling = true;
const response = await hasQFundEnded(atAddress);
if (response) {
clearInterval(interval.current);
}
isCalling = false;
}, 5000);
}
return () => {
if (interval.current) {
clearInterval(interval.current);
}
};
}, [blocksRemainingZero]);
return {
ATDeployed,
ATCompleted,
ATLoadingStatus,
ATStatus,
ATAmount,
};
};

102
src/hooks/useFetchCrowdfunds.tsx

@ -0,0 +1,102 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import {
addToHashMap,
upsertCrowdfunds,
Crowdfund,
} from "../state/features/crowdfundSlice";
import { RootState } from "../state/store";
import { fetchAndEvaluateCrowdfunds } from "../utils/fetchCrowdfunds";
import { CROWDFUND_BASE } from "../constants";
export const useFetchCrowdfunds = () => {
const dispatch = useDispatch();
const hashMapCrowdfund = useSelector(
(state: RootState) => state.crowdfund.hashMapCrowdfunds
);
const crowdfunds = useSelector(
(state: RootState) => state.crowdfund.crowdfunds
);
const checkAndUpdateResource = React.useCallback(
(crowdfund: Crowdfund) => {
const existingCrowdfund = hashMapCrowdfund[crowdfund.id];
if (!existingCrowdfund) {
return true;
} else if (
crowdfund?.updated &&
existingCrowdfund?.updated &&
(!existingCrowdfund?.updated || crowdfund?.updated) >
existingCrowdfund?.updated
) {
return true;
} else {
return false;
}
},
[hashMapCrowdfund]
);
const getCrowdfund = async (
user: string,
identifier: string,
content: any
) => {
const res = await fetchAndEvaluateCrowdfunds({
user,
identifier,
content,
});
dispatch(addToHashMap(res));
};
const getCrowdfunds = React.useCallback(async () => {
try {
const offset = crowdfunds.length;
const listLimit = 20;
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${CROWDFUND_BASE}&limit=${listLimit}&includemetadata=false&reverse=true&excludeblocked=true&exactmatchnames=true&offset=${offset}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
const structureData = responseData.map((resource: any): Crowdfund => {
return {
title: resource?.metadata?.title,
category: resource?.metadata?.category,
categoryName: resource?.metadata?.categoryName,
tags: resource?.metadata?.tags || [],
description: resource?.metadata?.description,
created: resource?.created,
updated: resource?.updated,
user: resource.name,
id: resource.identifier,
};
});
dispatch(upsertCrowdfunds(structureData));
for (const content of structureData) {
if (content.user && content.id) {
const res = checkAndUpdateResource(content);
if (res) {
getCrowdfund(content.user, content.id, content);
}
}
}
} catch (error) {
console.error(error);
}
}, [crowdfunds, hashMapCrowdfund]);
return {
getCrowdfunds,
checkAndUpdateResource,
getCrowdfund,
hashMapCrowdfund,
};
};

55
src/hooks/useFetchOwnerReviews.tsx

@ -0,0 +1,55 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { addToHashMapOwnerReviews } from "../state/features/globalSlice";
import { RootState } from "../state/store";
import { fetchAndEvaluateOwnerReviews } from "../utils/fetchOwnerReviews";
interface Resource {
id: string;
updated?: number;
}
export const useFetchOwnerReviews = () => {
const dispatch = useDispatch();
const hashMapOwnerReviews = useSelector(
(state: RootState) => state.global.hashMapOwnerReviews
);
// Get the review raw data from QDN
const getReview = async (owner: string, reviewId: string, content: any) => {
const res = await fetchAndEvaluateOwnerReviews({
owner,
reviewId,
content,
});
dispatch(addToHashMapOwnerReviews(res));
};
// Make sure that raw data isn't already present in Redux hashmap
const checkAndUpdateResource = React.useCallback(
(resource: Resource) => {
// Check if the post exists in hashMapPosts
const existingResource = hashMapOwnerReviews[resource.id];
if (!existingResource) {
// If the post doesn't exist, add it to hashMapPosts
return true;
} else if (
resource?.updated &&
existingResource?.updated &&
resource.updated > existingResource.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;
}
},
[hashMapOwnerReviews]
);
return {
getReview,
checkAndUpdateResource,
};
};

25
src/hooks/useWindowSize.tsx

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

269
src/index.css

@ -0,0 +1,269 @@
@font-face {
font-family: "Mulish";
src: url("./styles/fonts/Mulish.ttf") format("truetype");
}
@font-face {
font-family: "Copse";
src: url("./styles/fonts/Copse.ttf") format("truetype");
}
@font-face {
font-family: "Cambon Light";
src: url("./styles/fonts/Cambon-Light.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;
}
* {
padding: 0px;
margin: 0px;
}
p {
font-size: 18px;
}
.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%;
}
.test-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
min-height: 25px;
}
.test-grid-item {
border: 1px solid powderblue;
}
body::-webkit-scrollbar-track {
background-color: transparent;
}
body::-webkit-scrollbar-track:hover {
background-color: transparent;
}
body::-webkit-scrollbar {
width: 16px;
height: 10px;
background-color: white;
}
body::-webkit-scrollbar-thumb {
background-color: #838eee;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}
body::-webkit-scrollbar-thumb:hover {
background-color: #6270f0;
}
.MuiList-root::-webkit-scrollbar-track {
background-color: transparent;
}
.MuiList-root::-webkit-scrollbar-track:hover {
background-color: transparent;
}
.MuiList-root::-webkit-scrollbar {
width: 14px;
height: 10px;
background-color: white;
}
.MuiList-root::-webkit-scrollbar-thumb {
background-color: lightgray;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}
.MuiList-root::-webkit-scrollbar-thumb:hover {
background-color: lightslategray;
}
.my-masonry-grid {
display: -webkit-box; /* Not needed if autoprefixing */
display: -ms-flexbox; /* Not needed if autoprefixing */
display: flex;
margin-left: -20px; /* gutter size offset */
width: auto;
padding: 15px 20px;
}
.my-masonry-grid_column {
padding-left: 20px; /* gutter size */
background-clip: padding-box;
}
/* Style your items */
.my-masonry-grid_column > li {
/* change div to reference your elements you put in <Masonry> */
margin-bottom: 30px;
}
.my-svg path {
fill: red;
}
.qortal-link {
text-decoration: none; /* Removes the underline */
color: inherit; /* Inherits the color of the parent element */
}
.qortal-link:hover,
a:focus {
text-decoration: underline; /* Adds underline on hover and focus for accessibility */
}
.download-icon {
transition: all 0.5s ease-in-out;
animation: downloadIconAnimation 2s infinite;
}
@keyframes downloadIconAnimation {
0% {
transform: scale(1);
fill: #fff;
}
50% {
transform: scale(1.2);
fill: #3498db;
}
100% {
transform: scale(1);
fill: #fff;
}
}
.closePlayer {
position: absolute;
top: 0px;
width: 100%;
transition: all 0.3s;
display: flex;
justify-content: flex-end;
z-index: 8000;
}
/* When the screen is 600px or less, display .myClassUnder600 and hide .myClassOver600 */
@media screen and (max-width: 600px) {
.myClassUnder600 {
display: none !important;
}
}
@media screen and (min-width: 601px) {
.myClassOver600 {
display: none !important;
}
}
.editor-container {
background: #fcfcfc;
color: #000000;
border-radius: 5px;
min-width: 75%;
min-height: 50vh;
}
.editor-container * {
max-width: 100%;
word-break: break-word;
}
.audio-player {
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
border-radius: 10px;
}
.audio-player .toggle-play {
display: block;
margin-bottom: 20px;
padding: 10px 20px;
border: none;
border-radius: 5px;
background: #007bff;
color: white;
cursor: pointer;
}
.audio-player .toggle-play:hover {
background: #0056b3;
}
.audio-player .progress-container {
width: 100%;
height: 20px;
background: #ccc;
cursor: pointer;
margin-bottom: 20px;
}
.audio-player .progress-bar {
height: 100%;
background: #007bff;
}
.audio-player .volume-slider {
width: 100%;
cursor: pointer;
}
.audio-player .volume-slider {
width: 100%;
cursor: pointer;
}
/* React Quill */
.ql-editor {
min-height: 100px;
}

17
src/main.tsx

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

721
src/pages/Crowdfund/Crowdfund.tsx

@ -0,0 +1,721 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../state/store";
import { CircularProgress, Stack, useTheme } from "@mui/material";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import DownloadIcon from "@mui/icons-material/Download";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { DisplayHtml } from "../../components/common/DisplayHtml";
import FileElement from "../../components/common/FileElement";
import { setIsLoadingGlobal } from "../../state/features/globalSlice";
import { CROWDFUND_BASE, REVIEW_BASE, UPDATE_BASE } from "../../constants";
import { addToHashMap } from "../../state/features/crowdfundSlice";
import {
AboutMyCrowdfund,
BackToHomeButton,
CoverImage,
CrowdfundAccordion,
CrowdfundAccordionDetails,
CrowdfundAccordionFont,
CrowdfundAccordionSummary,
CrowdfundDescriptionRow,
CrowdfundInlineContentRow,
CrowdfundPageTitle,
CrowdfundStatusRow,
CrowdfundSubTitle,
CrowdfundSubTitleRow,
CrowdfundTitleRow,
MainCol,
MainContainer,
NoReviewsFont,
RatingContainer,
StyledRating,
} from "../../components/Crowdfund/Crowdfund-styles";
import AudioPlayer from "../../components/common/AudioPlayer";
import { NewCrowdfund } from "../../components/Crowdfund/NewCrowdfund";
import { CommentSection } from "../../components/common/Comments/CommentSection";
import { Donate } from "../../components/common/Donate/Donate";
import { CrowdfundProgress } from "../../components/common/Progress/Progress";
import { Countdown } from "../../components/common/Countdown/Countdown";
import { NewUpdate } from "../../components/Crowdfund/NewUpdate";
import { Update } from "./Update";
import { AccountCircleSVG } from "../../assets/svgs/AccountCircleSVG";
import moment from "moment";
import {
FileAttachmentContainer,
FileAttachmentFont,
PlayerBox,
} from "./Update-styles";
import CoverImageDefault from "../../assets/images/CoverImageDefault.webp";
import { setNotification } from "../../state/features/notificationsSlice";
import { useFetchCrowdfundStatus } from "../../hooks/useFetchCrowdfundStatus";
import { CrowdfundLoader } from "./CrowdfundLoader";
import { ReusableModalStyled } from "../../components/common/Reviews/QFundOwnerReviews-styles";
import { QFundOwnerReviews } from "../../components/common/Reviews/QFundOwnerReviews";
import DonorInfo from "../../components/common/Donate/DonorInfo";
import {
SearchTransactionResponse,
searchTransactions,
} from "qortal-app-utils";
export const Crowdfund = () => {
const theme = useTheme();
const dispatch = useDispatch();
const { name, id } = useParams();
const navigate = useNavigate();
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
);
const hashMapCrowdfunds = useSelector(
(state: RootState) => state.crowdfund.hashMapCrowdfunds
);
const [rawDonorData, setRawDonorData] = useState<SearchTransactionResponse[]>(
[]
);
const [crowdfundData, setCrowdfundData] = useState<any>(null);
const [currentAtInfo, setCurrentAtInfo] = useState<any>(null);
const [loadingAtInfo, setLoadingAtInfo] = useState<boolean>(false);
const username = useSelector((state: RootState) => state.auth?.user?.name);
const [updatesList, setUpdatesList] = useState<any[]>([]);
const [atAddressBalance, setAtAddressBalance] = useState<any>(null);
const [nodeInfo, setNodeInfo] = useState<any>(null);
const [openQFundOwnerReviews, setOpenQFundOwnerReviews] =
useState<boolean>(false);
const [ownerAvatar, setOwnerAvatar] = useState<string | null>(null);
const [averageRatingLoader, setAverageRatingLoader] =
useState<boolean>(false);
const [averageOwnerRating, setAverageOwnerRating] = useState<number | null>(
null
);
const [ownerRegisteredNumber, setOwnerRegisteredNumber] = useState<
number | null
>(null);
const [blocksRemainingZero, setBlocksRemainingZero] =
useState<boolean>(false);
const interval = useRef<any>(null);
const intervalBalance = useRef<any>(null);
const endDateRef = useRef<any>(null);
// Get the AT Address from the crowdfundData
const atAddress = useMemo(() => {
return crowdfundData?.deployedAT?.aTAddress || null;
}, [crowdfundData]);
const endDate = useMemo(() => {
if (!currentAtInfo?.sleepUntilHeight || !nodeInfo?.height) return null;
if (endDateRef.current) return endDateRef.current;
const diff = +currentAtInfo?.sleepUntilHeight - +nodeInfo.height;
const end = moment().add(diff, "minutes");
endDateRef.current = end;
return end;
}, [currentAtInfo, nodeInfo]);
const blocksRemaining = useMemo(() => {
if (
(!currentAtInfo?.sleepUntilHeight || !nodeInfo?.height) &&
!currentAtInfo?.isFinished
) {
return null;
} else if (currentAtInfo?.isFinished) {
setBlocksRemainingZero(true);
return 0;
} else {
const diff = +currentAtInfo?.sleepUntilHeight - +nodeInfo.height;
// If the difference is less than or equal to 0, then the crowdfund has ended and we must check /at/address to look for isFinished property on the response object. If it is true, then the crowdfund has ended. If it is false, then the crowdfund is still in progress, and we don't show the Q-Fund has ended status until then.
if (diff <= 0) {
setBlocksRemainingZero(true);
return 0;
}
return diff;
}
}, [currentAtInfo, nodeInfo]);
const editContent = useMemo(() => {
if (!crowdfundData) return null;
const content = {
title: crowdfundData?.title,
inlineContent: crowdfundData?.inlineContent,
attachments: crowdfundData?.attachments,
user: crowdfundData?.user,
coverImage: crowdfundData?.coverImage || null,
};
return content;
}, [crowdfundData]);
const getRawDonorData = (address: string) => {
searchTransactions({
txType: ["PAYMENT"],
address,
confirmationStatus: "BOTH",
}).then(donorResponse => {
setRawDonorData(donorResponse);
});
};
const getCurrentAtInfo = React.useCallback(async atAddress => {
console.log({ atAddress });
getRawDonorData(atAddress);
setLoadingAtInfo(true);
try {
const url = `/at/${atAddress}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.status === 200) {
const responseDataSearch = await response.json();
setCurrentAtInfo(responseDataSearch);
}
} catch (error) {
console.log(error);
} finally {
dispatch(setIsLoadingGlobal(false));
setLoadingAtInfo(false);
}
}, []);
const getNodeInfo = React.useCallback(async () => {
try {
const url = `/blocks/height`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearch = await response.json();
setNodeInfo({ height: responseDataSearch });
} catch (error) {
console.log(error);
} finally {
dispatch(setIsLoadingGlobal(false));
}
}, []);
const getAtAddressInfo = React.useCallback(async atAddress => {
try {
const url = `/addresses/balance/${atAddress}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearch = await response.json();
setAtAddressBalance(responseDataSearch);
} catch (error) {
console.log(error);
} finally {
dispatch(setIsLoadingGlobal(false));
}
}, []);
const getCrowdfundData = React.useCallback(
async (name: string, id: string) => {
try {
if (!name || !id) return;
dispatch(setIsLoadingGlobal(true));
// Get the resource location here
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${CROWDFUND_BASE}&limit=1&includemetadata=true&reverse=true&excludeblocked=true&name=${name}&exactmatchnames=true&offset=0&identifier=${id}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearch = await response.json();
// Always comes back as an array, so you must find the correct one using array bracket notation
if (responseDataSearch?.length > 0) {
let resourceData = responseDataSearch[0];
resourceData = {
title: resourceData?.metadata?.title,
category: resourceData?.metadata?.category,
categoryName: resourceData?.metadata?.categoryName,
tags: resourceData?.metadata?.tags || [],
description: resourceData?.metadata?.description,
coverImage: resourceData?.metadata?.coverImage,
created: resourceData?.created,
updated: resourceData?.updated,
user: resourceData.name,
id: resourceData.identifier,
};
// Get raw data of the resource here
const responseData = await qortalRequest({
action: "FETCH_QDN_RESOURCE",
name: name,
service: "DOCUMENT",
identifier: id,
});
if (responseData && !responseData.error) {
const combinedData = {
...resourceData,
...responseData,
};
setCrowdfundData(combinedData);
dispatch(addToHashMap(combinedData));
console.log({ combinedData });
if (combinedData?.deployedAT?.aTAddress) {
getCurrentAtInfo(combinedData?.deployedAT?.aTAddress);
getAtAddressInfo(combinedData?.deployedAT?.aTAddress);
}
}
}
} catch (error) {
console.log(error);
} finally {
dispatch(setIsLoadingGlobal(false));
}
},
[]
);
const getUpdates = React.useCallback(async (name: string, id: string) => {
try {
if (!name || !id) return;
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${UPDATE_BASE}${id.slice(
-12
)}&limit=0&includemetadata=false&reverse=true&excludeblocked=true&name=${name}&exactmatchnames=true&offset=0`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearch = await response.json();
setUpdatesList(responseDataSearch);
} catch (error) {
console.log(error);
}
}, []);
const QFundOwnerAvatarUrl = useCallback(async () => {
try {
if (name) {
const url = await qortalRequest({
action: "GET_QDN_RESOURCE_URL",
name: name,
service: "THUMBNAIL",
identifier: "qortal_avatar",
});
console.log({ url });
setOwnerAvatar(url);
}
} catch (error) {
console.error(error);
}
}, [name]);
// Get the Q-Fund Owner's Avatar
useEffect(() => {
QFundOwnerAvatarUrl();
}, [name]);
// Custom hook to get the AT Status, AT Achieved or Not, AT Amount, and AT Loading Status. We pass down the blocksRemainingZero state once blocksRemaining is 0 or less than 0. We do this to verify the completion status of the AT.
const { ATDeployed, ATCompleted, ATLoadingStatus, ATStatus, ATAmount } =
useFetchCrowdfundStatus(crowdfundData, atAddress, blocksRemainingZero);
// We get the crowdfund's updates if hashMapCrowdfund changes. This changes when you publish a new update or modify an existing update and if the ATStatus changes inside the useFetchCrowdfundStatus hook.
useEffect(() => {
if (name && id) {
const existingCrowdfund = hashMapCrowdfunds[id];
if (existingCrowdfund) {
setCrowdfundData(existingCrowdfund);
getCurrentAtInfo(existingCrowdfund?.deployedAT?.aTAddress);
getAtAddressInfo(existingCrowdfund?.deployedAT?.aTAddress);
} else {
getCrowdfundData(name, id);
}
getUpdates(name, id);
getNodeInfo();
}
}, [id, name, hashMapCrowdfunds, ATStatus]);
// Check node info every 30 seconds
const checkNodeInfo = useCallback(() => {
let isCalling = false;
interval.current = setInterval(async () => {
if (isCalling) return;
isCalling = true;
const res = await getNodeInfo();
if (id) {
const address = hashMapCrowdfunds[id]?.deployedAT?.aTAddress;
getRawDonorData(address);
}
isCalling = false;
}, 30000);
}, [getNodeInfo]);
useEffect(() => {
checkNodeInfo();
return () => {
if (interval?.current) {
clearInterval(interval.current);
}
};
}, [checkNodeInfo]);
const checkBalance = useCallback(
atAddress => {
let isCalling = false;
intervalBalance.current = setInterval(async () => {
if (isCalling) return;
isCalling = true;
const res = await getAtAddressInfo(atAddress);
isCalling = false;
}, 30000);
},
[getAtAddressInfo]
);
// Get 100 store reviews from QDN and calculate the average review.
const getOwnerAverageReview = useCallback(async () => {
if (!id || !name) return;
try {
let ownerNumber: number;
setAverageRatingLoader(true);
const shortQFundOwner = name.slice(0, 15);
const QFundOwnerRegistration = await qortalRequest({
action: "GET_NAME_DATA",
name: name,
});
// Get the owner's name registered number to be used as a unique variable when creating reviews. This will be passed down to the <AddReview /> component
if (Object.keys(QFundOwnerRegistration).length > 0) {
ownerNumber = QFundOwnerRegistration.registered;
setOwnerRegisteredNumber(ownerNumber);
} else {
throw new Error("No registered number found for QFund owner name");
}
// Those first three constants will remain the same no matter which crowdfund the owner made
const query = `${REVIEW_BASE}-${shortQFundOwner}-${ownerNumber}`;
// Since it the url includes /resources, you know you're fetching the resources and not the raw data
const url = `/arbitrary/resources/search?service=DOCUMENT&query=${query}&limit=100&includemetadata=false&mode=LATEST&reverse=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
if (responseData.length === 0) {
setAverageOwnerRating(null);
return;
}
// Modify resource into data that is more easily used on the front end
const storeRatingsArray = responseData.map((review: any) => {
const splitIdentifier = review.identifier.split("-");
const rating = Number(splitIdentifier[splitIdentifier.length - 1]) / 10;
return rating;
});
// Calculate average rating of the store
let averageRating =
storeRatingsArray.reduce((acc: number, curr: number) => {
return acc + curr;
}, 0) / storeRatingsArray.length;
averageRating = Math.ceil(averageRating * 2) / 2;
setAverageOwnerRating(averageRating);
} catch (error) {
console.error(error);
} finally {
setAverageRatingLoader(false);
}
}, [id, name]);
// Get average owner rating when id and name is available, and only if the storeId is different from the currentViewedStore when it's not your store, or if storeId is different from currentStore when it is your store. Do this to avoid unnecessary QDN calls.
useEffect(() => {
if (id && name) {
getOwnerAverageReview();
}
}, [id, name]);
useEffect(() => {
if (!atAddress) return;
checkBalance(atAddress);
return () => {
if (intervalBalance?.current) {
clearInterval(intervalBalance.current);
}
};
}, [checkBalance, atAddress]);
// Check if the crowdfund has been modified after its creation. If it has, we prevent the user from donating to the Q-Fund and redirect to homepage.
useEffect(() => {
if (crowdfundData?.created && crowdfundData?.updated) {
if (crowdfundData?.created === crowdfundData?.updated) {
return;
} else {
dispatch(
setNotification({
msg: "Q-Fund has been modified after its creation. Please be aware of this!",
alertType: "error",
})
);
}
}
}, [crowdfundData?.created, crowdfundData?.updated]);
if (!crowdfundData) return null;
return (
<>
<NewCrowdfund editId={id} editContent={editContent} />
<MainContainer container direction={"row"}>
<span style={{ position: "relative", width: "inherit" }}>
<CoverImage src={crowdfundData?.coverImage || CoverImageDefault} />
<BackToHomeButton
variant="contained"
onClick={() => {
navigate("/");
}}
>
Back To Homepage
</BackToHomeButton>
</span>
<MainCol item xs={12} sm={12} md={6} gap={"15px"}>
<CrowdfundTitleRow>
{!ownerAvatar ? (
<AccountCircleSVG
color={theme.palette.text.primary}
width="80"
height="80"
/>
) : (
<img
src={ownerAvatar}
alt="User Avatar"
width="80"
height="80"
style={{
borderRadius: "50%",
color: theme.palette.text.primary,
}}
/>
)}
<CrowdfundPageTitle>{crowdfundData?.title}</CrowdfundPageTitle>
</CrowdfundTitleRow>
{averageRatingLoader ? (
<CircularProgress />
) : (
<RatingContainer
onClick={() => {
setOpenQFundOwnerReviews(true);
}}
>
{!averageOwnerRating ? (
<NoReviewsFont>
No reviews yet. Be the first to review this Q-Fund owner!
</NoReviewsFont>
) : (
<StyledRating
precision={0.5}
value={averageOwnerRating}
readOnly
/>
)}
</RatingContainer>
)}
{ATLoadingStatus ? (
// Loader reusable component with status text
<CrowdfundLoader status={ATLoadingStatus} />
) : (
<CrowdfundStatusRow
style={{
color:
ATStatus === "Q-Fund Being Deployed"
? "#F2A74B"
: ATStatus === "Q-Fund Goal Achieved"
? "#0aba42"
: ATStatus === "Q-Fund Goal Not Achieved"
? "#bc0f0f"
: "#f8fd65",
border:
ATStatus === "Q-Fund Being Deployed"
? "1px solid #F2A74B"
: ATStatus === "Q-Fund Goal Achieved"
? "1px solid #0aba42"
: ATStatus === "Q-Fund Goal Not Achieved"
? "1px solid #bc0f0f"
: "1px solid #f8fd65",
}}
>{`Status: ${ATStatus}`}</CrowdfundStatusRow>
)}
<CrowdfundDescriptionRow>
{crowdfundData?.description}
</CrowdfundDescriptionRow>
<AboutMyCrowdfund>About My Q-Fund</AboutMyCrowdfund>
<CrowdfundInlineContentRow>
<DisplayHtml html={crowdfundData?.inlineContent} />
</CrowdfundInlineContentRow>
</MainCol>
<MainCol item xs={12} sm={12} md={6} gap={"17px"}>
{/* Ensure the AT is still active and not being deployed to display the donate button */}
{ATLoadingStatus ? (
// Loader reusable component with status text
<CrowdfundLoader status={ATLoadingStatus} />
) : ATStatus === "Q-Fund Being Deployed" || !currentAtInfo ? null : (
<>
{crowdfundData?.deployedAT?.goalValue &&
!isNaN(atAddressBalance) && (
<CrowdfundProgress
achieved={ATAmount || null}
raised={atAddressBalance}
goal={crowdfundData?.deployedAT?.goalValue}
/>
)}
<Countdown
loadingAtInfo={loadingAtInfo}
endDate={endDate}
blocksRemaining={blocksRemaining}
ATCompleted={ATCompleted}
/>
<Stack direction={"row"} gap={"25px"}>
<Donate
ATDonationPossible={ATDeployed && !ATCompleted}
atAddress={crowdfundData?.deployedAT?.aTAddress}
onSubmit={() => {
return;
}}
onClose={() => {
return;
}}
/>
<DonorInfo rawDonorData={rawDonorData} />
</Stack>
</>
)}
<CrowdfundSubTitleRow>
{crowdfundData?.attachments?.length > 0 && (
<CrowdfundSubTitle>Attachments</CrowdfundSubTitle>
)}
</CrowdfundSubTitleRow>
{crowdfundData?.attachments?.map(attachment => {
if (attachment?.service === "AUDIO")
return (
<AudioPlayer
key={attachment.identifier}
fullFile={attachment}
filename={attachment.filename}
name={attachment.name}
identifier={attachment.identifier}
service="AUDIO"
jsonId={crowdfundData?.id}
user={crowdfundData?.user}
/>
);
return (
<>
<PlayerBox
sx={{
minHeight: "55px",
}}
>
<FileAttachmentContainer>
<AttachFileIcon
sx={{
height: "16px",
width: "auto",
}}
></AttachFileIcon>
<FileAttachmentFont>
{attachment?.filename}
</FileAttachmentFont>
<FileElement
fileInfo={attachment}
title={attachment?.filename}
customStyles={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<DownloadIcon />
</FileElement>
</FileAttachmentContainer>
</PlayerBox>
</>
);
})}
{name === username && (
<NewUpdate crowdfundId={id} crowdfundName={name || ""} />
)}
<div style={{ width: "90%" }}>
{updatesList.map(update => {
return (
<CrowdfundAccordion key={update.identifier}>
<CrowdfundAccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1a-content"
id="panel1a-header"
>
<CrowdfundAccordionFont>
Update for {moment(update.created).format("LLL")}
</CrowdfundAccordionFont>
</CrowdfundAccordionSummary>
<CrowdfundAccordionDetails>
<Update updateObj={update} />
</CrowdfundAccordionDetails>
</CrowdfundAccordion>
);
})}
</div>
</MainCol>
</MainContainer>
{/* Comments section */}
<CrowdfundSubTitleRow style={{ marginTop: "85px" }}>
<CrowdfundSubTitle>Comments</CrowdfundSubTitle>
</CrowdfundSubTitleRow>
<CommentSection postId={id || ""} postName={name || ""} />
<ReusableModalStyled
id={"qfund-owner-reviews"}
customStyles={{
width: "96%",
maxWidth: 1200,
height: "95vh",
backgroundColor:
theme.palette.mode === "light" ? "#e8e8e8" : "#32333c",
position: "relative",
padding: "25px 40px",
borderRadius: "5px",
outline: "none",
overflowY: "auto",
overflowX: "hidden",
}}
open={openQFundOwnerReviews}
>
<QFundOwnerReviews
QFundId={id || ""}
averageOwnerRating={averageOwnerRating || null}
QFundOwnerRegisteredNumber={ownerRegisteredNumber || null}
QFundOwner={name || ""}
QFundOwnerAvatar={ownerAvatar || ""}
setOpenQFundOwnerReviews={setOpenQFundOwnerReviews}
/>
</ReusableModalStyled>
</>
);
};

16
src/pages/Crowdfund/CrowdfundLoader.tsx

@ -0,0 +1,16 @@
import { FC } from "react";
import { CircularProgress } from "@mui/material";
import { CrowdfundLoaderRow } from "../../components/Crowdfund/Crowdfund-styles";
interface CrowdfundLoaderProps {
status: string;
}
export const CrowdfundLoader: FC<CrowdfundLoaderProps> = ({ status }) => {
return (
<CrowdfundLoaderRow>
<CircularProgress />
{status}
</CrowdfundLoaderRow>
);
};

96
src/pages/Crowdfund/Update-styles.tsx

@ -0,0 +1,96 @@
import { styled } from "@mui/system";
import { Box, Button, Typography } from "@mui/material";
import { TimesSVG } from "../../assets/svgs/TimesSVG";
export const PlayerBox = styled(Box)(({ theme }) => ({
width: "340px",
outline: `1px solid ${theme.palette.primary.dark}`,
borderRadius: "3px",
minHeight: "95px",
}));
export const UpdateLoadingBox = styled(Box)({
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
});
export const UpdateContainer = styled(Box)({
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
});
export const UpdateRow = styled(Box)({
width: "100%",
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
gap: "15px",
});
export const UpdateNameRow = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: "10px",
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
fontSize: "18px",
userSelect: "none",
color: theme.palette.text.primary,
}));
export const UpdateCol = styled(Box)({
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "10px",
});
export const CrowdfundUpdateDate = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "14px",
letterSpacing: 0,
fontWeight: 400,
userSelect: "none",
color: theme.palette.text.primary,
}));
export const AttachmentCol = styled(Box)({
display: "flex",
width: "100%",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "10px",
});
export const FileAttachmentContainer = styled(Box)({
display: "flex",
alignItems: "center",
gap: "20px",
padding: "5px 10px",
});
export const FileAttachmentFont = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
color: theme.palette.text.primary,
fontSize: "16px",
letterSpacing: 0,
fontWeight: 400,
wordBreak: "break-word",
userSelect: "none",
}));
export const CloseNewUpdateModal = styled(TimesSVG)(({ theme }) => ({
transition: "all 0.2s ease-in-out",
"&:hover": {
cursor: "pointer",
transform: "scale(1.1)",
},
}));

216
src/pages/Crowdfund/Update.tsx

@ -0,0 +1,216 @@
import React, { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../state/store";
import { Avatar, Box, useTheme } from "@mui/material";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import DownloadIcon from "@mui/icons-material/Download";
import CircularProgress from "@mui/material/CircularProgress";
import { formatDate } from "../../utils/time";
import { DisplayHtml } from "../../components/common/DisplayHtml";
import FileElement from "../../components/common/FileElement";
import { setIsLoadingGlobal } from "../../state/features/globalSlice";
import { addToHashMap } from "../../state/features/crowdfundSlice";
import {
CrowdfundSubTitle,
CrowdfundTitle,
} from "../../components/Crowdfund/Crowdfund-styles";
import AudioPlayer from "../../components/common/AudioPlayer";
import { NewUpdate } from "../../components/Crowdfund/NewUpdate";
import {
AttachmentCol,
CrowdfundUpdateDate,
FileAttachmentContainer,
FileAttachmentFont,
UpdateCol,
UpdateContainer,
UpdateLoadingBox,
UpdateNameRow,
UpdateRow,
PlayerBox,
} from "./Update-styles";
export const Update = ({ updateObj }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { name } = useParams();
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
);
const avatarUrl = useMemo(() => {
let url = "";
if (name && userAvatarHash[name]) {
url = userAvatarHash[name];
}
return url;
}, [userAvatarHash, name]);
const [crowdfundData, setCrowdfundData] = useState<any>(null);
const username = useSelector((state: RootState) => state.auth?.user?.name);
const hashMapCrowdfunds = useSelector(
(state: RootState) => state.crowdfund.hashMapCrowdfunds
);
const editContent = useMemo(() => {
if (!crowdfundData) return null;
const content = {
title: crowdfundData?.title,
inlineContent: crowdfundData?.inlineContent,
attachments: crowdfundData?.attachments,
user: crowdfundData?.user,
};
return content;
}, [crowdfundData]);
const getCrowdfundData = React.useCallback(async updateObj => {
try {
let resourceData = updateObj;
resourceData = {
title: resourceData?.metadata?.title,
category: resourceData?.metadata?.category,
categoryName: resourceData?.metadata?.categoryName,
tags: resourceData?.metadata?.tags || [],
description: resourceData?.metadata?.description,
created: resourceData?.created,
updated: resourceData?.updated,
user: resourceData.name,
id: resourceData.identifier,
};
const responseData = await qortalRequest({
action: "FETCH_QDN_RESOURCE",
name: updateObj.name,
service: "DOCUMENT",
identifier: updateObj.identifier,
});
if (responseData && !responseData.error) {
const combinedData = {
...resourceData,
...responseData,
};
setCrowdfundData(combinedData);
dispatch(addToHashMap(combinedData));
}
} catch (error) {
console.log(error);
} finally {
dispatch(setIsLoadingGlobal(false));
}
}, []);
useEffect(() => {
if (updateObj) {
const existingCrowdfund = hashMapCrowdfunds[updateObj.identifier];
if (existingCrowdfund) {
setCrowdfundData(existingCrowdfund);
} else {
getCrowdfundData(updateObj);
}
}
}, [updateObj]);
if (!crowdfundData)
return (
<UpdateLoadingBox>
<CircularProgress />
</UpdateLoadingBox>
);
return (
<>
<NewUpdate
editContent={editContent}
editId={updateObj.identifier}
onSubmit={content => {
setCrowdfundData(content);
}}
crowdfundName={name || ""}
/>
<UpdateContainer>
<UpdateCol>
<UpdateRow>
<UpdateNameRow>
<Avatar src={avatarUrl} alt={`${name}'s avatar`} />
{name}
</UpdateNameRow>
<UpdateCol style={{ gap: 0 }}>
<CrowdfundTitle
sx={{
textAlign: "center",
}}
>
{crowdfundData?.title}
</CrowdfundTitle>
{crowdfundData?.created && (
<CrowdfundUpdateDate>
{formatDate(crowdfundData.created)}
</CrowdfundUpdateDate>
)}
</UpdateCol>
</UpdateRow>
<Box sx={{ padding: "10px 5px" }}>
<DisplayHtml html={crowdfundData?.inlineContent} />
</Box>
<AttachmentCol>
{crowdfundData?.attachments?.length > 0 && (
<>
<CrowdfundSubTitle>Attachments</CrowdfundSubTitle>
</>
)}
{crowdfundData?.attachments?.map(attachment => {
if (attachment?.service === "AUDIO")
return (
<AudioPlayer
key={attachment.identifier}
fullFile={attachment}
filename={attachment.filename}
name={attachment.name}
identifier={attachment.identifier}
service="AUDIO"
jsonId={crowdfundData?.id}
user={crowdfundData?.user}
/>
);
return (
<PlayerBox
key={attachment.identifier}
sx={{
minHeight: "55px",
}}
>
<FileAttachmentContainer>
<AttachFileIcon
sx={{
height: "16px",
width: "auto",
}}
></AttachFileIcon>
<FileAttachmentFont>
{attachment?.filename}
</FileAttachmentFont>
<FileElement
fileInfo={attachment}
title={attachment?.filename}
customStyles={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<DownloadIcon />
</FileElement>
</FileAttachmentContainer>
</PlayerBox>
);
})}
</AttachmentCol>
</UpdateCol>
</UpdateContainer>
</>
);
};

188
src/pages/Home/CrowdfundList.tsx

@ -0,0 +1,188 @@
import React, { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import { RootState } from "../../state/store";
import { Avatar, Grid, Skeleton, useTheme } from "@mui/material";
import { useFetchCrowdfunds } from "../../hooks/useFetchCrowdfunds";
import LazyLoad from "../../components/common/LazyLoad";
import ResponsiveImage from "../../components/ResponsiveImage";
import { formatDate } from "../../utils/time";
import {
BottomWrapper,
CardContainer,
ChannelCard,
CrowdfundContainer,
CrowdfundDescription,
CrowdfundGoal,
CrowdfundGoalRow,
CrowdfundImageContainer,
CrowdfundListHeader,
CrowdfundListTitle,
CrowdfundOwner,
CrowdfundText,
CrowdfundTitle,
CrowdfundTitleCard,
DonateButton,
NameContainer,
} from "./Home-styles";
import {
CrowdfundListWrapper,
CrowdfundUploadDate,
} from "../../components/Crowdfund/Crowdfund-styles";
import CoverImageDefault from "../../assets/images/CoverImageDefault.webp";
import { Crowdfund } from "../../state/features/crowdfundSlice";
import { QortalSVG } from "../../assets/svgs/QortalSVG";
export const CrowdfundList = () => {
const theme = useTheme();
const [isLoading, setIsLoading] = useState<boolean>(false);
const firstFetch = useRef(false);
const afterFetch = useRef(false);
const isFetching = useRef(false);
const hashMapCrowdfunds = useSelector(
(state: RootState) => state.crowdfund.hashMapCrowdfunds
);
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
);
const globalCrowdfunds = useSelector(
(state: RootState) => state.crowdfund.crowdfunds
);
const navigate = useNavigate();
const { getCrowdfunds } = useFetchCrowdfunds();
const getCrowdfundsHandler = React.useCallback(async () => {
if (!firstFetch.current || !afterFetch.current) return;
if (isFetching.current) return;
isFetching.current = true;
await getCrowdfunds();
isFetching.current = false;
}, [getCrowdfunds]);
const getCrowdfundHandlerMount = React.useCallback(async () => {
if (firstFetch.current) return;
firstFetch.current = true;
setIsLoading(true);
await getCrowdfunds();
afterFetch.current = true;
setIsLoading(false);
}, [getCrowdfunds]);
const crowdfunds = globalCrowdfunds;
useEffect(() => {
if (!firstFetch.current && globalCrowdfunds.length === 0) {
isFetching.current = true;
getCrowdfundHandlerMount();
} else {
firstFetch.current = true;
afterFetch.current = true;
}
}, [getCrowdfundHandlerMount, globalCrowdfunds]);
return (
<CrowdfundListWrapper>
<CrowdfundListHeader>
<CrowdfundListTitle>Most Recent Q-Funds</CrowdfundListTitle>
</CrowdfundListHeader>
<CrowdfundContainer container spacing={3} direction={"row"}>
{crowdfunds.map((crowdfund: Crowdfund) => {
const existingCrowdfund = hashMapCrowdfunds[crowdfund.id];
let hasHash = false;
let crowdfundObj = crowdfund;
if (existingCrowdfund) {
crowdfundObj = existingCrowdfund;
hasHash = true;
}
let avatarUrl = "";
if (userAvatarHash[crowdfundObj?.user]) {
avatarUrl = userAvatarHash[crowdfundObj?.user];
}
return (
<Grid item xs={12} sm={6} md={4} lg={3} xl={3} key={crowdfund.id}>
<CardContainer
onClick={() => {
navigate(
`/crowdfund/${crowdfundObj.user}/${crowdfundObj.id}`
);
}}
>
<CrowdfundImageContainer>
{!hasHash ? (
<Skeleton variant="rectangular" width={100} height={250} />
) : (
<ResponsiveImage
src={crowdfundObj?.coverImage || CoverImageDefault}
width={100}
height={150}
styles={{
maxHeight: "250px",
minHeight: "250px",
objectFit: "cover",
}}
/>
)}
<CrowdfundTitleCard>
{!hasHash ? (
<Skeleton variant="text" sx={{ fontSize: "20px" }} />
) : (
<>
<CrowdfundTitle>{crowdfundObj?.title}</CrowdfundTitle>
<NameContainer>
<Avatar
sx={{ height: 30, width: 30 }}
src={avatarUrl}
alt={`${crowdfundObj.user}'s avatar`}
/>
<CrowdfundOwner>
by {crowdfundObj?.user}
</CrowdfundOwner>
</NameContainer>
</>
)}
</CrowdfundTitleCard>
</CrowdfundImageContainer>
{!hasHash ? (
<Skeleton variant="rectangular" width={"100%"} height={250} />
) : (
<ChannelCard key={crowdfundObj.id}>
<CrowdfundDescription>
{crowdfundObj?.description}
</CrowdfundDescription>
<CrowdfundGoalRow>
<CrowdfundText>Campaign Goal:</CrowdfundText>
<CrowdfundGoal>
<QortalSVG
height={"22"}
width={"22"}
color={theme.palette.text.primary}
/>
{crowdfundObj?.deployedAT?.goalValue}
</CrowdfundGoal>
</CrowdfundGoalRow>
<BottomWrapper>
{crowdfundObj?.created && (
<CrowdfundUploadDate>
{formatDate(crowdfundObj.created)}
</CrowdfundUploadDate>
)}
<DonateButton>Back this project</DonateButton>
</BottomWrapper>
</ChannelCard>
)}
</CardContainer>
</Grid>
);
})}
</CrowdfundContainer>
<LazyLoad
onLoadMore={getCrowdfundsHandler}
isLoading={isLoading}
></LazyLoad>
</CrowdfundListWrapper>
);
};

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

@ -0,0 +1,329 @@
import { styled } from "@mui/system";
import { Box, Grid, Typography, Checkbox, Button } from "@mui/material";
export const HomepageTitleRow = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: "20px",
}));
export const Logo = styled("img")(({ theme }) => ({
width: "100px",
height: "100px",
}));
export const SubtitleContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
margin: "10px 0px",
width: "100%",
}));
export const Subtitle = styled(Typography)({
textAlign: "center",
fontSize: "20px",
});
const DoubleLine = styled(Typography)`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
`;
export const ChannelTitle = styled(DoubleLine)(({ theme }) => ({
fontFamily: "Cairo",
fontSize: "20px",
letterSpacing: "0.4px",
color: theme.palette.text.primary,
userSelect: "none",
marginBottom: "auto",
textAlign: "center",
}));
export const WelcomeTitle = styled(DoubleLine)(({ theme }) => ({
fontFamily: "Cairo",
fontSize: "24px",
letterSpacing: "0.4px",
color: theme.palette.text.primary,
userSelect: "none",
textAlign: "center",
}));
export const WelcomeContainer = styled(Box)(({ theme }) => ({
position: "fixed",
width: "90%",
height: "90%",
backgroundColor: theme.palette.background.paper,
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
zIndex: 500,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}));
export const ChannelCard = styled(Grid)(({ theme }) => ({
position: "relative",
display: "flex",
flexDirection: "column",
height: "auto",
maxWidth: "100%",
minHeight: "250px",
maxHeight: "250px",
backgroundColor: theme.palette.background.paper,
borderRadius: "0 0 8px 8px",
padding: "10px 15px",
gap: "20px",
border:
theme.palette.mode === "dark"
? "none"
: `1px solid ${theme.palette.primary.light}`,
boxShadow:
theme.palette.mode === "dark"
? "0px 4px 5px 0px hsla(0,0%,0%,0.14), 0px 1px 10px 0px hsla(0,0%,0%,0.12), 0px 2px 4px -1px hsla(0,0%,0%,0.2)"
: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
boxShadow:
theme.palette.mode === "dark"
? "0px 8px 10px 1px hsla(0,0%,0%,0.14), 0px 3px 14px 2px hsla(0,0%,0%,0.12), 0px 5px 5px -3px hsla(0,0%,0%,0.2)"
: "rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;",
},
}));
export const CrowdfundContainer = styled(Grid)(({ theme }) => ({
position: "relative",
display: "flex",
padding: "15px",
justifyContent: "center",
alignItems: "center",
}));
export const BottomWrapper = styled(Box)(({ theme }) => ({
position: "relative",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "auto",
}));
export const HomePageContainer = styled(Box)(({ theme }) => ({
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: "100%",
gap: "20px",
background: "linear-gradient(135deg, #74d7c5 0%, #34bfa6 49%, #159892 100%)",
}));
export const HomePageSubContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "40px",
paddingBottom: "50px",
textAlign: "center",
}));
export const HomepageTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontWeight: 400,
fontSize: "55px",
letterSpacing: "1px",
color: "white",
userSelect: "none",
}));
export const HomepageDescription = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "25px",
letterSpacing: "0px",
color: "white",
userSelect: "none",
padding: "0 200px",
}));
export const StepsContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "space-evenly",
alignItems: "flex-start",
gap: "20px",
width: "100%",
padding: "0 30px",
}));
export const StepCol = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: "10px",
width: "100%",
}));
export const StepIcon = styled(Box)(({ theme }) => ({
width: "100px",
height: "100px",
border: "2px solid white",
borderRadius: "50%",
backgroundColor: "transparent",
display: "flex",
justifyContent: "center",
alignItems: "center",
}));
export const StepTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontWeight: 400,
fontSize: "25px",
letterSpacing: "1px",
color: "white",
userSelect: "none",
}));
export const StepDescription = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontWeight: 400,
fontSize: "19px",
letterSpacing: "0px",
color: "white",
userSelect: "none",
}));
export const CrowdfundListHeader = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
width: "100%",
padding: "25px 0 10px 45px",
}));
export const CrowdfundListTitle = styled(Typography)(({ theme }) => ({
fontFamily: "Copse",
fontWeight: 400,
fontSize: "25px",
letterSpacing: "0.5px",
color: theme.palette.text.primary,
userSelect: "none",
}));
export const CardContainer = styled(Box)({
display: "flex",
flexDirection: "column",
height: "auto",
width: "100%",
});
export const CrowdfundImageContainer = styled(Box)({
display: "flex",
position: "relative",
cursor: "pointer",
});
export const CrowdfundTitleCard = styled(Box)(({ theme }) => ({
position: "absolute",
bottom: 0,
display: "flex",
flexDirection: "column",
height: "auto",
width: "100%",
backgroundColor:
theme.palette.mode === "dark"
? "rgba(142, 146, 223, 0.8)"
: "rgba(169, 217, 208, 0.8)",
color: theme.palette.text.primary,
padding: "5px 15px",
gap: "5px",
}));
export const CrowdfundTitle = styled(Typography)({
fontFamily: "Montserrat",
fontWeight: 400,
fontSize: "20px",
letterSpacing: "0.4px",
userSelect: "none",
});
export const NameContainer = styled(Box)({
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
gap: "5px",
});
export const CrowdfundOwner = styled(Typography)({
fontFamily: "Mulish",
fontSize: "16px",
letterSpacing: "0px",
userSelect: "none",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
width: "100%",
});
export const CrowdfundDescription = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
fontSize: "16px",
letterSpacing: "0px",
color: theme.palette.text.primary,
userSelect: "none",
}));
export const DonateButton = styled(Button)(({ theme }) => ({
display: "flex",
justifyContent: "center",
alignItems: "center",
minWidth: "200px",
width: "44%",
padding: "5px 25px",
borderRadius: "20px",
backgroundColor: theme.palette.primary.main,
color: "white",
fontFamily: "Mulish",
fontSize: "14px",
fontWeight: 400,
letterSpacing: "0.4px",
textTransform: "none",
transition: "all 0.3s ease-in-out",
"&:hover": {
backgroundColor: theme.palette.primary.dark,
cursor: "pointer",
},
}));
export const CrowdfundGoalRow = styled(Box)({
display: "flex",
alignItems: "center",
gap: "7px",
width: "100%",
});
export const CrowdfundText = styled(Typography)({
fontFamily: "Mulish",
fontSize: "18px",
fontWeight: 400,
letterSpacing: "0.4px",
userSelect: "none",
});
export const CrowdfundGoal = styled(Box)({
display: "flex",
alignItems: "center",
gap: "3px",
fontFamily: "Mulish",
fontSize: "18px",
fontWeight: 400,
letterSpacing: "0.4px",
});

122
src/pages/Home/Home.tsx

@ -0,0 +1,122 @@
import React, { useEffect } from 'react';
import { NewCrowdfund } from '../../components/Crowdfund/NewCrowdfund';
import { CrowdfundList } from './CrowdfundList';
import {
HomePageContainer,
HomePageSubContainer,
HomepageDescription,
HomepageTitle,
HomepageTitleRow,
Logo,
StepCol,
StepDescription,
StepIcon,
StepTitle,
StepsContainer,
} from './Home-styles';
import NavBar from '../../components/layout/Navbar/Navbar';
import { useDispatch, useSelector } from 'react-redux';
import { addUser } from '../../state/features/authSlice';
import { RootState } from '../../state/store';
import { ExploreSVG } from '../../assets/svgs/ExploreSVG';
import { DonateSVG } from '../../assets/svgs/DonateSVG';
import { TrackSVG } from '../../assets/svgs/TrackSVG';
import QFundLogo from '../../assets/images/QFundDarkLogo.png';
interface Props {
setTheme: (val: string) => void;
}
export const Home: React.FC<Props> = ({ setTheme }) => {
const dispatch = useDispatch();
const user = useSelector((state: RootState) => state.auth.user);
async function getNameInfo(address: string) {
const response = await qortalRequest({
action: 'GET_ACCOUNT_NAMES',
address: address,
});
const nameData = response;
if (nameData?.length > 0) {
return nameData[0].name;
} else {
return '';
}
}
const askForAccountInformation = React.useCallback(async () => {
try {
const account = await qortalRequest({
action: 'GET_USER_ACCOUNT',
});
const name = await getNameInfo(account.address);
dispatch(addUser({ ...account, name }));
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
if (user?.name) return;
askForAccountInformation();
}, [user]);
return (
<>
<HomePageContainer>
<NavBar
fixed={false}
setTheme={(val: string) => setTheme(val)}
authenticate={askForAccountInformation}
isAuthenticated={!!user?.name}
/>
<HomePageSubContainer>
<HomepageTitleRow>
<Logo src={QFundLogo} alt="logo" />
<HomepageTitle>Q-Fund</HomepageTitle>
</HomepageTitleRow>
<HomepageDescription>
Q-Fund is a decentralized crowdfunding platform built on the Qortal
blockchain. It allows users to create and contribute to crowdfunding
campaigns that are stored on the blockchain.
</HomepageDescription>
<StepsContainer>
<StepCol>
<StepIcon>
<ExploreSVG color={'white'} height={'50px'} width={'50px'} />
</StepIcon>
<StepTitle>Explore Qortal Initiatives</StepTitle>
<StepDescription>
Read into new Q-Fund initiatives and learn about the projects
that are being proposed in the community.
</StepDescription>
</StepCol>
<StepCol>
<StepIcon>
<DonateSVG color={'white'} height={'50px'} width={'50px'} />
</StepIcon>
<StepTitle>Contribute</StepTitle>
<StepDescription>
Donate QORT to campaigns you want to support.
</StepDescription>
</StepCol>
<StepCol>
<StepIcon>
<TrackSVG color={'white'} height={'50px'} width={'50px'} />
</StepIcon>
<StepTitle>Track Your Support</StepTitle>
<StepDescription>
Track the progress of the Q-Funds you've donated to, and keep
track of other people's comments and the latest updates from the
campaign owner.
</StepDescription>
</StepCol>
</StepsContainer>
<NewCrowdfund />
</HomePageSubContainer>
</HomePageContainer>
<CrowdfundList />
</>
);
};

27
src/state/features/authSlice.ts

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

94
src/state/features/crowdfundSlice.ts

@ -0,0 +1,94 @@
import { createSlice } from '@reduxjs/toolkit';
import { RootState } from '../store'
interface GlobalState {
hashMapCrowdfunds: Record<string, Crowdfund>
crowdfunds: Crowdfund[]
}
const initialState: GlobalState = {
hashMapCrowdfunds: {},
crowdfunds: []
}
export interface Crowdfund {
title: string
description: string
created: number
user: string
attachments?: any[]
id: string
category?: string
categoryName?: string
tags?: string[]
inlineContent?: string
updated?: number | string
isValid?: boolean
coverImage?: string
deployedAT?: any
}
// export interface Crowdfund {
// title: string
// description: string
// created: number
// user: string
// attachments?: any[]
// id: string
// category?: string
// categoryName?: string
// tags?: string[]
// updated?: number | string
// isValid?: boolean
// inlineContent?: string
// coverImage?: string
// }
export const crowdfundSlice = createSlice({
name: 'crowdfund',
initialState,
reducers: {
addCrowdfundToBeginning: (state, action) => {
state.crowdfunds.unshift(action.payload)
},
addToHashMap: (state, action) => {
const crowdfund = action.payload
state.hashMapCrowdfunds[crowdfund.id] = crowdfund
},
updateInHashMap: (state, action) => {
const { id } = action.payload
const crowdfund = action.payload
state.hashMapCrowdfunds[id] = { ...crowdfund }
},
removeFromHashMap: (state, action) => {
const idToDelete = action.payload
delete state.hashMapCrowdfunds[idToDelete]
},
addArrayToHashMap: (state, action) => {
const crowdfunds = action.payload
crowdfunds.forEach((video: Crowdfund) => {
state.hashMapCrowdfunds[video.id] = video
})
},
upsertCrowdfunds: (state, action) => {
action.payload.forEach((crowdfund: Crowdfund) => {
const index = state.crowdfunds.findIndex((p) => p.id === crowdfund.id)
if (index !== -1) {
state.crowdfunds[index] = crowdfund
} else {
state.crowdfunds.push(crowdfund)
}
})
},
}
})
export const {
addCrowdfundToBeginning,
addToHashMap,
updateInHashMap,
removeFromHashMap,
addArrayToHashMap,
upsertCrowdfunds
} = crowdfundSlice.actions
export default crowdfundSlice.reducer

90
src/state/features/globalSlice.ts

@ -0,0 +1,90 @@
import { createSlice } from "@reduxjs/toolkit";
export interface OwnerReview {
id: string;
name: string;
title: string;
description: string;
created: number;
rating: number;
updated?: number;
}
interface GlobalState {
isLoadingGlobal: boolean;
downloads: any;
userAvatarHash: Record<string, string>;
videoPlaying: any | null;
ownerReviews: OwnerReview[];
hashMapOwnerReviews: Record<string, OwnerReview>;
}
const initialState: GlobalState = {
isLoadingGlobal: false,
downloads: {},
userAvatarHash: {},
videoPlaying: null,
ownerReviews: [],
hashMapOwnerReviews: {},
};
export const globalSlice = createSlice({
name: "global",
initialState,
reducers: {
setIsLoadingGlobal: (state, action) => {
state.isLoadingGlobal = action.payload;
},
setAddToDownloads: (state, action) => {
const download = action.payload;
state.downloads[download.identifier] = download;
},
updateDownloads: (state, action) => {
const { identifier } = action.payload;
const download = action.payload;
state.downloads[identifier] = {
...state.downloads[identifier],
...download,
};
},
setUserAvatarHash: (state, action) => {
const avatar = action.payload;
if (avatar?.name && avatar?.url) {
state.userAvatarHash[avatar?.name] = avatar?.url;
}
},
setVideoPlaying: (state, action) => {
state.videoPlaying = action.payload;
},
addToReviews: (state, action) => {
const newReview = action.payload;
state.ownerReviews.unshift(newReview);
},
addToHashMapOwnerReviews: (state, action) => {
const review = action.payload;
state.hashMapOwnerReviews[review.id] = review;
},
upsertReviews: (state, action) => {
action.payload.forEach((review: OwnerReview) => {
const index = state.ownerReviews.findIndex(p => p.id === review.id);
if (index !== -1) {
state.ownerReviews[index] = review;
} else {
state.ownerReviews.push(review);
}
});
},
},
});
export const {
setIsLoadingGlobal,
setAddToDownloads,
updateDownloads,
setUserAvatarHash,
setVideoPlaying,
addToReviews,
addToHashMapOwnerReviews,
upsertReviews,
} = globalSlice.actions;
export default globalSlice.reducer;

73
src/state/features/notificationsSlice.ts

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

27
src/state/store.ts

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

BIN
src/styles/fonts/Cairo.ttf

Binary file not shown.

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

Binary file not shown.

BIN
src/styles/fonts/Catamaran.ttf

Binary file not shown.

BIN
src/styles/fonts/Copse.ttf

Binary file not shown.

BIN
src/styles/fonts/Karla.ttf

Binary file not shown.

BIN
src/styles/fonts/Livvic.ttf

Binary file not shown.

BIN
src/styles/fonts/Merriweather Sans.ttf

Binary file not shown.

BIN
src/styles/fonts/Montserrat.ttf

Binary file not shown.

BIN
src/styles/fonts/Mulish.ttf

Binary file not shown.

BIN
src/styles/fonts/Oxygen.ttf

Binary file not shown.

BIN
src/styles/fonts/ProximaNova.otf

Binary file not shown.

BIN
src/styles/fonts/Raleway.ttf

Binary file not shown.

236
src/styles/theme.tsx

@ -0,0 +1,236 @@
import { createTheme } from "@mui/material/styles";
const commonThemeOptions = {
typography: {
fontFamily: [
"Mulish",
"Copse",
"Cambon Light",
"Raleway, sans-serif",
"Montserrat",
"Proxima Nova",
"Oxygen",
"Catamaran",
"Cairo",
"Arial",
].join(","),
h1: {
fontSize: "2rem",
fontWeight: 600,
},
h2: {
fontSize: "1.75rem",
fontWeight: 500,
},
h3: {
fontSize: "1.5rem",
fontWeight: 500,
},
h4: {
fontSize: "1.25rem",
fontWeight: 500,
},
h5: {
fontSize: "1rem",
fontWeight: 500,
},
h6: {
fontSize: "0.875rem",
fontWeight: 500,
},
body1: {
fontSize: "23px",
fontFamily: "Raleway",
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: "0.5px",
},
body2: {
fontSize: "18px",
fontFamily: "Raleway, Arial",
fontWeight: 400,
lineHeight: 1.4,
letterSpacing: "0.2px",
},
margin: 0,
},
spacing: 8,
shape: {
borderRadius: 4,
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 900,
lg: 1200,
xl: 1536,
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
backgroundColor: "inherit",
transition: "filter 0.3s ease-in-out",
"&:hover": {
filter: "brightness(1.1)",
},
},
},
defaultProps: {
disableElevation: true,
disableRipple: true,
},
},
},
};
const lightTheme = createTheme({
...commonThemeOptions,
palette: {
mode: "light",
primary: {
main: "#34BFA6",
dark: "#2c9d88",
light: "#A9D9D0",
},
secondary: {
main: "#57AAF2",
dark: "#2E83F2",
},
background: {
default: "#ffffff",
paper: "#F2F2F2",
},
text: {
primary: "#000000",
secondary: "#525252",
},
},
components: {
MuiCssBaseline: {
styleOverrides: {
"body::-webkit-scrollbar-track": {
backgroundColor: "#ffffff",
},
"body::-webkit-scrollbar-track:hover": {
backgroundColor: "#ffffff",
},
"body::-webkit-scrollbar": {
width: "16px",
height: "10px",
backgroundColor: "#ffffff",
},
"body::-webkit-scrollbar-thumb": {
backgroundColor: "#34BFA6",
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
transition: "all 0.3s ease-in-out",
},
"body::-webkit-scrollbar-thumb:hover": {
backgroundColor: "#2c9d88",
},
},
},
MuiCard: {
styleOverrides: {
root: {
boxShadow:
"rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;",
borderRadius: "8px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
boxShadow:
"rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;",
},
},
},
},
MuiIcon: {
defaultProps: {
style: {
color: "#000000",
},
},
},
},
});
const darkTheme = createTheme({
...commonThemeOptions,
palette: {
mode: "dark",
primary: {
main: "#6B6FBF",
dark: "#5a5da7",
light: "#979be0",
},
secondary: {
main: "#F2A74B",
dark: "#e39e3a",
},
background: {
default: "#1C1A26",
paper: "#434259",
},
text: {
primary: "#ffffff",
secondary: "#b3b3b3",
},
},
components: {
MuiCssBaseline: {
styleOverrides: {
"body::-webkit-scrollbar-track": {
backgroundColor: "#1C1A26",
},
"body::-webkit-scrollbar-track:hover": {
backgroundColor: "#1C1A26",
},
"body::-webkit-scrollbar": {
width: "16px",
height: "10px",
backgroundColor: "#1C1A26",
},
"body::-webkit-scrollbar-thumb": {
backgroundColor: "#6B6FBF",
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
transition: "all 0.3s ease-in-out",
},
"body::-webkit-scrollbar-thumb:hover": {
backgroundColor: "#5a5da7",
},
},
},
MuiCard: {
styleOverrides: {
root: {
boxShadow: "none",
borderRadius: "8px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
boxShadow:
" 0px 3px 4px 0px hsla(0,0%,0%,0.14), 0px 3px 3px -2px hsla(0,0%,0%,0.12), 0px 1px 8px 0px hsla(0,0%,0%,0.2);",
},
},
},
},
MuiIcon: {
defaultProps: {
style: {
color: "#ffffff",
},
},
},
},
});
export { lightTheme, darkTheme };

BIN
src/test/download.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

BIN
src/test/mockimg.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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

Loading…
Cancel
Save