initial commit
14
.eslintrc.cjs
Normal file
@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
},
|
||||
}
|
25
.gitignore
vendored
Normal file
@ -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
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"tabWidth": 2,
|
||||
"semi": true
|
||||
}
|
13
index.html
Normal file
@ -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
Normal file
55
package.json
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
src/assets/images/QFundDarkLogo.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
src/assets/images/QFundLightLogo.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
src/assets/img/qort.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
25
src/assets/svgs/AccountCircleSVG.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
interface AccountCircleSVGProps {
|
||||
color: string
|
||||
height: string
|
||||
width: string
|
||||
}
|
||||
|
||||
export const AccountCircleSVG: React.FC<AccountCircleSVGProps> = ({
|
||||
color,
|
||||
height,
|
||||
width
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={height}
|
||||
viewBox="0 96 960 960"
|
||||
width={width}
|
||||
>
|
||||
<path
|
||||
fill={color}
|
||||
d="M222 801q63-44 125-67.5T480 710q71 0 133.5 23.5T739 801q44-54 62.5-109T820 576q0-145-97.5-242.5T480 236q-145 0-242.5 97.5T140 576q0 61 19 116t63 109Zm257.814-195Q422 606 382.5 566.314q-39.5-39.686-39.5-97.5t39.686-97.314q39.686-39.5 97.5-39.5t97.314 39.686q39.5 39.686 39.5 97.5T577.314 566.5q-39.686 39.5-97.5 39.5Zm.654 370Q398 976 325 944.5q-73-31.5-127.5-86t-86-127.266Q80 658.468 80 575.734T111.5 420.5q31.5-72.5 86-127t127.266-86q72.766-31.5 155.5-31.5T635.5 207.5q72.5 31.5 127 86t86 127.032q31.5 72.532 31.5 155T848.5 731q-31.5 73-86 127.5t-127.032 86q-72.532 31.5-155 31.5ZM480 916q55 0 107.5-16T691 844q-51-36-104-55t-107-19q-54 0-107 19t-104 55q51 40 103.5 56T480 916Zm0-370q34 0 55.5-21.5T557 469q0-34-21.5-55.5T480 392q-34 0-55.5 21.5T403 469q0 34 21.5 55.5T480 546Zm0-77Zm0 374Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
23
src/assets/svgs/DarkModeSVG.tsx
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -0,0 +1,7 @@
|
||||
export interface IconTypes {
|
||||
color?: string;
|
||||
height: string;
|
||||
width: string;
|
||||
className?: string;
|
||||
onClickFunc?: (e?: any) => void;
|
||||
}
|
23
src/assets/svgs/LightModeSVG.tsx
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -0,0 +1,89 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { Box, Button, TextField, Typography, Modal } from '@mui/material'
|
||||
import {
|
||||
useDropzone,
|
||||
DropzoneRootProps,
|
||||
DropzoneInputProps
|
||||
} from 'react-dropzone'
|
||||
import Compressor from 'compressorjs'
|
||||
|
||||
const toBase64 = (file: File): Promise<string | ArrayBuffer | null> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = (error) => {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
interface ImageUploaderProps {
|
||||
children: React.ReactNode
|
||||
onPick: (base64Img: string) => void
|
||||
}
|
||||
|
||||
const ImageUploader: React.FC<ImageUploaderProps> = ({ children, onPick }) => {
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 1) {
|
||||
return
|
||||
}
|
||||
let compressedFile: File | undefined
|
||||
|
||||
try {
|
||||
const image = acceptedFiles[0]
|
||||
await new Promise<void>((resolve) => {
|
||||
new Compressor(image, {
|
||||
quality: 0.6,
|
||||
maxWidth: 1200,
|
||||
mimeType: 'image/webp',
|
||||
success(result) {
|
||||
const file = new File([result], 'name', {
|
||||
type: 'image/webp'
|
||||
})
|
||||
compressedFile = file
|
||||
resolve()
|
||||
},
|
||||
error(err) {}
|
||||
})
|
||||
})
|
||||
if (!compressedFile) return
|
||||
const base64Img = await toBase64(compressedFile)
|
||||
|
||||
onPick(base64Img as string)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
[onPick]
|
||||
)
|
||||
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive
|
||||
}: {
|
||||
getRootProps: () => DropzoneRootProps
|
||||
getInputProps: () => DropzoneInputProps
|
||||
isDragActive: boolean
|
||||
} = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': []
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUploader
|
56
src/components/ResponsiveImage.tsx
Normal file
@ -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
Normal file
@ -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;
|
@ -0,0 +1,28 @@
|
||||
import { styled } from '@mui/system';
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
export const StyledModal = styled(Modal)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}))
|
||||
|
||||
export const ModalContent = styled(Box)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
padding: theme.spacing(4),
|
||||
borderRadius: theme.spacing(1),
|
||||
width: '40%',
|
||||
'&:focus': {
|
||||
outline: 'none'
|
||||
}
|
||||
}))
|
||||
|
||||
export const ModalText = styled(Typography)(({ theme }) => ({
|
||||
fontFamily: "Raleway",
|
||||
fontSize: "25px",
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
100
src/components/common/BlockedNamesModal/BlockedNamesModal.tsx
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -0,0 +1,48 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
interface Props {
|
||||
onLoadMore: () => Promise<void>
|
||||
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
Normal file
@ -0,0 +1,86 @@
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { toast, ToastContainer, Zoom, Slide } from 'react-toastify'
|
||||
import { removeNotification } from '../../../state/features/notificationsSlice'
|
||||
import 'react-toastify/dist/ReactToastify.css'
|
||||
import { RootState } from '../../../state/store'
|
||||
|
||||
const Notification = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const { alertTypes } = useSelector((state: RootState) => state.notifications)
|
||||
|
||||
if (alertTypes.alertError) {
|
||||
toast.error(`❌ ${alertTypes?.alertError}`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 4000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
icon: false
|
||||
})
|
||||
dispatch(removeNotification())
|
||||
}
|
||||
if (alertTypes.alertSuccess) {
|
||||
toast.success(`✔️ ${alertTypes?.alertSuccess}`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 4000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
icon: false
|
||||
})
|
||||
dispatch(removeNotification())
|
||||
}
|
||||
if (alertTypes.alertInfo) {
|
||||
toast.info(`${alertTypes?.alertInfo}`, {
|
||||
position: 'top-right',
|
||||
autoClose: 1300,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
theme: 'light'
|
||||
})
|
||||
dispatch(removeNotification())
|
||||
}
|
||||
|
||||
if (alertTypes.alertInfo) {
|
||||
return (
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={2000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
toastStyle={{ fontSize: '16px' }}
|
||||
transition={Slide}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContainer
|
||||
transition={Zoom}
|
||||
position="bottom-right"
|
||||
autoClose={false}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
draggable
|
||||
pauseOnHover
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Notification
|
42
src/components/common/PageLoader.tsx
Normal file
@ -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
Normal file
@ -0,0 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
interface PortalProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Portal: React.FC<PortalProps> = ({ children }) => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
return () => setMounted(false)
|
||||
}, [])
|
||||
|
||||
return mounted
|
||||
? createPortal(
|
||||
children,
|
||||
document.querySelector('#modal-root') as HTMLElement
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
export default Portal
|
92
src/components/common/Progress/Progress-styles.tsx
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -0,0 +1,17 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
interface CustomWindow extends Window {
|
||||
_qdnBase: any
|
||||
}
|
||||
|
||||
const customWindow = window as unknown as CustomWindow
|
||||
|
||||
const baseUrl = customWindow?._qdnBase || ''
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<BrowserRouter basename={baseUrl}>
|
||||
<App />
|
||||
<div id="modal-root" />
|
||||
</BrowserRouter>
|
||||
)
|
721
src/pages/Crowdfund/Crowdfund.tsx
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -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
Normal file
@ -0,0 +1,27 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
|
||||
interface AuthState {
|
||||
user: {
|
||||
address: string;
|
||||
publicKey: string;
|
||||
name?: string;
|
||||
} | null;
|
||||
}
|
||||
const initialState: AuthState = {
|
||||
user: null
|
||||
};
|
||||
|
||||
export const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
addUser: (state, action) => {
|
||||
state.user = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addUser } = authSlice.actions;
|
||||
|
||||
export default authSlice.reducer;
|
94
src/state/features/crowdfundSlice.ts
Normal file
@ -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
Normal file
@ -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
Normal file
@ -0,0 +1,73 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface AlertTypes {
|
||||
alertSuccess: string
|
||||
alertError: string
|
||||
alertInfo: string
|
||||
}
|
||||
|
||||
interface InitialState {
|
||||
alertTypes: AlertTypes
|
||||
}
|
||||
|
||||
const initialState: InitialState = {
|
||||
alertTypes: {
|
||||
alertSuccess: '',
|
||||
alertError: '',
|
||||
alertInfo: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationsSlice = createSlice({
|
||||
name: "notifications",
|
||||
initialState,
|
||||
reducers: {
|
||||
setNotification: (
|
||||
state: InitialState,
|
||||
action: PayloadAction<{ alertType: string; msg: string }>
|
||||
) => {
|
||||
if (action.payload.alertType === "success") {
|
||||
return {
|
||||
...state,
|
||||
alertTypes: {
|
||||
...state.alertTypes,
|
||||
alertSuccess: action.payload.msg,
|
||||
},
|
||||
};
|
||||
} else if (action.payload.alertType === "error") {
|
||||
return {
|
||||
...state,
|
||||
alertTypes: {
|
||||
...state.alertTypes,
|
||||
alertError: action.payload.msg,
|
||||
},
|
||||
};
|
||||
} else if (action.payload.alertType === "info") {
|
||||
return {
|
||||
...state,
|
||||
alertTypes: {
|
||||
...state.alertTypes,
|
||||
alertInfo: action.payload.msg,
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
},
|
||||
removeNotification: (state: InitialState) => {
|
||||
return {
|
||||
...state,
|
||||
alertTypes: {
|
||||
...state.alertTypes,
|
||||
alertSuccess: '',
|
||||
alertError: '',
|
||||
alertInfo: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setNotification, removeNotification } =
|
||||
notificationsSlice.actions;
|
||||
|
||||
export default notificationsSlice.reducer;
|
27
src/state/store.ts
Normal file
@ -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
Normal file
BIN
src/styles/fonts/Cambon-Light.ttf
Normal file
BIN
src/styles/fonts/Catamaran.ttf
Normal file
BIN
src/styles/fonts/Copse.ttf
Normal file
BIN
src/styles/fonts/Karla.ttf
Normal file
BIN
src/styles/fonts/Livvic.ttf
Normal file
BIN
src/styles/fonts/Merriweather Sans.ttf
Normal file
BIN
src/styles/fonts/Montserrat.ttf
Normal file
BIN
src/styles/fonts/Mulish.ttf
Normal file
BIN
src/styles/fonts/Oxygen.ttf
Normal file
BIN
src/styles/fonts/ProximaNova.otf
Normal file
BIN
src/styles/fonts/Raleway.ttf
Normal file
236
src/styles/theme.tsx
Normal file
@ -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
Normal file
After Width: | Height: | Size: 426 KiB |
BIN
src/test/mockimg.jpg
Normal file
After Width: | Height: | Size: 21 KiB |