@ -0,0 +1,24 @@ |
|||||||
|
# Logs |
||||||
|
logs |
||||||
|
*.log |
||||||
|
npm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
|
pnpm-debug.log* |
||||||
|
lerna-debug.log* |
||||||
|
*.zip |
||||||
|
node_modules |
||||||
|
dist |
||||||
|
dist-ssr |
||||||
|
*.local |
||||||
|
|
||||||
|
# Editor directories and files |
||||||
|
.vscode/* |
||||||
|
!.vscode/extensions.json |
||||||
|
.idea |
||||||
|
.DS_Store |
||||||
|
*.suo |
||||||
|
*.ntvs* |
||||||
|
*.njsproj |
||||||
|
*.sln |
||||||
|
*.sw? |
@ -0,0 +1,10 @@ |
|||||||
|
{ |
||||||
|
"printWidth": 80, |
||||||
|
"singleQuote": false, |
||||||
|
"trailingComma": "es5", |
||||||
|
"bracketSpacing": true, |
||||||
|
"jsxBracketSameLine": false, |
||||||
|
"arrowParens": "avoid", |
||||||
|
"tabWidth": 2, |
||||||
|
"semi": true |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8" /> |
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<title>Q-Mail</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="root"></div> |
||||||
|
<script type="module" src="/src/main.tsx"></script> |
||||||
|
</body> |
||||||
|
</html> |
@ -0,0 +1,65 @@ |
|||||||
|
{ |
||||||
|
"name": "q-mail", |
||||||
|
"private": true, |
||||||
|
"version": "0.0.0", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "tsc && vite build", |
||||||
|
"preview": "vite preview" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@emotion/react": "^11.10.6", |
||||||
|
"@emotion/styled": "^11.10.6", |
||||||
|
"@mui/icons-material": "^5.11.11", |
||||||
|
"@mui/material": "^5.11.13", |
||||||
|
"@reduxjs/toolkit": "^1.9.3", |
||||||
|
"@tiptap/core": "^2.0.4", |
||||||
|
"@tiptap/extension-highlight": "^2.0.4", |
||||||
|
"@tiptap/extension-underline": "^2.0.4", |
||||||
|
"@tiptap/starter-kit": "^2.0.4", |
||||||
|
"@types/react-grid-layout": "^1.3.2", |
||||||
|
"axios": "^1.3.4", |
||||||
|
"compressorjs": "^1.2.1", |
||||||
|
"dompurify": "^3.0.3", |
||||||
|
"flexlayout-react": "^0.7.9", |
||||||
|
"localforage": "^1.10.0", |
||||||
|
"mime": "^4.0.1", |
||||||
|
"moment": "^2.29.4", |
||||||
|
"philliplm-react-modern-audio-player": "^1.4.6", |
||||||
|
"quill-image-resize-module-react": "^3.0.0", |
||||||
|
"react": "^18.2.0", |
||||||
|
"react-copy-to-clipboard": "^5.1.0", |
||||||
|
"react-dnd": "^16.0.1", |
||||||
|
"react-dnd-html5-backend": "^16.0.1", |
||||||
|
"react-dom": "^18.2.0", |
||||||
|
"react-dropzone": "^14.2.3", |
||||||
|
"react-grid-layout": "^1.3.4", |
||||||
|
"react-intersection-observer": "^9.4.3", |
||||||
|
"react-joyride": "^2.5.4", |
||||||
|
"react-masonry-css": "^1.0.16", |
||||||
|
"react-quill": "^2.0.0", |
||||||
|
"react-redux": "^8.0.5", |
||||||
|
"react-resize-detector": "^8.0.4", |
||||||
|
"react-router-dom": "^6.9.0", |
||||||
|
"react-toastify": "^9.1.2", |
||||||
|
"react-virtuoso": "^4.3.3", |
||||||
|
"short-unique-id": "^4.4.4", |
||||||
|
"slate": "^0.91.4", |
||||||
|
"slate-history": "^0.86.0", |
||||||
|
"slate-react": "^0.91.11" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@mui/types": "^7.2.3", |
||||||
|
"@types/dompurify": "^3.0.2", |
||||||
|
"@types/react": "^18.0.28", |
||||||
|
"@types/react-copy-to-clipboard": "^5.0.7", |
||||||
|
"@types/react-dom": "^18.0.11", |
||||||
|
"@vitejs/plugin-react": "^4.2.1", |
||||||
|
"core-js": "^3.30.2", |
||||||
|
"prettier": "^2.8.6", |
||||||
|
"typescript": "^4.9.3", |
||||||
|
"vite": "^5.0.10", |
||||||
|
"worker-loader": "^3.0.8" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,37 @@ |
|||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { Routes, Route } from 'react-router-dom' |
||||||
|
|
||||||
|
import { ThemeProvider } from '@mui/material/styles' |
||||||
|
import { CssBaseline } from '@mui/material' |
||||||
|
import { lightTheme, darkTheme } from './styles/theme' |
||||||
|
import { store } from './state/store' |
||||||
|
import { Provider } from 'react-redux' |
||||||
|
import GlobalWrapper from './wrappers/GlobalWrapper' |
||||||
|
import DownloadWrapper from './wrappers/DownloadWrapper' |
||||||
|
import Notification from './components/common/Notification/Notification' |
||||||
|
import { Mail } from './pages/Mail/Mail' |
||||||
|
|
||||||
|
function App() { |
||||||
|
const themeColor = window._qdnTheme |
||||||
|
|
||||||
|
return ( |
||||||
|
<Provider store={store}> |
||||||
|
<ThemeProvider theme={darkTheme}> |
||||||
|
<Notification /> |
||||||
|
<DownloadWrapper> |
||||||
|
<GlobalWrapper> |
||||||
|
<CssBaseline /> |
||||||
|
|
||||||
|
<Routes> |
||||||
|
<Route path="/" element={<Mail />} /> |
||||||
|
<Route path="/to/:name" element={<Mail isFromTo />} /> |
||||||
|
</Routes> |
||||||
|
</GlobalWrapper> |
||||||
|
</DownloadWrapper> |
||||||
|
</ThemeProvider> |
||||||
|
</Provider> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default App |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 6.3 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 2.3 KiB |
@ -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> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,21 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const AlignCenterSVG: React.FC<SVGProps> = ({ |
||||||
|
color, |
||||||
|
height, |
||||||
|
width |
||||||
|
}) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
height={height} |
||||||
|
width={width} |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
viewBox="0 96 960 960" |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M150 936q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 876h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 936H150Zm164-165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T314 711h333q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T647 771H314ZM150 606q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 546h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 606H150Zm164-165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T314 381h333q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T647 441H314ZM150 276q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 216h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 276H150Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const AlignLeftSVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M150 771q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 711h412q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T562 771H150Zm0-330q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 381h412q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T562 441H150Zm0 165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 546h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 606H150Zm0 330q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 876h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 936H150Zm0-660q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 216h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 276H150Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const AlignRightSVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M150 936q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 876h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 936H150Zm249-165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T399 711h411q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 771H399ZM150 606q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 546h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 606H150Zm249-165q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T399 381h411q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 441H399ZM150 276q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 216h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 276H150Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const BoldSVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M335 856q-25 0-42.5-17.5T275 796V356q0-25 17.5-42.5T335 296h168q66 0 114.5 42T666 444q0 38-21 70t-56 49v6q43 14 69.5 50t26.5 81q0 68-52.5 112T510 856H335Zm26-76h144q38 0 66-25t28-63q0-37-28-62t-66-25H361v175Zm0-247h136q35 0 60.5-23t25.5-58q0-35-25.5-58.5T497 370H361v163Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
import { IconTypes } from "./IconTypes"; |
||||||
|
|
||||||
|
export const CircleSVG: React.FC<IconTypes> = ({ |
||||||
|
color, |
||||||
|
height, |
||||||
|
width, |
||||||
|
className, |
||||||
|
onClickFunc, |
||||||
|
}) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
onClick={onClickFunc} |
||||||
|
className={className} |
||||||
|
fill={color} |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 -960 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path d="m424-296 282-282-56-56-226 226-114-114-56 56 170 170Zm56 216q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" /> |
||||||
|
</svg> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,35 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { styled } from '@mui/system'; |
||||||
|
import { SVGProps } from './interfaces'; |
||||||
|
|
||||||
|
// Create a styled container with hover effects
|
||||||
|
const HoverContainer = styled('div')({ |
||||||
|
display: 'inline-block', |
||||||
|
transition: 'transform 0.3s ease, opacity 0.3s ease', |
||||||
|
opacity: 1, // Default opacity
|
||||||
|
|
||||||
|
'&:hover': { |
||||||
|
transform: 'scale(1.5)', |
||||||
|
opacity: 1, // Increased opacity on hover
|
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
export const CloseSVG:React.FC<SVGProps> = ({ color, opacity }) => { |
||||||
|
return ( |
||||||
|
<HoverContainer> |
||||||
|
<svg |
||||||
|
width="12" |
||||||
|
height="12" |
||||||
|
viewBox="0 0 12 12" |
||||||
|
fill="none" |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
> |
||||||
|
<path |
||||||
|
d="M2 0L0 2L4 6L0 10L2 12L6 8L10 12L12 10L8 6L12 2L10 0L6 4L2 0Z" |
||||||
|
fill={color} |
||||||
|
fillOpacity={opacity} |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
</HoverContainer> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const CodeBlockSVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="m330 576 70-70q9-9 9-22t-9-22q-9-9-21.833-9-12.834 0-22.167 9l-93 93q-5 5-7 10.133-2 5.134-2 11Q254 582 256 587q2 5 7 10l94 94q9.333 9 22.167 9Q392 700 401 691q9-9 9-22t-9-22l-71-71Zm300 0-71 71q-9 9-9 22t9 22q9 9 21.833 9 12.834 0 22.167-9l94-94q5-5 7-10.133 2-5.134 2-11Q706 570 704 565q-2-5-7-10l-94-94q-4-5-10-7t-12-2q-6 0-11.5 2t-10.167 6.8Q550 470.4 550 483.2q0 12.8 9 21.8l71 71ZM180 936q-24 0-42-18t-18-42V276q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600V276H180v600Zm0-600v600-600Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { styled } from '@mui/system'; |
||||||
|
import { SVGProps } from './interfaces'; |
||||||
|
|
||||||
|
// Create a styled container with hover effects
|
||||||
|
const SvgContainer = styled('svg')({ |
||||||
|
'& path': { |
||||||
|
fill: 'rgba(41, 41, 43, 1)', // Default to red if no color prop
|
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
export const CreateThreadIcon:React.FC<SVGProps> = ({ color, opacity }) => { |
||||||
|
return ( |
||||||
|
<SvgContainer width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||||
|
<path d="M0 9.80209V9.0205C0.0460138 8.67679 0.080024 8.31425 0.144043 7.98466C0.469856 6.30568 1.25577 4.79934 2.38071 3.6977C4.13924 1.88262 6.22987 0.985679 8.52256 0.674927C9.9086 0.485649 11.3116 0.565177 12.6758 0.910345C14.5124 1.34351 16.1889 2.2075 17.6053 3.67886C18.7276 4.84183 19.5319 6.24257 19.858 7.98466C19.918 8.31189 19.952 8.64383 20 8.97577V9.80209C19.9827 9.8676 19.9693 9.93447 19.96 10.0022C19.8708 11.2186 19.5113 12.3861 18.9177 13.3875C17.961 15.0025 16.6297 16.2594 15.0825 17.0082C12.4657 18.3525 9.75693 18.5667 6.98209 17.8346C6.8589 17.8074 6.73157 17.8264 6.61799 17.8887C5.15955 18.7339 3.70511 19.5908 2.24867 20.4501C2.18866 20.4854 2.12464 20.5183 2.0146 20.5748L3.78714 16.3703C3.37301 16.0148 2.96889 15.7017 2.60078 15.3415C1.42243 14.1879 0.556167 12.7895 0.182055 11.0192C0.0980294 10.6213 0.060018 10.2094 0 9.80209ZM14.0042 10.5931C14.1362 10.5968 14.2676 10.5698 14.3907 10.5135C14.5138 10.4572 14.6262 10.3728 14.7214 10.2651C14.8167 10.1574 14.8928 10.0286 14.9455 9.8861C14.9982 9.7436 15.0264 9.59023 15.0285 9.43484V9.4113C15.0285 9.25517 15.0024 9.10058 14.9516 8.95634C14.9008 8.8121 14.8264 8.68104 14.7326 8.57064C14.6388 8.46025 14.5274 8.37268 14.4048 8.31293C14.2823 8.25319 14.1509 8.22243 14.0182 8.22243C13.8855 8.22243 13.7542 8.25319 13.6316 8.31293C13.509 8.37268 13.3976 8.46025 13.3038 8.57064C13.21 8.68104 13.1356 8.8121 13.0848 8.95634C13.034 9.10058 13.0079 9.25517 13.0079 9.4113C13.0074 9.56588 13.0327 9.71906 13.0825 9.86211C13.1323 10.0052 13.2055 10.1353 13.2981 10.245C13.3906 10.3547 13.5005 10.442 13.6217 10.5017C13.7429 10.5614 13.8728 10.5925 14.0042 10.5931ZM10.003 10.5931C10.203 10.5926 10.3983 10.5225 10.5644 10.3915C10.7306 10.2606 10.86 10.0746 10.9364 9.85719C11.0129 9.63976 11.0329 9.40056 10.9939 9.16977C10.9549 8.93898 10.8588 8.72694 10.7175 8.5604C10.5763 8.39385 10.3962 8.28026 10.2002 8.23396C10.0041 8.18765 9.80084 8.21071 9.61591 8.30022C9.43099 8.38973 9.27273 8.54168 9.1611 8.7369C9.04948 8.93212 8.98949 9.16187 8.9887 9.39717C8.98975 9.71356 9.09688 10.0167 9.28682 10.2406C9.47675 10.4646 9.73413 10.5912 10.003 10.5931ZM4.98349 9.3854C4.9836 9.61979 5.04316 9.8488 5.15456 10.0431C5.26595 10.2374 5.42411 10.3882 5.60876 10.476C5.79341 10.5639 5.99616 10.5849 6.19102 10.5364C6.38588 10.4878 6.56399 10.3719 6.70252 10.2035C6.84105 10.0351 6.93371 9.82183 6.96861 9.59108C7.00352 9.36032 6.97909 9.12255 6.89845 8.90823C6.8178 8.69392 6.68463 8.51281 6.51597 8.38811C6.34732 8.26342 6.15087 8.20081 5.95179 8.20831C5.69208 8.21809 5.44579 8.34641 5.26507 8.56611C5.08434 8.78581 4.98336 9.07963 4.98349 9.3854Z" fill="#29292B"/> |
||||||
|
</SvgContainer> |
||||||
|
|
||||||
|
|
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,23 @@ |
|||||||
|
import { IconTypes } from "./IconTypes"; |
||||||
|
|
||||||
|
export const EmptyCircleSVG: React.FC<IconTypes> = ({ |
||||||
|
color, |
||||||
|
height, |
||||||
|
width, |
||||||
|
className, |
||||||
|
onClickFunc, |
||||||
|
}) => { |
||||||
|
return ( |
||||||
|
|
||||||
|
<svg onClick={onClickFunc} |
||||||
|
className={className} |
||||||
|
fill={color} |
||||||
|
|
||||||
|
height={height} |
||||||
|
|
||||||
|
width={width} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" ><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const H2SVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M149.825 776Q137 776 128.5 767.375T120 746V406q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T180 406v140h180V406q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T420 406v340q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625-12.825 0-21.325-8.625T360 746V606H180v140q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625ZM570 776q-12.75 0-21.375-8.625T540 746V606q0-24.75 17.625-42.375T600 546h180V436H570q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T570 376h210q24.75 0 42.375 17.625T840 436v110q0 24.75-17.625 42.375T780 606H600v110h210q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 776H570Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const H3SVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M149.825 776Q137 776 128.5 767.375T120 746V406q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T180 406v140h180V406q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T420 406v340q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625-12.825 0-21.325-8.625T360 746V606H180v140q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625ZM570 776q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T570 716h210V606H650q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T650 546h130V436H570q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T570 376h210q24.75 0 42.375 17.625T840 436v280q0 24.75-17.625 42.375T780 776H570Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
export interface IconTypes { |
||||||
|
color?: string; |
||||||
|
height: string; |
||||||
|
width: string; |
||||||
|
className?: string; |
||||||
|
onClickFunc?: (e?: any) => void; |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const ItalicSVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M264 857q-16.8 0-28.4-11.641-11.6-11.641-11.6-28.5t11.6-28.359Q247.2 777 264 777h94l139-409H378q-16.8 0-28.4-11.641-11.6-11.641-11.6-28.5t11.6-28.359Q361.2 288 378 288h300q16.8 0 28.4 11.641 11.6 11.641 11.6 28.5T706.4 356.5Q694.8 368 678 368h-94L445 777h119q16.8 0 28.4 11.641 11.6 11.641 11.6 28.5T592.4 845.5Q580.8 857 564 857H264Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const LinkSVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M280 776q-85 0-142.5-57.5T80 576q0-85 57.5-142.5T280 376h140q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T420 436H280q-60 0-100 40t-40 100q0 60 40 100t100 40h140q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T420 776H280Zm75-170q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T355 546h250q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T605 606H355Zm185 170q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T540 716h140q60 0 100-40t40-100q0-60-40-100t-100-40H540q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T540 376h140q85 0 142.5 57.5T880 576q0 85-57.5 142.5T680 776H540Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
interface NewWindowSVGProps { |
||||||
|
color: string |
||||||
|
height: string |
||||||
|
width: string |
||||||
|
} |
||||||
|
|
||||||
|
export const NewWindowSVG: React.FC<NewWindowSVGProps> = ({ |
||||||
|
color, |
||||||
|
height, |
||||||
|
width |
||||||
|
}) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
width={width} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
> |
||||||
|
<path |
||||||
|
d="M180 936q-24 0-42-18t-18-42V276q0-24 18-42t42-18h300v60H180v600h600V576h60v300q0 24-18 42t-42 18H180Zm480-420V396H540v-60h120V216h60v120h120v60H720v120h-60Z" |
||||||
|
fill={color} |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { styled } from '@mui/system'; |
||||||
|
import { SVGProps } from './interfaces'; |
||||||
|
|
||||||
|
// Create a styled container with hover effects
|
||||||
|
const SvgContainer = styled('svg')({ |
||||||
|
'& path': { |
||||||
|
fill: 'rgba(41, 41, 43, 1)', // Default to red if no color prop
|
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
export const SendNewMessage:React.FC<SVGProps> = ({ color, opacity }) => { |
||||||
|
return ( |
||||||
|
<SvgContainer width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.33271 10.2306C2.88006 10.001 2.89088 9.65814 3.3554 9.46527L16.3563 4.06742C16.8214 3.87427 17.0961 4.11004 16.9689 4.59692L14.1253 15.4847C13.9985 15.9703 13.5515 16.1438 13.1241 15.8705L10.0773 13.9219C9.8629 13.7848 9.56272 13.8345 9.40985 14.0292L8.41215 15.2997C8.10197 15.6946 7.71724 15.6311 7.5525 15.1567L6.67584 12.6326C6.51125 12.1587 6.01424 11.5902 5.55821 11.359L3.33271 10.2306Z" /> |
||||||
|
</SvgContainer> |
||||||
|
|
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,17 @@ |
|||||||
|
import { SVGProps } from './interfaces' |
||||||
|
|
||||||
|
export const UnderlineSVG: React.FC<SVGProps> = ({ color, height, width }) => { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
height={height} |
||||||
|
viewBox="0 96 960 960" |
||||||
|
width={width} |
||||||
|
> |
||||||
|
<path |
||||||
|
fill={color} |
||||||
|
d="M230 916q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T230 856h500q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T730 916H230Zm250-140q-100 0-156.5-58.5T267 559V257q0-16.882 12.527-28.941Q292.055 216 309.027 216 326 216 338 228.059T350 257v302q0 63 34 101t96 38q62 0 96-38t34-101V257q0-16.882 12.527-28.941Q635.055 216 652.027 216 669 216 681 228.059T693 257v302q0 100-56.5 158.5T480 776Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,6 @@ |
|||||||
|
export interface SVGProps { |
||||||
|
color: string |
||||||
|
height: string |
||||||
|
width: string |
||||||
|
opacity?: number |
||||||
|
} |
@ -0,0 +1,96 @@ |
|||||||
|
import React, { useRef, useState, useEffect } from 'react' |
||||||
|
import ReactResizeDetector from 'react-resize-detector' |
||||||
|
import { Layouts, Layout } from 'react-grid-layout' |
||||||
|
|
||||||
|
interface DynamicHeightItemProps { |
||||||
|
children: React.ReactNode |
||||||
|
layouts: Layouts |
||||||
|
setLayouts: (layouts: any) => void |
||||||
|
i: string |
||||||
|
breakpoint: keyof Layouts |
||||||
|
rows?: number |
||||||
|
count?: number |
||||||
|
type?: string |
||||||
|
padding?: number |
||||||
|
} |
||||||
|
|
||||||
|
const DynamicHeightItem: React.FC<DynamicHeightItemProps> = ({ |
||||||
|
children, |
||||||
|
layouts, |
||||||
|
setLayouts, |
||||||
|
i, |
||||||
|
breakpoint, |
||||||
|
rows = 1, |
||||||
|
count, |
||||||
|
type, |
||||||
|
padding |
||||||
|
}) => { |
||||||
|
const [height, setHeight] = useState<number>(rows * 150) |
||||||
|
const ref = useRef<HTMLDivElement>(null) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (ref.current) { |
||||||
|
setHeight(ref.current.clientHeight) |
||||||
|
} |
||||||
|
}, [ref.current]) |
||||||
|
|
||||||
|
const onResize = () => { |
||||||
|
if (ref.current) { |
||||||
|
setHeight(ref.current.clientHeight) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getBreakpoint = (screenWidth: number) => { |
||||||
|
if (screenWidth >= 996) { |
||||||
|
return 'md' |
||||||
|
} else if (screenWidth >= 768) { |
||||||
|
return 'sm' |
||||||
|
} else { |
||||||
|
return 'xs' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const widthWin = window.innerWidth |
||||||
|
let newBreakpoint = breakpoint |
||||||
|
// if (!newBreakpoint) {
|
||||||
|
// newBreakpoint = getBreakpoint(widthWin)
|
||||||
|
// }
|
||||||
|
|
||||||
|
setLayouts((prev: any) => { |
||||||
|
const newLayouts: any = { ...prev } |
||||||
|
newLayouts[newBreakpoint] = newLayouts[newBreakpoint]?.map( |
||||||
|
(item: Layout) => { |
||||||
|
if (item.i === i) { |
||||||
|
let constantNum = 25 |
||||||
|
|
||||||
|
return { |
||||||
|
...item, |
||||||
|
h: Math.ceil(height / (rows * constantNum)) // Adjust this value based on your rowHeight and the number of rows the element spans
|
||||||
|
} |
||||||
|
} |
||||||
|
return item |
||||||
|
} |
||||||
|
) |
||||||
|
return newLayouts |
||||||
|
}) |
||||||
|
}, [height, breakpoint, count, setLayouts]) |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return ( |
||||||
|
<div ref={ref} style={{ width: '100%', height: 'auto' }}> |
||||||
|
<ReactResizeDetector handleHeight onResize={onResize}> |
||||||
|
<div |
||||||
|
style={{ |
||||||
|
padding: `${padding ? padding : 0}px` |
||||||
|
}} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</div> |
||||||
|
</ReactResizeDetector> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default DynamicHeightItem |
@ -0,0 +1,39 @@ |
|||||||
|
import React, { useRef, useState, useEffect } from 'react' |
||||||
|
import ReactResizeDetector from 'react-resize-detector' |
||||||
|
import { Layouts, Layout } from 'react-grid-layout' |
||||||
|
|
||||||
|
interface DynamicHeightItemProps { |
||||||
|
children: React.ReactNode |
||||||
|
layouts: Layouts |
||||||
|
setLayouts: (layouts: any) => void |
||||||
|
i: string |
||||||
|
breakpoint: keyof Layouts |
||||||
|
rows?: number |
||||||
|
count?: number |
||||||
|
type?: string |
||||||
|
padding?: number |
||||||
|
} |
||||||
|
|
||||||
|
export const DynamicHeightItemMinimal: React.FC<DynamicHeightItemProps> = ({ |
||||||
|
children, |
||||||
|
layouts, |
||||||
|
setLayouts, |
||||||
|
i, |
||||||
|
breakpoint, |
||||||
|
rows = 1, |
||||||
|
count, |
||||||
|
type, |
||||||
|
padding |
||||||
|
}) => { |
||||||
|
return ( |
||||||
|
<div style={{ width: '100%', height: 'auto' }}> |
||||||
|
<div |
||||||
|
style={{ |
||||||
|
padding: `${padding ? padding : 0}px` |
||||||
|
}} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,330 @@ |
|||||||
|
import * as React from "react"; |
||||||
|
import { styled, useTheme } from "@mui/material/styles"; |
||||||
|
import Box from "@mui/material/Box"; |
||||||
|
import Typography from "@mui/material/Typography"; |
||||||
|
import { useDispatch, useSelector } from "react-redux"; |
||||||
|
import { CircularProgress } from "@mui/material"; |
||||||
|
import { MyContext } from "../wrappers/DownloadWrapper"; |
||||||
|
import { RootState } from "../state/store"; |
||||||
|
import { setNotification } from "../state/features/notificationsSlice"; |
||||||
|
import { base64ToUint8Array } from "../utils/toBase64"; |
||||||
|
|
||||||
|
|
||||||
|
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; |
||||||
|
mimeTypeSaved?: string; |
||||||
|
disable?: boolean; |
||||||
|
mode?: string; |
||||||
|
otherUser?: string; |
||||||
|
customStyles?: any; |
||||||
|
loadStyles?: 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, |
||||||
|
children, |
||||||
|
mimeTypeSaved, |
||||||
|
disable, |
||||||
|
customStyles, |
||||||
|
loadStyles = {} |
||||||
|
}: IAudioElement) { |
||||||
|
const { downloadVideo } = React.useContext(MyContext); |
||||||
|
const [startedDownload, setStartedDownload] = React.useState<boolean>(false) |
||||||
|
const [isLoading, setIsLoading] = React.useState<boolean>(false); |
||||||
|
const [downloadLoader, setDownloadLoader] = React.useState<any>(false); |
||||||
|
const downloads = useSelector((state: RootState) => state.global?.downloads); |
||||||
|
const hasCommencedDownload = React.useRef(false); |
||||||
|
const dispatch = useDispatch(); |
||||||
|
const reDownload = React.useRef<boolean>(false) |
||||||
|
const status = React.useRef<null | string>(null) |
||||||
|
|
||||||
|
const isFetchingProperties = React.useRef<boolean>(false) |
||||||
|
const download = React.useMemo(() => { |
||||||
|
if (!downloads || !fileInfo?.identifier) return {}; |
||||||
|
const findDownload = downloads[fileInfo?.identifier]; |
||||||
|
|
||||||
|
if (!findDownload) return {}; |
||||||
|
return findDownload; |
||||||
|
}, [downloads, fileInfo]); |
||||||
|
|
||||||
|
const resourceStatus = React.useMemo(() => { |
||||||
|
return download?.status || {}; |
||||||
|
}, [download]); |
||||||
|
|
||||||
|
const handlePlay = async () => { |
||||||
|
if (disable) return; |
||||||
|
hasCommencedDownload.current = true; |
||||||
|
setStartedDownload(true) |
||||||
|
if ( |
||||||
|
resourceStatus?.status === "READY" |
||||||
|
) { |
||||||
|
if (downloadLoader) return; |
||||||
|
|
||||||
|
setDownloadLoader(true); |
||||||
|
let filename = download?.properties?.filename |
||||||
|
let mimeType = download?.properties?.type |
||||||
|
|
||||||
|
try { |
||||||
|
const { name, service, identifier } = fileInfo; |
||||||
|
|
||||||
|
const res = await qortalRequest({ |
||||||
|
action: "GET_QDN_RESOURCE_PROPERTIES", |
||||||
|
name: name, |
||||||
|
service: service, |
||||||
|
identifier: identifier, |
||||||
|
}); |
||||||
|
filename = res?.filename || filename; |
||||||
|
mimeType = res?.mimeType || mimeType || mimeTypeSaved; |
||||||
|
} catch (error) { |
||||||
|
|
||||||
|
} |
||||||
|
try { |
||||||
|
const { name, service, identifier } = fileInfo; |
||||||
|
|
||||||
|
let resData = await qortalRequest({ |
||||||
|
action: 'FETCH_QDN_RESOURCE', |
||||||
|
name: name, |
||||||
|
service: service, |
||||||
|
identifier: identifier, |
||||||
|
encoding: 'base64' |
||||||
|
}) |
||||||
|
|
||||||
|
let requestEncryptBody: any = { |
||||||
|
action: 'DECRYPT_DATA', |
||||||
|
encryptedData: resData } |
||||||
|
const resDecrypt = await qortalRequest(requestEncryptBody) |
||||||
|
|
||||||
|
if (!resDecrypt) throw new Error('Unable to decrypt file') |
||||||
|
const decryptToUnit8Array = base64ToUint8Array(resDecrypt) |
||||||
|
let blob = null |
||||||
|
if (mimeType) { |
||||||
|
blob = new Blob([decryptToUnit8Array], { |
||||||
|
type: mimeType |
||||||
|
}) |
||||||
|
} else { |
||||||
|
blob = new Blob([decryptToUnit8Array]) |
||||||
|
} |
||||||
|
|
||||||
|
if (!blob) throw new Error('Unable to build file into blob') |
||||||
|
await qortalRequest({ |
||||||
|
action: 'SAVE_FILE', |
||||||
|
blob, |
||||||
|
filename: |
||||||
|
download?.properties?.originalFilename || |
||||||
|
filename, |
||||||
|
mimeType |
||||||
|
}) |
||||||
|
|
||||||
|
//old
|
||||||
|
|
||||||
|
// const url = `/arbitrary/${service}/${name}/${identifier}`;
|
||||||
|
// fetch(url)
|
||||||
|
// .then(response => response.blob())
|
||||||
|
// .then(async blob => {
|
||||||
|
|
||||||
|
// await qortalRequest({
|
||||||
|
// action: "SAVE_FILE",
|
||||||
|
// blob,
|
||||||
|
// filename: filename,
|
||||||
|
// mimeType,
|
||||||
|
// });
|
||||||
|
// })
|
||||||
|
// .catch(error => {
|
||||||
|
// console.error("Error fetching the video:", error);
|
||||||
|
// });
|
||||||
|
} catch (error: any) { |
||||||
|
let notificationObj: any = null; |
||||||
|
if (typeof error === "string") { |
||||||
|
notificationObj = { |
||||||
|
msg: error || "Failed to send message", |
||||||
|
alertType: "error", |
||||||
|
}; |
||||||
|
} else if (typeof error?.error === "string") { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.error || "Failed to send message", |
||||||
|
alertType: "error", |
||||||
|
}; |
||||||
|
} else { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.message || "Failed to send message", |
||||||
|
alertType: "error", |
||||||
|
}; |
||||||
|
} |
||||||
|
if (!notificationObj) return; |
||||||
|
dispatch(setNotification(notificationObj)); |
||||||
|
} finally { |
||||||
|
setDownloadLoader(false); |
||||||
|
} |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const { name, service, identifier } = fileInfo; |
||||||
|
|
||||||
|
setIsLoading(true); |
||||||
|
downloadVideo({ |
||||||
|
name, |
||||||
|
service, |
||||||
|
identifier, |
||||||
|
properties: { |
||||||
|
...fileInfo, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const refetch = React.useCallback(async () => { |
||||||
|
if (!fileInfo) return |
||||||
|
try { |
||||||
|
const { name, service, identifier } = fileInfo; |
||||||
|
isFetchingProperties.current = true |
||||||
|
await qortalRequest({ |
||||||
|
action: 'GET_QDN_RESOURCE_PROPERTIES', |
||||||
|
name, |
||||||
|
service, |
||||||
|
identifier |
||||||
|
}) |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
|
||||||
|
} finally { |
||||||
|
isFetchingProperties.current = false |
||||||
|
} |
||||||
|
|
||||||
|
}, [fileInfo]) |
||||||
|
|
||||||
|
const refetchInInterval = ()=> { |
||||||
|
try { |
||||||
|
const interval = setInterval(()=> { |
||||||
|
if(status?.current === 'DOWNLOADED'){ |
||||||
|
refetch() |
||||||
|
} |
||||||
|
if(status?.current === 'READY'){ |
||||||
|
clearInterval(interval); |
||||||
|
} |
||||||
|
|
||||||
|
}, 7500) |
||||||
|
} catch (error) { |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
if(resourceStatus?.status){ |
||||||
|
status.current = resourceStatus?.status |
||||||
|
} |
||||||
|
if ( |
||||||
|
resourceStatus?.status === "READY" && |
||||||
|
download?.url && |
||||||
|
download?.properties?.filename && |
||||||
|
hasCommencedDownload.current |
||||||
|
) { |
||||||
|
setIsLoading(false); |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: "Download completed. Click to save file", |
||||||
|
alertType: "info", |
||||||
|
}) |
||||||
|
); |
||||||
|
} else if ( |
||||||
|
resourceStatus?.status === 'DOWNLOADED' && |
||||||
|
reDownload?.current === false |
||||||
|
) { |
||||||
|
refetchInInterval() |
||||||
|
reDownload.current = true |
||||||
|
} |
||||||
|
}, [resourceStatus, download]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
onClick={handlePlay} |
||||||
|
sx={{ |
||||||
|
width: "100%", |
||||||
|
overflow: "hidden", |
||||||
|
position: "relative", |
||||||
|
cursor: "pointer", |
||||||
|
...(customStyles || {}), |
||||||
|
}} |
||||||
|
> |
||||||
|
{children && ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: "flex", |
||||||
|
alignItems: "center", |
||||||
|
position: "relative", |
||||||
|
gap: "7px", |
||||||
|
}} |
||||||
|
> |
||||||
|
{children}{" "} |
||||||
|
{((resourceStatus.status && resourceStatus?.status !== "READY") || |
||||||
|
isLoading) && startedDownload ? ( |
||||||
|
<> |
||||||
|
<CircularProgress color="secondary" size={14} /> |
||||||
|
<Typography style={{ |
||||||
|
...loadStyles |
||||||
|
}} variant="body2">{`${Math.round( |
||||||
|
resourceStatus?.percentLoaded || 0 |
||||||
|
).toFixed(0)}% loaded`}</Typography>
|
||||||
|
</> |
||||||
|
) : resourceStatus?.status === "READY" ? ( |
||||||
|
<> |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontSize: "14px", |
||||||
|
}} |
||||||
|
style={{ |
||||||
|
...loadStyles |
||||||
|
}} |
||||||
|
> |
||||||
|
Click to save |
||||||
|
</Typography> |
||||||
|
{downloadLoader && ( |
||||||
|
<CircularProgress color="secondary" size={14} /> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) : null} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,230 @@ |
|||||||
|
import React, { useState, useEffect } from 'react' |
||||||
|
import { styled, Box } from '@mui/system' |
||||||
|
import { |
||||||
|
Drawer, |
||||||
|
List, |
||||||
|
ListItem, |
||||||
|
ListItemText, |
||||||
|
Typography, |
||||||
|
ButtonBase, |
||||||
|
Button, |
||||||
|
Tooltip |
||||||
|
} from '@mui/material' |
||||||
|
import VideoCallIcon from '@mui/icons-material/VideoCall' |
||||||
|
import VideoModal from './VideoPublishModal' |
||||||
|
import { useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import { AudioModal } from './AudioPublishModal' |
||||||
|
import AudioFileIcon from '@mui/icons-material/AudioFile' |
||||||
|
interface VideoPanelProps { |
||||||
|
onSelect: (video: Video) => void |
||||||
|
height?: string |
||||||
|
width?: string |
||||||
|
} |
||||||
|
|
||||||
|
interface VideoApiResponse { |
||||||
|
videos: Video[] |
||||||
|
} |
||||||
|
|
||||||
|
const Panel = styled('div')` |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
width: 100%; |
||||||
|
padding-bottom: 10px; |
||||||
|
height: 100%; |
||||||
|
overflow: hidden; |
||||||
|
|
||||||
|
&::-webkit-scrollbar { |
||||||
|
width: 8px; |
||||||
|
height: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb { |
||||||
|
background-color: #888; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover { |
||||||
|
background-color: #555; |
||||||
|
} |
||||||
|
` |
||||||
|
|
||||||
|
const PublishButton = styled(Button)` |
||||||
|
/* position: absolute; |
||||||
|
bottom: 20px; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
margin: auto; */ |
||||||
|
max-width: 80%; |
||||||
|
` |
||||||
|
|
||||||
|
export const AudioPanel: React.FC<VideoPanelProps> = ({ |
||||||
|
onSelect, |
||||||
|
height, |
||||||
|
width |
||||||
|
}) => { |
||||||
|
const [isOpen, setIsOpen] = useState(false) |
||||||
|
const [videos, setVideos] = useState<Video[]>([]) |
||||||
|
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false) |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
|
||||||
|
const fetchVideos = React.useCallback(async (): Promise<Video[]> => { |
||||||
|
if (!user?.name) return [] |
||||||
|
|
||||||
|
let res |
||||||
|
try { |
||||||
|
res = await qortalRequest({ |
||||||
|
action: 'LIST_QDN_RESOURCES', |
||||||
|
service: 'AUDIO', |
||||||
|
name: user.name, |
||||||
|
includeMetadata: true, |
||||||
|
limit: 100, |
||||||
|
offset: 0, |
||||||
|
reverse: true |
||||||
|
}) |
||||||
|
} catch (error) { |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
// Replace this URL with the actual API endpoint
|
||||||
|
|
||||||
|
return res |
||||||
|
}, [user]) |
||||||
|
useEffect(() => { |
||||||
|
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleToggle = () => { |
||||||
|
setIsOpen(!isOpen) |
||||||
|
} |
||||||
|
|
||||||
|
const handleClick = (video: Video) => { |
||||||
|
onSelect(video) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Tooltip title="Add an audio file" arrow> |
||||||
|
<AudioFileIcon |
||||||
|
onClick={handleToggle} |
||||||
|
sx={{ |
||||||
|
height: height || '30px', |
||||||
|
width: width || 'auto', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
></AudioFileIcon> |
||||||
|
</Tooltip> |
||||||
|
<Drawer |
||||||
|
anchor="right" |
||||||
|
open={isOpen} |
||||||
|
onClose={handleToggle} |
||||||
|
ModalProps={{ |
||||||
|
keepMounted: true // Better performance on mobile
|
||||||
|
}} |
||||||
|
sx={{ |
||||||
|
'& .MuiPaper-root': { |
||||||
|
width: '400px' |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<Panel> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
alignItems: 'center', |
||||||
|
flex: '0 0' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
variant="h5" |
||||||
|
component="div" |
||||||
|
sx={{ flexGrow: 1, mt: 2, mb: 1 }} |
||||||
|
> |
||||||
|
Select Audio |
||||||
|
</Typography> |
||||||
|
<Typography |
||||||
|
variant="subtitle2" |
||||||
|
component="div" |
||||||
|
sx={{ flexGrow: 1, mb: 2 }} |
||||||
|
> |
||||||
|
List of audios in QDN under your name |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
|
||||||
|
<List |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flex: '1', |
||||||
|
overflow: 'auto' |
||||||
|
}} |
||||||
|
> |
||||||
|
{videos.map((video) => ( |
||||||
|
<ListItem key={video.identifier}> |
||||||
|
<ButtonBase |
||||||
|
onClick={() => handleClick(video)} |
||||||
|
sx={{ width: '100%' }} |
||||||
|
> |
||||||
|
<ListItemText |
||||||
|
primary={video?.metadata?.title || ''} |
||||||
|
secondary={video?.metadata?.description || ''} |
||||||
|
/> |
||||||
|
</ButtonBase> |
||||||
|
</ListItem> |
||||||
|
))} |
||||||
|
</List> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center', |
||||||
|
flex: '0 0 50px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<PublishButton |
||||||
|
variant="contained" |
||||||
|
onClick={() => setIsOpenVideoModal(true)} |
||||||
|
> |
||||||
|
Publish new audio file |
||||||
|
</PublishButton> |
||||||
|
</Box> |
||||||
|
</Panel> |
||||||
|
</Drawer> |
||||||
|
<AudioModal |
||||||
|
onClose={() => { |
||||||
|
setIsOpenVideoModal(false) |
||||||
|
}} |
||||||
|
open={isOpenVideoModal} |
||||||
|
onPublish={(value) => { |
||||||
|
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||||
|
setIsOpenVideoModal(false) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
// Add this to your 'types.ts' file
|
||||||
|
export interface Video { |
||||||
|
name: string |
||||||
|
service: string |
||||||
|
identifier: string |
||||||
|
metadata: { |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
tags: string[] |
||||||
|
category: string |
||||||
|
categoryName: string |
||||||
|
} |
||||||
|
size: number |
||||||
|
created: number |
||||||
|
updated: number |
||||||
|
} |
@ -0,0 +1,192 @@ |
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react' |
||||||
|
import { Box, IconButton, Slider } from '@mui/material' |
||||||
|
import { CircularProgress, Typography } from '@mui/material' |
||||||
|
import AudioPlyr from 'philliplm-react-modern-audio-player' |
||||||
|
import LinearProgress from '@mui/material/LinearProgress' |
||||||
|
|
||||||
|
import { |
||||||
|
PlayArrow, |
||||||
|
Pause, |
||||||
|
VolumeUp, |
||||||
|
Fullscreen, |
||||||
|
PictureInPicture |
||||||
|
} from '@mui/icons-material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { |
||||||
|
removeAudio, |
||||||
|
setShowingAudioPlayer |
||||||
|
} from '../../state/features/globalSlice' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
|
||||||
|
const VideoContainer = styled(Box)` |
||||||
|
position: relative; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
margin: 20px 0px; |
||||||
|
z-index: 501; |
||||||
|
` |
||||||
|
|
||||||
|
const VideoElement = styled('video')` |
||||||
|
width: 100%; |
||||||
|
height: auto; |
||||||
|
background: rgb(33, 33, 33); |
||||||
|
` |
||||||
|
|
||||||
|
const ControlsContainer = styled(Box)` |
||||||
|
position: absolute; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
bottom: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
padding: 8px; |
||||||
|
background-color: rgba(0, 0, 0, 0.6); |
||||||
|
` |
||||||
|
|
||||||
|
interface VideoPlayerProps { |
||||||
|
src?: string |
||||||
|
poster?: string |
||||||
|
name?: string |
||||||
|
identifier?: string |
||||||
|
service?: string |
||||||
|
autoplay?: boolean |
||||||
|
title?: string |
||||||
|
description?: string |
||||||
|
playlist?: IPlaylist[] |
||||||
|
currAudio: number | null |
||||||
|
} |
||||||
|
|
||||||
|
export interface IPlaylist { |
||||||
|
name: string |
||||||
|
identifier: string |
||||||
|
service: string |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
} |
||||||
|
interface CustomWindow extends Window { |
||||||
|
_qdnTheme: any // Replace 'any' with the appropriate type if you know it
|
||||||
|
} |
||||||
|
const customWindow = window as unknown as CustomWindow |
||||||
|
const themeColor = customWindow?._qdnTheme |
||||||
|
|
||||||
|
export const AudioPlayer: React.FC<VideoPlayerProps> = ({ currAudio }) => { |
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false) |
||||||
|
const { downloads, showingAudioPlayer } = useSelector( |
||||||
|
(state: RootState) => state.global |
||||||
|
) |
||||||
|
const dispatch = useDispatch() |
||||||
|
const downloadsLength: number = useMemo( |
||||||
|
() => |
||||||
|
Object.keys(downloads) |
||||||
|
.map((item) => { |
||||||
|
return downloads[item] |
||||||
|
}) |
||||||
|
.filter( |
||||||
|
(download: any) => |
||||||
|
download?.service === 'AUDIO' && |
||||||
|
download?.status?.status === 'READY' && |
||||||
|
!!download.url |
||||||
|
).length, |
||||||
|
[downloads] |
||||||
|
) |
||||||
|
|
||||||
|
const audioPlayList = useMemo(() => { |
||||||
|
const filterAudios = Object.keys(downloads) |
||||||
|
.map((item) => { |
||||||
|
return downloads[item] |
||||||
|
}) |
||||||
|
.filter( |
||||||
|
(download: any) => |
||||||
|
download?.service === 'AUDIO' && |
||||||
|
download?.url && |
||||||
|
download?.status?.status === 'READY' |
||||||
|
) |
||||||
|
return filterAudios.map((audio: any, index: number) => { |
||||||
|
return { |
||||||
|
name: audio?.blogPost?.audioTitle, |
||||||
|
src: audio?.url, |
||||||
|
id: index + 1, |
||||||
|
identifier: audio?.identifier, |
||||||
|
description: audio?.blogPost?.audioDescription || '' |
||||||
|
} |
||||||
|
}) |
||||||
|
}, [downloadsLength]) |
||||||
|
|
||||||
|
const currAudioMemo: number | null = useMemo(() => { |
||||||
|
const findIndex = audioPlayList.findIndex( |
||||||
|
(item) => item?.identifier === currAudio |
||||||
|
) |
||||||
|
if (findIndex !== -1) { |
||||||
|
return findIndex |
||||||
|
} |
||||||
|
return null |
||||||
|
}, [audioPlayList, currAudio]) |
||||||
|
|
||||||
|
if (isLoading) |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
isolation: 'isolate', |
||||||
|
width: '100%', |
||||||
|
position: 'fixed', |
||||||
|
colorScheme: 'light', |
||||||
|
bottom: '0px', |
||||||
|
padding: '10px', |
||||||
|
height: '50px', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
justifyContent: 'center', |
||||||
|
alignItems: 'flex-start' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontSize: '10px' |
||||||
|
}} |
||||||
|
> |
||||||
|
Loading playlist... |
||||||
|
</Typography> |
||||||
|
<LinearProgress |
||||||
|
sx={{ |
||||||
|
width: '100%' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
|
||||||
|
if (audioPlayList.length === 0 || !showingAudioPlayer) return null |
||||||
|
return ( |
||||||
|
<VideoContainer> |
||||||
|
<AudioPlyr |
||||||
|
rootContainerProps={{ |
||||||
|
defaultColorScheme: themeColor === 'dark' ? 'dark' : 'light', |
||||||
|
colorScheme: themeColor === 'dark' ? 'dark' : 'light' |
||||||
|
}} |
||||||
|
currentIndex={currAudioMemo} |
||||||
|
playList={audioPlayList} |
||||||
|
activeUI={{ |
||||||
|
all: true |
||||||
|
}} |
||||||
|
placement={{ |
||||||
|
player: 'bottom', |
||||||
|
|
||||||
|
playList: 'top', |
||||||
|
volumeSlider: 'top' |
||||||
|
}} |
||||||
|
closeCallback={() => { |
||||||
|
dispatch(setShowingAudioPlayer(false)) |
||||||
|
}} |
||||||
|
// rootContainerProps={{
|
||||||
|
// colorScheme: theme,
|
||||||
|
// width
|
||||||
|
// }}
|
||||||
|
/> |
||||||
|
</VideoContainer> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,358 @@ |
|||||||
|
import React, { useState } from 'react' |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
Modal, |
||||||
|
TextField, |
||||||
|
Typography, |
||||||
|
Select, |
||||||
|
MenuItem, |
||||||
|
FormControl, |
||||||
|
InputLabel, |
||||||
|
SelectChangeEvent, |
||||||
|
OutlinedInput, |
||||||
|
Chip, |
||||||
|
IconButton |
||||||
|
} from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { useDropzone } from 'react-dropzone' |
||||||
|
import { toBase64 } from '../../utils/toBase64' |
||||||
|
import AddIcon from '@mui/icons-material/Add' |
||||||
|
import CloseIcon from '@mui/icons-material/Close' |
||||||
|
import { usePublishAudio } from './PublishAudio' |
||||||
|
|
||||||
|
const StyledModal = styled(Modal)(({ theme }) => ({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center' |
||||||
|
})) |
||||||
|
|
||||||
|
const ChipContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
flexWrap: 'wrap', |
||||||
|
'& > *': { |
||||||
|
margin: '4px' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const ModalContent = styled(Box)(({ theme }) => ({ |
||||||
|
backgroundColor: theme.palette.background.paper, |
||||||
|
padding: theme.spacing(4), |
||||||
|
borderRadius: theme.spacing(1), |
||||||
|
width: '40%', |
||||||
|
'&:focus': { |
||||||
|
outline: 'none' |
||||||
|
} |
||||||
|
})) |
||||||
|
|
||||||
|
interface VideoModalProps { |
||||||
|
open: boolean |
||||||
|
onClose: () => void |
||||||
|
onPublish: (value: any) => void |
||||||
|
} |
||||||
|
|
||||||
|
interface SelectOption { |
||||||
|
id: string |
||||||
|
name: string |
||||||
|
} |
||||||
|
|
||||||
|
async function addAudioCoverImage( |
||||||
|
base64Audio: string, |
||||||
|
coverImageBase64: string |
||||||
|
): Promise<string> { |
||||||
|
// Decode the base64 audio data
|
||||||
|
const audioData: Uint8Array = new Uint8Array( |
||||||
|
atob(base64Audio) |
||||||
|
.split('') |
||||||
|
.map((char) => char.charCodeAt(0)) |
||||||
|
) |
||||||
|
|
||||||
|
const decoder: TextDecoder = new TextDecoder('utf-8') |
||||||
|
const decodedAudioData: string = decoder.decode(audioData) |
||||||
|
|
||||||
|
// Create a Blob object from the decoded audio data
|
||||||
|
const blob: Blob = new Blob([decodedAudioData], { type: 'audio/mpeg' }) |
||||||
|
|
||||||
|
// Create a new file name for the audio with cover image
|
||||||
|
const fileName: string = 'audio-with-cover.mp3' |
||||||
|
|
||||||
|
// Create a new FormData object to hold the file and metadata
|
||||||
|
const formData: FormData = new FormData() |
||||||
|
formData.append('file', blob, fileName) |
||||||
|
|
||||||
|
// Create a new image object from the base64 data
|
||||||
|
const image: HTMLImageElement = new Image() |
||||||
|
image.src = `data:image/png;base64,${coverImageBase64}` |
||||||
|
|
||||||
|
// Wait for the image to load before getting its dimensions
|
||||||
|
await new Promise((resolve) => { |
||||||
|
image.onload = () => resolve(null) |
||||||
|
}) |
||||||
|
|
||||||
|
// Get the image dimensions
|
||||||
|
const width: number = image.width |
||||||
|
const height: number = image.height |
||||||
|
|
||||||
|
// Create a new metadata object with the image dimensions
|
||||||
|
const metadata: any = { |
||||||
|
title: 'Audio with Cover', |
||||||
|
artist: 'Artist Name', |
||||||
|
album: 'Album Name', |
||||||
|
trackNumber: 1, |
||||||
|
image: { |
||||||
|
mime: 'image/png', |
||||||
|
type: 3, |
||||||
|
description: 'Cover Image', |
||||||
|
data: coverImageBase64, |
||||||
|
width: width, |
||||||
|
height: height |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Set the metadata on the file
|
||||||
|
formData.set('metadata', JSON.stringify(metadata)) |
||||||
|
|
||||||
|
// Create a new URL object for the file
|
||||||
|
const url: string = URL.createObjectURL(blob) |
||||||
|
|
||||||
|
// Create a download link for the file
|
||||||
|
const link: HTMLAnchorElement = document.createElement('a') |
||||||
|
link.href = url |
||||||
|
link.download = fileName |
||||||
|
link.click() |
||||||
|
|
||||||
|
// Read the downloaded file and return its contents as a base64 string
|
||||||
|
const fileReader: FileReader = new FileReader() |
||||||
|
fileReader.readAsDataURL(blob) |
||||||
|
return await new Promise<string>((resolve, reject) => { |
||||||
|
fileReader.onload = () => { |
||||||
|
const base64: string | undefined = fileReader.result?.toString() |
||||||
|
if (base64 !== undefined) { |
||||||
|
resolve(base64) |
||||||
|
} else { |
||||||
|
reject(new Error('Failed to read downloaded file.')) |
||||||
|
} |
||||||
|
} |
||||||
|
fileReader.onerror = () => reject(fileReader.error) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export const AudioModal: React.FC<VideoModalProps> = ({ |
||||||
|
open, |
||||||
|
onClose, |
||||||
|
onPublish |
||||||
|
}) => { |
||||||
|
const [file, setFile] = useState<File | null>(null) |
||||||
|
const [title, setTitle] = useState('') |
||||||
|
const [description, setDescription] = useState('') |
||||||
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||||
|
null |
||||||
|
) |
||||||
|
const [inputValue, setInputValue] = useState<string>('') |
||||||
|
const [chips, setChips] = useState<string[]>([]) |
||||||
|
|
||||||
|
const [options, setOptions] = useState<SelectOption[]>([]) |
||||||
|
const [tags, setTags] = useState<string[]>([]) |
||||||
|
const { publishAudio } = usePublishAudio() |
||||||
|
const { getRootProps, getInputProps } = useDropzone({ |
||||||
|
accept: { |
||||||
|
'audio/*': [] |
||||||
|
}, |
||||||
|
maxFiles: 1, |
||||||
|
onDrop: (acceptedFiles) => { |
||||||
|
setFile(acceptedFiles[0]) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
||||||
|
setTitle(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleDescriptionChange = ( |
||||||
|
event: React.ChangeEvent<HTMLInputElement> |
||||||
|
) => { |
||||||
|
setDescription(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||||
|
const optionId = event.target.value |
||||||
|
const selectedOption = options.find((option) => option.id === optionId) |
||||||
|
setSelectedOption(selectedOption || null) |
||||||
|
} |
||||||
|
|
||||||
|
const handleChipDelete = (index: number) => { |
||||||
|
const newChips = [...chips] |
||||||
|
newChips.splice(index, 1) |
||||||
|
setChips(newChips) |
||||||
|
} |
||||||
|
|
||||||
|
const handleSubmit = async () => { |
||||||
|
const missingFields = [] |
||||||
|
|
||||||
|
if (!title) missingFields.push('title') |
||||||
|
if (!file) missingFields.push('file') |
||||||
|
if (missingFields.length > 0) { |
||||||
|
const missingFieldsString = missingFields.join(', ') |
||||||
|
const errMsg = `Missing: ${missingFieldsString}` |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
if (!file) return |
||||||
|
|
||||||
|
const formattedTags: { [key: string]: string } = {} |
||||||
|
chips.forEach((tag, i) => { |
||||||
|
formattedTags[`tag${i + 1}`] = tag |
||||||
|
}) |
||||||
|
|
||||||
|
try { |
||||||
|
const base64 = await toBase64(file) |
||||||
|
if (typeof base64 !== 'string') return |
||||||
|
const base64String = base64.split(',')[1] |
||||||
|
|
||||||
|
const res = await publishAudio({ |
||||||
|
title, |
||||||
|
description, |
||||||
|
base64: base64String, |
||||||
|
category: selectedOption?.id || '', |
||||||
|
...formattedTags |
||||||
|
}) |
||||||
|
onPublish(res) |
||||||
|
setFile(null) |
||||||
|
setTitle('') |
||||||
|
setDescription('') |
||||||
|
onClose() |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputChange = (event: any) => { |
||||||
|
setInputValue(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputKeyDown = (event: any) => { |
||||||
|
if (event.key === 'Enter' && inputValue !== '') { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} else { |
||||||
|
event.preventDefault() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const addChip = () => { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getListCategories = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const url = `/arbitrary/categories` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
setOptions(responseData) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getListCategories() |
||||||
|
}, [getListCategories]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<StyledModal open={open} onClose={onClose}> |
||||||
|
<ModalContent> |
||||||
|
<Typography variant="h6" component="h2" gutterBottom> |
||||||
|
Upload Audio |
||||||
|
</Typography> |
||||||
|
<Box |
||||||
|
{...getRootProps()} |
||||||
|
sx={{ |
||||||
|
border: '1px dashed gray', |
||||||
|
padding: 2, |
||||||
|
textAlign: 'center', |
||||||
|
marginBottom: 2 |
||||||
|
}} |
||||||
|
> |
||||||
|
<input {...getInputProps()} /> |
||||||
|
<Typography> |
||||||
|
{file |
||||||
|
? file.name |
||||||
|
: 'Drag and drop an audio file here or click to select a file'} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
<TextField |
||||||
|
label="Audio Title" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
value={title} |
||||||
|
onChange={handleTitleChange} |
||||||
|
inputProps={{ maxLength: 40 }} |
||||||
|
sx={{ marginBottom: 2 }} |
||||||
|
/> |
||||||
|
<TextField |
||||||
|
label="Audio Description" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
multiline |
||||||
|
rows={4} |
||||||
|
value={description} |
||||||
|
onChange={handleDescriptionChange} |
||||||
|
inputProps={{ maxLength: 180 }} |
||||||
|
sx={{ marginBottom: 2 }} |
||||||
|
/> |
||||||
|
{options.length > 0 && ( |
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<InputLabel id="Category">Select a Category</InputLabel> |
||||||
|
<Select |
||||||
|
labelId="Category" |
||||||
|
input={<OutlinedInput label="Select a Category" />} |
||||||
|
value={selectedOption?.id || ''} |
||||||
|
onChange={handleOptionChange} |
||||||
|
> |
||||||
|
{options.map((option) => ( |
||||||
|
<MenuItem key={option.id} value={option.id}> |
||||||
|
{option.name} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
)} |
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||||
|
<TextField |
||||||
|
label="Add a tag" |
||||||
|
value={inputValue} |
||||||
|
onChange={handleInputChange} |
||||||
|
onKeyDown={handleInputKeyDown} |
||||||
|
disabled={chips.length === 3} |
||||||
|
/> |
||||||
|
|
||||||
|
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||||
|
<AddIcon /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
<ChipContainer> |
||||||
|
{chips.map((chip, index) => ( |
||||||
|
<Chip |
||||||
|
key={index} |
||||||
|
label={chip} |
||||||
|
onDelete={() => handleChipDelete(index)} |
||||||
|
deleteIcon={<CloseIcon />} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ChipContainer> |
||||||
|
</FormControl> |
||||||
|
<Button variant="contained" color="primary" onClick={handleSubmit}> |
||||||
|
Submit |
||||||
|
</Button> |
||||||
|
</ModalContent> |
||||||
|
</StyledModal> |
||||||
|
) |
||||||
|
} |
@ -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, |
||||||
|
})); |
@ -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: 'Arial' |
||||||
|
}} |
||||||
|
onClick={() => removeFromBlockList(name)} |
||||||
|
> |
||||||
|
Remove |
||||||
|
</Button> |
||||||
|
</ListItem> |
||||||
|
))} |
||||||
|
</List> |
||||||
|
<Button variant="contained" color="primary" onClick={onClose}> |
||||||
|
Close |
||||||
|
</Button> |
||||||
|
</ModalContent> |
||||||
|
</StyledModal> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,118 @@ |
|||||||
|
import React, { useState } from 'react'; |
||||||
|
import Chip from '@mui/material/Chip'; |
||||||
|
import TextField from '@mui/material/TextField'; |
||||||
|
import { useDispatch } from 'react-redux'; |
||||||
|
import { setNotification } from '../../../state/features/notificationsSlice'; |
||||||
|
import { Input } from '@mui/material'; |
||||||
|
|
||||||
|
export interface NameChip { |
||||||
|
name: string; |
||||||
|
publicKey: string; |
||||||
|
address: string; |
||||||
|
} |
||||||
|
interface ChipInputComponent { |
||||||
|
chips: NameChip[]; |
||||||
|
setChips: (val: NameChip[])=> void; |
||||||
|
} |
||||||
|
|
||||||
|
export const ChipInputComponent = ({chips, setChips}: ChipInputComponent) => { |
||||||
|
const [inputValue, setInputValue] = useState<string>(''); |
||||||
|
const dispatch = useDispatch() |
||||||
|
// Add chip on enter or onBlur
|
||||||
|
const handleAddChip = async () => { |
||||||
|
try { |
||||||
|
if(!inputValue) return |
||||||
|
const recipientName = inputValue |
||||||
|
const resName = await qortalRequest({ |
||||||
|
action: 'GET_NAME_DATA', |
||||||
|
name: recipientName |
||||||
|
}) |
||||||
|
if (!resName?.owner) throw new Error("Name cannot be found") |
||||||
|
|
||||||
|
const recipientAddress = resName.owner |
||||||
|
const resAddress = await qortalRequest({ |
||||||
|
action: 'GET_ACCOUNT_DATA', |
||||||
|
address: recipientAddress |
||||||
|
}) |
||||||
|
if (!resAddress?.publicKey) throw new Error("Cannot retrieve public key of name") |
||||||
|
const recipientPublicKey = resAddress.publicKey |
||||||
|
if (inputValue && !chips.find((item)=> item?.name === inputValue)) { |
||||||
|
setChips([...chips, { |
||||||
|
name: inputValue, |
||||||
|
publicKey: recipientPublicKey, |
||||||
|
address: recipientAddress |
||||||
|
}]); |
||||||
|
setInputValue(''); |
||||||
|
} |
||||||
|
} catch (error:any) { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: error?.message, |
||||||
|
alertType: 'error' |
||||||
|
}) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
}; |
||||||
|
|
||||||
|
// Remove chip
|
||||||
|
const handleDeleteChip = (chipToDelete: string) => () => { |
||||||
|
setChips(chips.filter(chip => chip.name !== chipToDelete)); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
{chips.map((chip, index) => ( |
||||||
|
<Chip |
||||||
|
key={index} |
||||||
|
label={chip.name} |
||||||
|
onDelete={handleDeleteChip(chip.name)} |
||||||
|
sx={{ |
||||||
|
color: 'rgba(84, 84, 84, 1)', |
||||||
|
'& .MuiChip-deleteIcon': { |
||||||
|
color: 'black' , // Style the delete icon,
|
||||||
|
"&:hover": { |
||||||
|
color: 'rgba(84, 84, 84, 1)' |
||||||
|
} |
||||||
|
} |
||||||
|
}} |
||||||
|
/> |
||||||
|
))} |
||||||
|
{/* <TextField |
||||||
|
value={inputValue} |
||||||
|
onChange={(e) => setInputValue(e.target.value)} |
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAddChip()} |
||||||
|
placeholder="Type and press enter..." |
||||||
|
/> */} |
||||||
|
<Input |
||||||
|
id="standard-adornment-name" |
||||||
|
value={inputValue} |
||||||
|
onChange={(e) => { |
||||||
|
setInputValue(e.target.value) |
||||||
|
}} |
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAddChip()} |
||||||
|
disableUnderline |
||||||
|
autoComplete='off' |
||||||
|
autoCorrect='off' |
||||||
|
placeholder="Type and press enter..." |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
color: 'var(--new-message-text)', |
||||||
|
'& .MuiInput-input::placeholder': { |
||||||
|
color: 'rgba(84, 84, 84, 0.70) !important', |
||||||
|
fontSize: '20px', |
||||||
|
fontStyle: 'normal', |
||||||
|
fontWeight: 400, |
||||||
|
lineHeight: '120%', // 24px
|
||||||
|
letterSpacing: '0.15px', |
||||||
|
opacity: 1 |
||||||
|
}, |
||||||
|
'&:focus': { |
||||||
|
outline: 'none', |
||||||
|
}, |
||||||
|
// Add any additional styles for the input here
|
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,279 @@ |
|||||||
|
import { |
||||||
|
Avatar, |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
Dialog, |
||||||
|
DialogActions, |
||||||
|
DialogContent, |
||||||
|
DialogTitle, |
||||||
|
Typography, |
||||||
|
useTheme |
||||||
|
} from '@mui/material' |
||||||
|
import React, { useCallback, useState } from 'react' |
||||||
|
import { CommentEditor } from './CommentEditor' |
||||||
|
import { CardContentContainerComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { StyledCardHeaderComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { StyledCardColComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { AuthorTextComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { StyledCardContentComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../../state/store' |
||||||
|
import Portal from '../Portal' |
||||||
|
import { Tipping } from '../Tipping/Tipping' |
||||||
|
interface CommentProps { |
||||||
|
comment: any |
||||||
|
postId: string |
||||||
|
onSubmit: (obj?: any, isEdit?: boolean) => void |
||||||
|
} |
||||||
|
export const Comment = ({ comment, postId, onSubmit }: CommentProps) => { |
||||||
|
const [isReplying, setIsReplying] = useState<boolean>(false) |
||||||
|
const [isEditing, setIsEditing] = useState<boolean>(false) |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const [currentEdit, setCurrentEdit] = useState<any>(null) |
||||||
|
const handleSubmit = useCallback((comment: any, isEdit?: boolean) => { |
||||||
|
onSubmit(comment, isEdit) |
||||||
|
setCurrentEdit(null) |
||||||
|
setIsReplying(false) |
||||||
|
}, []) |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
width: '100%', |
||||||
|
flexDirection: 'column' |
||||||
|
}} |
||||||
|
> |
||||||
|
{currentEdit && ( |
||||||
|
<Portal> |
||||||
|
<Dialog |
||||||
|
open={!!currentEdit} |
||||||
|
onClose={() => setCurrentEdit(null)} |
||||||
|
aria-labelledby="alert-dialog-title" |
||||||
|
aria-describedby="alert-dialog-description" |
||||||
|
> |
||||||
|
<DialogTitle id="alert-dialog-title"></DialogTitle> |
||||||
|
<DialogContent> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '300px', |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CommentEditor |
||||||
|
onSubmit={(obj) => handleSubmit(obj, true)} |
||||||
|
postId={postId} |
||||||
|
isEdit |
||||||
|
commentId={currentEdit?.identifier} |
||||||
|
commentMessage={currentEdit?.message} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<Button variant="contained" onClick={() => setCurrentEdit(null)}> |
||||||
|
Close |
||||||
|
</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
</Portal> |
||||||
|
)} |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
width: '100%', |
||||||
|
flexDirection: 'column' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CommentCard |
||||||
|
name={comment?.name} |
||||||
|
message={comment?.message} |
||||||
|
replies={comment?.replies || []} |
||||||
|
setCurrentEdit={setCurrentEdit} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: '5px', |
||||||
|
marginTop: '20px', |
||||||
|
justifyContent: 'flex-end' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="contained" |
||||||
|
onClick={() => setIsReplying(true)} |
||||||
|
> |
||||||
|
reply |
||||||
|
</Button> |
||||||
|
{user?.name === comment?.name && ( |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="contained" |
||||||
|
onClick={() => setCurrentEdit(comment)} |
||||||
|
> |
||||||
|
edit |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
{isReplying && ( |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="contained" |
||||||
|
onClick={() => { |
||||||
|
setIsReplying(false) |
||||||
|
setIsEditing(false) |
||||||
|
}} |
||||||
|
> |
||||||
|
close |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</CommentCard> |
||||||
|
{/* <Typography variant="body1"> {comment?.message}</Typography> */} |
||||||
|
</Box> |
||||||
|
|
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
width: '100%', |
||||||
|
flexDirection: 'column', |
||||||
|
alignItems: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
{isReplying && ( |
||||||
|
<CommentEditor |
||||||
|
onSubmit={handleSubmit} |
||||||
|
postId={postId} |
||||||
|
isReply |
||||||
|
commentId={comment.identifier} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const CommentCard = ({ |
||||||
|
message, |
||||||
|
created, |
||||||
|
name, |
||||||
|
replies, |
||||||
|
children, |
||||||
|
setCurrentEdit |
||||||
|
}: any) => { |
||||||
|
const [avatarUrl, setAvatarUrl] = React.useState<string>('') |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
|
||||||
|
const theme = useTheme() |
||||||
|
|
||||||
|
const getAvatar = React.useCallback(async (author: string) => { |
||||||
|
try { |
||||||
|
let url = await qortalRequest({ |
||||||
|
action: 'GET_QDN_RESOURCE_URL', |
||||||
|
name: author, |
||||||
|
service: 'THUMBNAIL', |
||||||
|
identifier: 'qortal_avatar' |
||||||
|
}) |
||||||
|
|
||||||
|
setAvatarUrl(url) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getAvatar(name) |
||||||
|
}, [name]) |
||||||
|
return ( |
||||||
|
<CardContentContainerComment> |
||||||
|
<StyledCardHeaderComment |
||||||
|
sx={{ |
||||||
|
'& .MuiCardHeader-content': { |
||||||
|
overflow: 'hidden' |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box> |
||||||
|
<Avatar src={avatarUrl} alt={`${name}'s avatar`} /> |
||||||
|
</Box> |
||||||
|
<StyledCardColComment> |
||||||
|
<AuthorTextComment |
||||||
|
color={ |
||||||
|
theme.palette.mode === 'light' |
||||||
|
? theme.palette.text.secondary |
||||||
|
: '#d6e8ff' |
||||||
|
} |
||||||
|
> |
||||||
|
{name} |
||||||
|
</AuthorTextComment> |
||||||
|
</StyledCardColComment> |
||||||
|
{name && ( |
||||||
|
<Tipping |
||||||
|
name={name} |
||||||
|
onSubmit={() => { |
||||||
|
// setNameTip('')
|
||||||
|
}} |
||||||
|
onClose={() => { |
||||||
|
// setNameTip('')
|
||||||
|
}} |
||||||
|
onlyIcon={true} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</StyledCardHeaderComment> |
||||||
|
<StyledCardContentComment> |
||||||
|
<Typography |
||||||
|
variant="body2" |
||||||
|
color={theme.palette.text.primary} |
||||||
|
sx={{ |
||||||
|
fontSize: '16px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{message} |
||||||
|
</Typography> |
||||||
|
</StyledCardContentComment> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
paddingLeft: '15px', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column' |
||||||
|
}} |
||||||
|
> |
||||||
|
{replies?.map((reply: any) => { |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
key={reply?.identifier} |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
border: '1px solid grey', |
||||||
|
borderRadius: '10px', |
||||||
|
marginTop: '8px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CommentCard |
||||||
|
name={reply?.name} |
||||||
|
message={reply?.message} |
||||||
|
setCurrentEdit={setCurrentEdit} |
||||||
|
> |
||||||
|
{user?.name === reply?.name && ( |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="contained" |
||||||
|
onClick={() => setCurrentEdit(reply)} |
||||||
|
sx={{ |
||||||
|
width: '30px', |
||||||
|
alignSelf: 'flex-end', |
||||||
|
background: theme.palette.primary.light |
||||||
|
}} |
||||||
|
> |
||||||
|
edit |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</CommentCard> |
||||||
|
{/* <Typography variant="body2"> {reply?.message}</Typography> */} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
})} |
||||||
|
</Box> |
||||||
|
{children} |
||||||
|
</CardContentContainerComment> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,172 @@ |
|||||||
|
import { Box, Button, TextField } from '@mui/material' |
||||||
|
import React, { useEffect, useState } from 'react' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../../state/store' |
||||||
|
import ShortUniqueId from 'short-unique-id' |
||||||
|
import { setNotification } from '../../../state/features/notificationsSlice' |
||||||
|
import { toBase64 } from '../../../utils/toBase64' |
||||||
|
const uid = new ShortUniqueId() |
||||||
|
interface CommentEditorProps { |
||||||
|
postId: string |
||||||
|
onSubmit: (obj: any) => void |
||||||
|
isReply?: boolean |
||||||
|
commentId?: string |
||||||
|
isEdit?: boolean |
||||||
|
commentMessage?: string |
||||||
|
} |
||||||
|
|
||||||
|
function utf8ToBase64(inputString: string): string { |
||||||
|
// Encode the string as UTF-8
|
||||||
|
const utf8String = encodeURIComponent(inputString).replace( |
||||||
|
/%([0-9A-F]{2})/g, |
||||||
|
(match, p1) => String.fromCharCode(Number('0x' + p1)) |
||||||
|
) |
||||||
|
|
||||||
|
// Convert the UTF-8 encoded string to base64
|
||||||
|
const base64String = btoa(utf8String) |
||||||
|
return base64String |
||||||
|
} |
||||||
|
|
||||||
|
export const CommentEditor = ({ |
||||||
|
onSubmit, |
||||||
|
postId, |
||||||
|
isReply, |
||||||
|
commentId, |
||||||
|
isEdit, |
||||||
|
commentMessage |
||||||
|
}: CommentEditorProps) => { |
||||||
|
const [value, setValue] = useState<string>('') |
||||||
|
const dispatch = useDispatch() |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (isEdit && commentMessage) { |
||||||
|
setValue(commentMessage) |
||||||
|
} |
||||||
|
}, [isEdit, commentMessage]) |
||||||
|
const publishComment = async (identifier: string) => { |
||||||
|
let address |
||||||
|
let name |
||||||
|
let errorMsg = '' |
||||||
|
|
||||||
|
address = user?.address |
||||||
|
name = user?.name || '' |
||||||
|
|
||||||
|
if (!address) { |
||||||
|
errorMsg = "Cannot post: your address isn't available" |
||||||
|
} |
||||||
|
if (!name) { |
||||||
|
errorMsg = 'Cannot post without a name' |
||||||
|
} |
||||||
|
|
||||||
|
if (value.length > 200) { |
||||||
|
errorMsg = 'Comment needs to be under 200 characters' |
||||||
|
} |
||||||
|
|
||||||
|
if (errorMsg) { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: errorMsg, |
||||||
|
alertType: 'error' |
||||||
|
}) |
||||||
|
) |
||||||
|
throw new Error(errorMsg) |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const base64 = utf8ToBase64(value) |
||||||
|
const resourceResponse = await qortalRequest({ |
||||||
|
action: 'PUBLISH_QDN_RESOURCE', |
||||||
|
name: name, |
||||||
|
service: 'BLOG_COMMENT', |
||||||
|
data64: base64, |
||||||
|
identifier: identifier |
||||||
|
}) |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: 'Comment successfully published', |
||||||
|
alertType: 'success' |
||||||
|
}) |
||||||
|
) |
||||||
|
return resourceResponse |
||||||
|
} catch (error: any) { |
||||||
|
let notificationObj = null |
||||||
|
if (typeof error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error || 'Failed to publish comment', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else if (typeof error?.error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.error || 'Failed to publish comment', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.message || 'Failed to publish comment', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} |
||||||
|
if (!notificationObj) throw new Error('Failed to publish comment') |
||||||
|
|
||||||
|
dispatch(setNotification(notificationObj)) |
||||||
|
throw new Error('Failed to publish comment') |
||||||
|
} |
||||||
|
} |
||||||
|
const handleSubmit = async () => { |
||||||
|
try { |
||||||
|
const id = uid() |
||||||
|
|
||||||
|
let identifier = `qcomment_v1_qblog_${postId.slice(-12)}_${id}` |
||||||
|
if (isReply && commentId) { |
||||||
|
identifier = `qcomment_v1_qblog_${postId.slice( |
||||||
|
-12 |
||||||
|
)}_reply_${commentId.slice(-6)}_${id}` |
||||||
|
} |
||||||
|
if (isEdit && commentId) { |
||||||
|
identifier = commentId |
||||||
|
} |
||||||
|
await publishComment(identifier) |
||||||
|
onSubmit({ |
||||||
|
created: Date.now(), |
||||||
|
identifier, |
||||||
|
message: value, |
||||||
|
service: 'BLOG_COMMENT', |
||||||
|
name: user?.name |
||||||
|
}) |
||||||
|
setValue('') |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
marginTop: '15px', |
||||||
|
width: '90%' |
||||||
|
}} |
||||||
|
> |
||||||
|
<TextField |
||||||
|
id="standard-multiline-flexible" |
||||||
|
label="Your comment" |
||||||
|
multiline |
||||||
|
maxRows={4} |
||||||
|
variant="filled" |
||||||
|
value={value} |
||||||
|
inputProps={{ |
||||||
|
maxLength: 200, |
||||||
|
style: { |
||||||
|
fontSize: '16px' |
||||||
|
} |
||||||
|
}} |
||||||
|
InputLabelProps={{ style: { fontSize: '18px' } }} |
||||||
|
onChange={(e) => setValue(e.target.value)} |
||||||
|
/> |
||||||
|
|
||||||
|
<Button variant="contained" onClick={handleSubmit}> |
||||||
|
{isReply ? 'Submit reply' : isEdit ? 'Edit' : 'Submit comment'} |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,307 @@ |
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
||||||
|
import { CommentEditor } from './CommentEditor' |
||||||
|
import { Comment } from './Comment' |
||||||
|
import { Box, Button, Drawer, Typography, useTheme } from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import CloseIcon from '@mui/icons-material/Close' |
||||||
|
import { useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../../state/store' |
||||||
|
import CommentIcon from '@mui/icons-material/Comment' |
||||||
|
interface CommentSectionProps { |
||||||
|
postId: string |
||||||
|
} |
||||||
|
|
||||||
|
const Panel = styled('div')` |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
width: 100%; |
||||||
|
padding-bottom: 10px; |
||||||
|
height: 100%; |
||||||
|
overflow: hidden; |
||||||
|
|
||||||
|
&::-webkit-scrollbar { |
||||||
|
width: 8px; |
||||||
|
height: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb { |
||||||
|
background-color: #888; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover { |
||||||
|
background-color: #555; |
||||||
|
} |
||||||
|
` |
||||||
|
export const CommentSection = ({ postId }: CommentSectionProps) => { |
||||||
|
const [listComments, setListComments] = useState<any[]>([]) |
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false) |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const [newMessages, setNewMessages] = useState(0) |
||||||
|
const theme = useTheme() |
||||||
|
const onSubmit = (obj?: any, isEdit?: boolean) => { |
||||||
|
if (isEdit) { |
||||||
|
setListComments((prev: any[]) => { |
||||||
|
const findCommentIndex = prev.findIndex( |
||||||
|
(item) => item?.identifier === obj?.identifier |
||||||
|
) |
||||||
|
if (findCommentIndex === -1) return prev |
||||||
|
|
||||||
|
const newArray = [...prev] |
||||||
|
newArray[findCommentIndex] = obj |
||||||
|
return newArray |
||||||
|
}) |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
setListComments((prev) => [ |
||||||
|
...prev, |
||||||
|
{ |
||||||
|
...obj |
||||||
|
} |
||||||
|
]) |
||||||
|
} |
||||||
|
const getComments = useCallback( |
||||||
|
async (isNewMessages?: boolean, numberOfComments?: number) => { |
||||||
|
let offset: number = 0 |
||||||
|
if (isNewMessages && numberOfComments) { |
||||||
|
offset = numberOfComments |
||||||
|
} |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=qcomment_v1_qblog_${postId.slice( |
||||||
|
-12 |
||||||
|
)}&limit=20&includemetadata=true&offset=${offset}&reverse=false&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
let comments: any[] = [] |
||||||
|
for (const comment of responseData) { |
||||||
|
if (comment.identifier && comment.name) { |
||||||
|
const url = `/arbitrary/BLOG_COMMENT/${comment.name}/${comment.identifier}` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const responseData2 = await response.text() |
||||||
|
if (responseData) { |
||||||
|
comments.push({ |
||||||
|
message: responseData2, |
||||||
|
...comment |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if (isNewMessages) { |
||||||
|
setListComments((prev) => [...prev, ...comments]) |
||||||
|
setNewMessages(0) |
||||||
|
} else { |
||||||
|
setListComments(comments) |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
} catch (error) {} |
||||||
|
}, |
||||||
|
[] |
||||||
|
) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
getComments() |
||||||
|
}, [getComments]) |
||||||
|
|
||||||
|
const structuredCommentList = useMemo(() => { |
||||||
|
return listComments.reduce((acc, curr, index, array) => { |
||||||
|
if (curr?.identifier?.includes('_reply_')) { |
||||||
|
return acc |
||||||
|
} |
||||||
|
acc.push({ |
||||||
|
...curr, |
||||||
|
replies: array.filter((comment) => |
||||||
|
comment.identifier.includes(`_reply_${curr.identifier.slice(-6)}`) |
||||||
|
) |
||||||
|
}) |
||||||
|
return acc |
||||||
|
}, []) |
||||||
|
}, [listComments]) |
||||||
|
|
||||||
|
const interval = useRef<any>(null) |
||||||
|
|
||||||
|
const checkNewComments = useCallback(async () => { |
||||||
|
try { |
||||||
|
const offset = listComments.length |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=qcomment_v1_qblog_${postId.slice( |
||||||
|
-12 |
||||||
|
)}&limit=20&includemetadata=true&offset=${offset}&reverse=false&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
setNewMessages(responseData.length) |
||||||
|
} catch (error) {} |
||||||
|
}, [listComments, postId]) |
||||||
|
|
||||||
|
const checkNewMessagesFunc = useCallback(() => { |
||||||
|
let isCalling = false |
||||||
|
interval.current = setInterval(async () => { |
||||||
|
if (isCalling) return |
||||||
|
isCalling = true |
||||||
|
const res = await checkNewComments() |
||||||
|
isCalling = false |
||||||
|
}, 15000) |
||||||
|
}, [checkNewComments]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
checkNewMessagesFunc() |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (interval?.current) { |
||||||
|
clearInterval(interval.current) |
||||||
|
} |
||||||
|
} |
||||||
|
}, [checkNewMessagesFunc]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'relative' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CommentIcon |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
onClick={() => setIsOpen((prev) => !prev)} |
||||||
|
> |
||||||
|
Comments |
||||||
|
</CommentIcon> |
||||||
|
{listComments?.length > 0 && ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
fontSize: '12px', |
||||||
|
background: theme.palette.mode === 'dark' ? 'white' : 'black', |
||||||
|
color: theme.palette.mode === 'dark' ? 'black' : 'white', |
||||||
|
borderRadius: '50%', |
||||||
|
position: 'absolute', |
||||||
|
top: '-15px', |
||||||
|
right: '-15px', |
||||||
|
width: '20px', |
||||||
|
height: '20px', |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
{listComments.length < 10 ? listComments.length : '9+'} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
<Drawer |
||||||
|
variant="persistent" |
||||||
|
hideBackdrop={true} |
||||||
|
anchor="right" |
||||||
|
open={isOpen} |
||||||
|
onClose={() => {}} |
||||||
|
ModalProps={{ |
||||||
|
keepMounted: true // Better performance on mobile
|
||||||
|
}} |
||||||
|
sx={{ |
||||||
|
'& .MuiPaper-root': { |
||||||
|
width: '400px' |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<Panel> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'row', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'space-between', |
||||||
|
flex: '0 0', |
||||||
|
padding: '10px', |
||||||
|
width: '100%' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex' |
||||||
|
}} |
||||||
|
> |
||||||
|
{newMessages > 0 && ( |
||||||
|
<Button |
||||||
|
onClick={() => getComments(true, listComments.length)} |
||||||
|
variant="contained" |
||||||
|
size="small" |
||||||
|
> |
||||||
|
Load {newMessages} new{' '} |
||||||
|
{newMessages > 1 ? 'messages' : 'message'} |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
<CloseIcon |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
onClick={() => setIsOpen(false)} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
|
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flex: '1', |
||||||
|
overflow: 'auto' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
margin: '25px 0px 50px 0px', |
||||||
|
maxWidth: '400px', |
||||||
|
width: '100%', |
||||||
|
gap: '10px', |
||||||
|
padding: '0px 5px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{structuredCommentList.map((comment: any) => { |
||||||
|
return ( |
||||||
|
<Comment |
||||||
|
key={comment?.identifier} |
||||||
|
comment={comment} |
||||||
|
onSubmit={onSubmit} |
||||||
|
postId={postId} |
||||||
|
/> |
||||||
|
) |
||||||
|
})} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center', |
||||||
|
flex: '0 0 100px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CommentEditor onSubmit={onSubmit} postId={postId} /> |
||||||
|
</Box> |
||||||
|
</Panel> |
||||||
|
</Drawer> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,56 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogActions, |
||||||
|
DialogContent, |
||||||
|
DialogContentText, |
||||||
|
DialogTitle, |
||||||
|
Button |
||||||
|
} from '@mui/material' |
||||||
|
|
||||||
|
export interface ModalProps { |
||||||
|
open: boolean |
||||||
|
title: string |
||||||
|
message: string |
||||||
|
handleConfirm: () => void |
||||||
|
handleCancel: () => void |
||||||
|
} |
||||||
|
|
||||||
|
const ConfirmationModal: React.FC<ModalProps> = ({ |
||||||
|
open, |
||||||
|
title, |
||||||
|
message, |
||||||
|
handleConfirm, |
||||||
|
handleCancel |
||||||
|
}) => { |
||||||
|
return ( |
||||||
|
<Dialog |
||||||
|
open={open} |
||||||
|
onClose={handleCancel} |
||||||
|
aria-labelledby="alert-dialog-title" |
||||||
|
aria-describedby="alert-dialog-description" |
||||||
|
> |
||||||
|
<DialogTitle id="alert-dialog-title">{title}</DialogTitle> |
||||||
|
<DialogContent> |
||||||
|
<DialogContentText id="alert-dialog-description"> |
||||||
|
{message} |
||||||
|
</DialogContentText> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<Button variant="contained" onClick={handleCancel} color="primary"> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
variant="contained" |
||||||
|
onClick={handleConfirm} |
||||||
|
color="primary" |
||||||
|
autoFocus |
||||||
|
> |
||||||
|
Proceed |
||||||
|
</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default ConfirmationModal |
@ -0,0 +1,16 @@ |
|||||||
|
import React from 'react' |
||||||
|
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
|
||||||
|
const CustomSvgIcon: React.FC<any> = styled(SvgIcon)(({ theme }) => ({ |
||||||
|
cursor: 'pointer', |
||||||
|
color: '#5f6368', |
||||||
|
transition: 'all 0.2s', |
||||||
|
'&:hover': { |
||||||
|
transform: 'scale(1.1)' |
||||||
|
} |
||||||
|
})) as unknown as React.FC<any> |
||||||
|
|
||||||
|
export const CustomIcon: React.FC<any> = (props) => { |
||||||
|
return <CustomSvgIcon {...props} /> |
||||||
|
} |
@ -0,0 +1,289 @@ |
|||||||
|
import React, { useState, useEffect } from 'react' |
||||||
|
import { |
||||||
|
Accordion, |
||||||
|
AccordionDetails, |
||||||
|
AccordionSummary, |
||||||
|
Box, |
||||||
|
LinearProgress, |
||||||
|
List, |
||||||
|
ListItem, |
||||||
|
ListItemIcon, |
||||||
|
Typography, |
||||||
|
useTheme |
||||||
|
} from '@mui/material' |
||||||
|
import { Movie, ArrowDropDown } from '@mui/icons-material' |
||||||
|
import { SxProps } from '@mui/system' |
||||||
|
import { Theme } from '@mui/material/styles' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' |
||||||
|
import { removePrefix } from '../../utils/blogIdformats' |
||||||
|
import { useLocation, useNavigate } from 'react-router-dom' |
||||||
|
import AudiotrackIcon from '@mui/icons-material/Audiotrack' |
||||||
|
import { |
||||||
|
setCurrAudio, |
||||||
|
setShowingAudioPlayer |
||||||
|
} from '../../state/features/globalSlice' |
||||||
|
import { MAIL_ATTACHMENT_SERVICE_TYPE } from '../../constants/mail' |
||||||
|
|
||||||
|
type DownloadItem = { |
||||||
|
id: string |
||||||
|
name: string |
||||||
|
progress: number |
||||||
|
} |
||||||
|
|
||||||
|
export const DownloadTaskManager: React.FC = () => { |
||||||
|
const { downloads } = useSelector((state: RootState) => state.global) |
||||||
|
const dispatch = useDispatch() |
||||||
|
const location = useLocation() |
||||||
|
const isMailRoute = location.pathname === '/mail' |
||||||
|
const theme = useTheme() |
||||||
|
const [visible, setVisible] = useState(false) |
||||||
|
const [hidden, setHidden] = useState(true) |
||||||
|
const navigate = useNavigate() |
||||||
|
const containerStyles: SxProps<Theme> = { |
||||||
|
position: 'fixed', |
||||||
|
top: '50px', |
||||||
|
right: 0, |
||||||
|
zIndex: 1000, |
||||||
|
maxHeight: '80%', |
||||||
|
overflowY: 'auto', |
||||||
|
backgroundColor: 'background.paper', |
||||||
|
boxShadow: 2, |
||||||
|
display: 'block' |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
// Simulate downloads for demo purposes
|
||||||
|
|
||||||
|
if (visible) { |
||||||
|
setTimeout(() => { |
||||||
|
setHidden(true) |
||||||
|
setVisible(false) |
||||||
|
}, 3000) |
||||||
|
} |
||||||
|
}, [visible]) |
||||||
|
|
||||||
|
const toggleVisibility = () => { |
||||||
|
setVisible(true) |
||||||
|
setHidden(false) |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (Object.keys(downloads).length === 0) return |
||||||
|
setVisible(true) |
||||||
|
setHidden(false) |
||||||
|
}, [downloads]) |
||||||
|
|
||||||
|
if (isMailRoute) return null |
||||||
|
if ( |
||||||
|
!downloads || |
||||||
|
Object.keys(downloads).filter( |
||||||
|
(item) => downloads[item].service !== MAIL_ATTACHMENT_SERVICE_TYPE |
||||||
|
).length === 0 |
||||||
|
) |
||||||
|
return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box sx={{ position: 'fixed', top: '50px', right: '5px', zIndex: 1000 }}> |
||||||
|
<Accordion |
||||||
|
sx={{ |
||||||
|
width: '200px', |
||||||
|
backgroundColor: theme.palette.primary.main |
||||||
|
}} |
||||||
|
> |
||||||
|
<AccordionSummary |
||||||
|
expandIcon={<ExpandMoreIcon />} |
||||||
|
aria-controls="panel1a-content" |
||||||
|
id="panel1a-header" |
||||||
|
sx={{ |
||||||
|
minHeight: 'unset', |
||||||
|
height: '36px', |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
'&.MuiAccordionSummary-content': { |
||||||
|
padding: 0, |
||||||
|
margin: 0 |
||||||
|
}, |
||||||
|
'&.Mui-expanded': { |
||||||
|
minHeight: 'unset', |
||||||
|
height: '36px' |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontFamily: 'Arial', |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontSize: '14px' |
||||||
|
}} |
||||||
|
> |
||||||
|
Downloads |
||||||
|
</Typography> |
||||||
|
</AccordionSummary> |
||||||
|
<AccordionDetails |
||||||
|
sx={{ |
||||||
|
padding: '5px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<List |
||||||
|
sx={{ |
||||||
|
maxHeight: '50vh', |
||||||
|
overflow: 'auto' |
||||||
|
}} |
||||||
|
> |
||||||
|
{Object.keys(downloads) |
||||||
|
.filter( |
||||||
|
(item) => |
||||||
|
downloads[item].service !== MAIL_ATTACHMENT_SERVICE_TYPE |
||||||
|
) |
||||||
|
.map((download: any) => { |
||||||
|
const downloadObj = downloads[download] |
||||||
|
const progress = downloads[download]?.status?.percentLoaded || 0 |
||||||
|
const status = downloads[download]?.status?.status |
||||||
|
const service = downloads[download]?.service |
||||||
|
return ( |
||||||
|
<ListItem |
||||||
|
key={downloadObj?.identifier} |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
width: '100%', |
||||||
|
justifyContent: 'center', |
||||||
|
background: theme.palette.primary.main, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
cursor: 'pointer', |
||||||
|
padding: '2px' |
||||||
|
}} |
||||||
|
onClick={() => { |
||||||
|
if (service === 'AUDIO' && downloadObj?.identifier) { |
||||||
|
dispatch(setCurrAudio(downloadObj?.identifier)) |
||||||
|
dispatch(setShowingAudioPlayer(true)) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const str = downloadObj?.blogPost?.postId |
||||||
|
if (!str) return |
||||||
|
const arr = str.split('-post-') |
||||||
|
const str1 = arr[0] |
||||||
|
const str2 = arr[1] |
||||||
|
const blogId = removePrefix(str1) |
||||||
|
navigate( |
||||||
|
`/${downloadObj?.blogPost.user}/${blogId}/${str2}` |
||||||
|
) |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<ListItemIcon> |
||||||
|
{service === 'AUDIO' && ( |
||||||
|
<AudiotrackIcon |
||||||
|
sx={{ color: theme.palette.text.primary }} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{service === 'VIDEO' && ( |
||||||
|
<Movie sx={{ color: theme.palette.text.primary }} /> |
||||||
|
)} |
||||||
|
</ListItemIcon> |
||||||
|
|
||||||
|
<Box |
||||||
|
sx={{ width: '100px', marginLeft: 1, marginRight: 1 }} |
||||||
|
> |
||||||
|
<LinearProgress |
||||||
|
variant="determinate" |
||||||
|
value={progress} |
||||||
|
sx={{ |
||||||
|
borderRadius: '5px', |
||||||
|
color: theme.palette.secondary.main |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontFamily: 'Arial', |
||||||
|
color: theme.palette.text.primary |
||||||
|
}} |
||||||
|
variant="caption" |
||||||
|
> |
||||||
|
{`${progress?.toFixed(0)}%`}{' '} |
||||||
|
{status && status === 'REFETCHING' && '- refetching'} |
||||||
|
{status && status === 'DOWNLOADED' && '- building'} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontSize: '10px', |
||||||
|
width: '100%', |
||||||
|
textAlign: 'end', |
||||||
|
fontFamily: 'Arial', |
||||||
|
color: theme.palette.text.primary |
||||||
|
}} |
||||||
|
> |
||||||
|
{downloadObj?.identifier} |
||||||
|
</Typography> |
||||||
|
</ListItem> |
||||||
|
) |
||||||
|
})} |
||||||
|
</List> |
||||||
|
</AccordionDetails> |
||||||
|
</Accordion> |
||||||
|
|
||||||
|
{/* <IconButton onClick={() => {}} aria-label="toggle download manager"> |
||||||
|
<ArrowDropDown /> |
||||||
|
</IconButton> */} |
||||||
|
{/* <Box sx={containerStyles}> |
||||||
|
<List |
||||||
|
sx={{ |
||||||
|
width: '200px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{Object.keys(downloads).map((download: any) => { |
||||||
|
const downloadObj = downloads[download] |
||||||
|
const progress = downloads[download]?.status?.percentLoaded || 0 |
||||||
|
return ( |
||||||
|
<ListItem |
||||||
|
key={downloadObj?.identifier} |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
width: '100%', |
||||||
|
justifyContent: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<ListItemIcon> |
||||||
|
<Movie /> |
||||||
|
</ListItemIcon> |
||||||
|
|
||||||
|
<Box sx={{ width: '100px', marginLeft: 1 }}> |
||||||
|
<LinearProgress variant="determinate" value={progress} /> |
||||||
|
</Box> |
||||||
|
<Typography variant="caption">{`${progress}%`}</Typography> |
||||||
|
</Box> |
||||||
|
|
||||||
|
<ListItemText |
||||||
|
primary={downloadObj?.identifier} |
||||||
|
sx={{ |
||||||
|
fontSize: '14px', |
||||||
|
width: '100%', |
||||||
|
textAlign: 'end' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</ListItem> |
||||||
|
) |
||||||
|
})} |
||||||
|
</List> |
||||||
|
</Box> */} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,55 @@ |
|||||||
|
// DraggableResizableGrid.tsx
|
||||||
|
import React from 'react' |
||||||
|
import { DndProvider } from 'react-dnd' |
||||||
|
import { HTML5Backend } from 'react-dnd-html5-backend' |
||||||
|
import GridLayout, { Layout } from 'react-grid-layout' |
||||||
|
|
||||||
|
import './DraggableResizableGrid.css' // Add your custom CSS for the grid layout
|
||||||
|
|
||||||
|
interface GridItem { |
||||||
|
id: string |
||||||
|
content: React.ReactNode |
||||||
|
} |
||||||
|
|
||||||
|
interface DraggableResizableGridProps { |
||||||
|
items: GridItem[] |
||||||
|
cols?: number |
||||||
|
rowHeight?: number |
||||||
|
onLayoutChange?: (layout: Layout[]) => void |
||||||
|
} |
||||||
|
|
||||||
|
const DraggableResizableGrid: React.FC<DraggableResizableGridProps> = ({ |
||||||
|
items, |
||||||
|
cols = 12, |
||||||
|
rowHeight = 30, |
||||||
|
onLayoutChange |
||||||
|
}) => { |
||||||
|
const layout = items.map((item, index) => ({ |
||||||
|
i: item.id, |
||||||
|
x: index % cols, |
||||||
|
y: Math.floor(index / cols), |
||||||
|
w: 4, |
||||||
|
h: 4 |
||||||
|
})) |
||||||
|
|
||||||
|
return ( |
||||||
|
<DndProvider backend={HTML5Backend}> |
||||||
|
<GridLayout |
||||||
|
className="layout" |
||||||
|
layout={layout} |
||||||
|
cols={cols} |
||||||
|
rowHeight={rowHeight} |
||||||
|
width={1200} |
||||||
|
onLayoutChange={onLayoutChange} |
||||||
|
> |
||||||
|
{items.map((item) => ( |
||||||
|
<div key={item.id} className="grid-item"> |
||||||
|
{item.content} |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</GridLayout> |
||||||
|
</DndProvider> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default DraggableResizableGrid |
@ -0,0 +1,36 @@ |
|||||||
|
import React, { ReactNode } from 'react' |
||||||
|
|
||||||
|
interface ErrorBoundaryProps { |
||||||
|
children: ReactNode |
||||||
|
fallback: ReactNode |
||||||
|
} |
||||||
|
|
||||||
|
interface ErrorBoundaryState { |
||||||
|
hasError: boolean |
||||||
|
} |
||||||
|
|
||||||
|
class ErrorBoundary extends React.Component< |
||||||
|
ErrorBoundaryProps, |
||||||
|
ErrorBoundaryState |
||||||
|
> { |
||||||
|
state: ErrorBoundaryState = { |
||||||
|
hasError: false |
||||||
|
} |
||||||
|
|
||||||
|
static getDerivedStateFromError(_: Error): ErrorBoundaryState { |
||||||
|
return { hasError: true } |
||||||
|
} |
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { |
||||||
|
// You can log the error and errorInfo here, for example, to an error reporting service.
|
||||||
|
console.error('Error caught in ErrorBoundary:', error, errorInfo) |
||||||
|
} |
||||||
|
|
||||||
|
render(): React.ReactNode { |
||||||
|
if (this.state.hasError) return this.props.fallback |
||||||
|
|
||||||
|
return this.props.children |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export default ErrorBoundary |
@ -0,0 +1,232 @@ |
|||||||
|
import React, { useState, useEffect } from 'react' |
||||||
|
import { styled, Box } from '@mui/system' |
||||||
|
import { |
||||||
|
Drawer, |
||||||
|
List, |
||||||
|
ListItem, |
||||||
|
ListItemText, |
||||||
|
Typography, |
||||||
|
ButtonBase, |
||||||
|
Button, |
||||||
|
Tooltip |
||||||
|
} from '@mui/material' |
||||||
|
import VideoCallIcon from '@mui/icons-material/VideoCall' |
||||||
|
import VideoModal from './VideoPublishModal' |
||||||
|
import { useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import AttachFileIcon from '@mui/icons-material/AttachFile' |
||||||
|
import { AudioModal } from './AudioPublishModal' |
||||||
|
import AudioFileIcon from '@mui/icons-material/AudioFile' |
||||||
|
import { GenericModal } from './GenericPublishModal' |
||||||
|
interface VideoPanelProps { |
||||||
|
onSelect: (video: Video) => void |
||||||
|
height?: string |
||||||
|
width?: string |
||||||
|
} |
||||||
|
|
||||||
|
interface VideoApiResponse { |
||||||
|
videos: Video[] |
||||||
|
} |
||||||
|
|
||||||
|
const Panel = styled('div')` |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
width: 100%; |
||||||
|
padding-bottom: 10px; |
||||||
|
height: 100%; |
||||||
|
overflow: hidden; |
||||||
|
|
||||||
|
&::-webkit-scrollbar { |
||||||
|
width: 8px; |
||||||
|
height: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb { |
||||||
|
background-color: #888; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover { |
||||||
|
background-color: #555; |
||||||
|
} |
||||||
|
` |
||||||
|
|
||||||
|
const PublishButton = styled(Button)` |
||||||
|
/* position: absolute; |
||||||
|
bottom: 20px; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
margin: auto; */ |
||||||
|
max-width: 80%; |
||||||
|
` |
||||||
|
|
||||||
|
export const FilePanel: React.FC<VideoPanelProps> = ({ |
||||||
|
onSelect, |
||||||
|
height, |
||||||
|
width |
||||||
|
}) => { |
||||||
|
const [isOpen, setIsOpen] = useState(false) |
||||||
|
const [videos, setVideos] = useState<Video[]>([]) |
||||||
|
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false) |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
|
||||||
|
const fetchVideos = React.useCallback(async (): Promise<Video[]> => { |
||||||
|
if (!user?.name) return [] |
||||||
|
|
||||||
|
let res |
||||||
|
try { |
||||||
|
res = await qortalRequest({ |
||||||
|
action: 'LIST_QDN_RESOURCES', |
||||||
|
service: 'FILE', |
||||||
|
name: user.name, |
||||||
|
includeMetadata: true, |
||||||
|
limit: 100, |
||||||
|
offset: 0, |
||||||
|
reverse: true |
||||||
|
}) |
||||||
|
} catch (error) {} |
||||||
|
|
||||||
|
// Replace this URL with the actual API endpoint
|
||||||
|
|
||||||
|
return res |
||||||
|
}, [user]) |
||||||
|
useEffect(() => { |
||||||
|
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleToggle = () => { |
||||||
|
setIsOpen(!isOpen) |
||||||
|
} |
||||||
|
|
||||||
|
const handleClick = (video: Video) => { |
||||||
|
onSelect(video) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Tooltip title="Add any type of file" arrow> |
||||||
|
<AttachFileIcon |
||||||
|
onClick={handleToggle} |
||||||
|
sx={{ |
||||||
|
height: height || '30px', |
||||||
|
width: width || 'auto', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
></AttachFileIcon> |
||||||
|
</Tooltip> |
||||||
|
<Drawer |
||||||
|
anchor="right" |
||||||
|
open={isOpen} |
||||||
|
onClose={handleToggle} |
||||||
|
ModalProps={{ |
||||||
|
keepMounted: true // Better performance on mobile
|
||||||
|
}} |
||||||
|
sx={{ |
||||||
|
'& .MuiPaper-root': { |
||||||
|
width: '400px' |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<Panel> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
alignItems: 'center', |
||||||
|
flex: '0 0' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
variant="h5" |
||||||
|
component="div" |
||||||
|
sx={{ flexGrow: 1, mt: 2, mb: 1 }} |
||||||
|
> |
||||||
|
Select File |
||||||
|
</Typography> |
||||||
|
<Typography |
||||||
|
variant="subtitle2" |
||||||
|
component="div" |
||||||
|
sx={{ flexGrow: 1, mb: 2 }} |
||||||
|
> |
||||||
|
List of Files in QDN under your name (FILE service) |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
|
||||||
|
<List |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flex: '1', |
||||||
|
overflow: 'auto' |
||||||
|
}} |
||||||
|
> |
||||||
|
{videos.map((video) => ( |
||||||
|
<ListItem key={video.identifier}> |
||||||
|
<ButtonBase |
||||||
|
onClick={() => handleClick(video)} |
||||||
|
sx={{ width: '100%' }} |
||||||
|
> |
||||||
|
<ListItemText |
||||||
|
primary={video?.metadata?.title || ''} |
||||||
|
secondary={video?.metadata?.description || ''} |
||||||
|
/> |
||||||
|
</ButtonBase> |
||||||
|
</ListItem> |
||||||
|
))} |
||||||
|
</List> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center', |
||||||
|
flex: '0 0 50px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<PublishButton |
||||||
|
variant="contained" |
||||||
|
onClick={() => setIsOpenVideoModal(true)} |
||||||
|
> |
||||||
|
Publish new file |
||||||
|
</PublishButton> |
||||||
|
</Box> |
||||||
|
</Panel> |
||||||
|
</Drawer> |
||||||
|
<GenericModal |
||||||
|
service="FILE" |
||||||
|
identifierPrefix="qfile_qblog" |
||||||
|
onClose={() => { |
||||||
|
setIsOpenVideoModal(false) |
||||||
|
}} |
||||||
|
open={isOpenVideoModal} |
||||||
|
onPublish={(value) => { |
||||||
|
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||||
|
setIsOpenVideoModal(false) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
// Add this to your 'types.ts' file
|
||||||
|
export interface Video { |
||||||
|
name: string |
||||||
|
service: string |
||||||
|
identifier: string |
||||||
|
metadata: { |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
tags: string[] |
||||||
|
category: string |
||||||
|
categoryName: string |
||||||
|
} |
||||||
|
size: number |
||||||
|
created: number |
||||||
|
updated: number |
||||||
|
} |
@ -0,0 +1,308 @@ |
|||||||
|
import React, { useState } from 'react' |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
Modal, |
||||||
|
TextField, |
||||||
|
Typography, |
||||||
|
Select, |
||||||
|
MenuItem, |
||||||
|
FormControl, |
||||||
|
InputLabel, |
||||||
|
SelectChangeEvent, |
||||||
|
OutlinedInput, |
||||||
|
Chip, |
||||||
|
IconButton |
||||||
|
} from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { useDropzone } from 'react-dropzone' |
||||||
|
import { toBase64 } from '../../utils/toBase64' |
||||||
|
import AddIcon from '@mui/icons-material/Add' |
||||||
|
import CloseIcon from '@mui/icons-material/Close' |
||||||
|
import { usePublishGeneric } from './PublishGeneric' |
||||||
|
import { useDispatch } from 'react-redux' |
||||||
|
import { setNotification } from '../../state/features/notificationsSlice' |
||||||
|
|
||||||
|
const StyledModal = styled(Modal)(({ theme }) => ({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center' |
||||||
|
})) |
||||||
|
|
||||||
|
const ChipContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
flexWrap: 'wrap', |
||||||
|
'& > *': { |
||||||
|
margin: '4px' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const ModalContent = styled(Box)(({ theme }) => ({ |
||||||
|
backgroundColor: theme.palette.background.paper, |
||||||
|
padding: theme.spacing(4), |
||||||
|
borderRadius: theme.spacing(1), |
||||||
|
width: '40%', |
||||||
|
'&:focus': { |
||||||
|
outline: 'none' |
||||||
|
} |
||||||
|
})) |
||||||
|
|
||||||
|
interface GenericModalProps { |
||||||
|
open: boolean |
||||||
|
onClose: () => void |
||||||
|
onPublish: (value: any) => void |
||||||
|
acceptedFileType?: string |
||||||
|
acceptedFileTypes?: string[] |
||||||
|
service: string |
||||||
|
identifierPrefix: string |
||||||
|
} |
||||||
|
|
||||||
|
interface SelectOption { |
||||||
|
id: string |
||||||
|
name: string |
||||||
|
} |
||||||
|
const maxSize = 500 * 1024 * 1024 |
||||||
|
|
||||||
|
export const GenericModal: React.FC<GenericModalProps> = ({ |
||||||
|
open, |
||||||
|
onClose, |
||||||
|
onPublish, |
||||||
|
acceptedFileType, |
||||||
|
acceptedFileTypes, |
||||||
|
service, |
||||||
|
identifierPrefix |
||||||
|
}) => { |
||||||
|
const [file, setFile] = useState<File | null>(null) |
||||||
|
const [title, setTitle] = useState('') |
||||||
|
const [description, setDescription] = useState('') |
||||||
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||||
|
null |
||||||
|
) |
||||||
|
const [inputValue, setInputValue] = useState<string>('') |
||||||
|
const [chips, setChips] = useState<string[]>([]) |
||||||
|
|
||||||
|
const [options, setOptions] = useState<SelectOption[]>([]) |
||||||
|
const [tags, setTags] = useState<string[]>([]) |
||||||
|
const { publishGeneric } = usePublishGeneric() |
||||||
|
const dispatch = useDispatch() |
||||||
|
|
||||||
|
let acceptedFile = {} |
||||||
|
if (acceptedFileType) { |
||||||
|
acceptedFile = { |
||||||
|
[acceptedFileType]: [] |
||||||
|
} |
||||||
|
} |
||||||
|
const { getRootProps, getInputProps } = useDropzone({ |
||||||
|
...acceptedFile, |
||||||
|
maxFiles: 1, |
||||||
|
maxSize, |
||||||
|
onDrop: (acceptedFiles) => { |
||||||
|
setFile(acceptedFiles[0]) |
||||||
|
}, |
||||||
|
onDropRejected: (rejectedFiles) => { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: 'Your file is over the 500mb limit.', |
||||||
|
alertType: 'error' |
||||||
|
}) |
||||||
|
) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
||||||
|
setTitle(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleDescriptionChange = ( |
||||||
|
event: React.ChangeEvent<HTMLInputElement> |
||||||
|
) => { |
||||||
|
setDescription(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||||
|
const optionId = event.target.value |
||||||
|
const selectedOption = options.find((option) => option.id === optionId) |
||||||
|
setSelectedOption(selectedOption || null) |
||||||
|
} |
||||||
|
|
||||||
|
const handleChipDelete = (index: number) => { |
||||||
|
const newChips = [...chips] |
||||||
|
newChips.splice(index, 1) |
||||||
|
setChips(newChips) |
||||||
|
} |
||||||
|
|
||||||
|
const handleSubmit = async () => { |
||||||
|
const missingFields = [] |
||||||
|
|
||||||
|
if (!title) missingFields.push('title') |
||||||
|
if (!file) missingFields.push('file') |
||||||
|
if (missingFields.length > 0) { |
||||||
|
const missingFieldsString = missingFields.join(', ') |
||||||
|
const errMsg = `Missing: ${missingFieldsString}` |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
if (!file) return |
||||||
|
|
||||||
|
const formattedTags: { [key: string]: string } = {} |
||||||
|
chips.forEach((tag, i) => { |
||||||
|
formattedTags[`tag${i + 1}`] = tag |
||||||
|
}) |
||||||
|
|
||||||
|
try { |
||||||
|
const base64 = await toBase64(file) |
||||||
|
if (typeof base64 !== 'string') return |
||||||
|
const base64String = base64.split(',')[1] |
||||||
|
const fileExtension = file?.name?.split('.')?.pop() |
||||||
|
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20) |
||||||
|
const filename = `${fileTitle}.${fileExtension}` |
||||||
|
const res = await publishGeneric({ |
||||||
|
service, |
||||||
|
identifierPrefix, |
||||||
|
title, |
||||||
|
description, |
||||||
|
base64: base64String, |
||||||
|
filename: filename, |
||||||
|
category: selectedOption?.id || '', |
||||||
|
...formattedTags |
||||||
|
}) |
||||||
|
onPublish(res) |
||||||
|
setFile(null) |
||||||
|
setTitle('') |
||||||
|
setDescription('') |
||||||
|
onClose() |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputChange = (event: any) => { |
||||||
|
setInputValue(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputKeyDown = (event: any) => { |
||||||
|
if (event.key === 'Enter' && inputValue !== '') { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} else { |
||||||
|
event.preventDefault() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const addChip = () => { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getListCategories = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const url = `/arbitrary/categories` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
setOptions(responseData) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getListCategories() |
||||||
|
}, [getListCategories]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<StyledModal open={open} onClose={onClose}> |
||||||
|
<ModalContent> |
||||||
|
<Typography variant="h6" component="h2" gutterBottom> |
||||||
|
Upload {service} |
||||||
|
</Typography> |
||||||
|
<Box |
||||||
|
{...getRootProps()} |
||||||
|
sx={{ |
||||||
|
border: '1px dashed gray', |
||||||
|
padding: 2, |
||||||
|
textAlign: 'center', |
||||||
|
marginBottom: 2 |
||||||
|
}} |
||||||
|
> |
||||||
|
<input {...getInputProps()} /> |
||||||
|
<Typography> |
||||||
|
{file |
||||||
|
? file.name |
||||||
|
: 'Drag and drop a file here or click to select a file'} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
<TextField |
||||||
|
label="Title" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
value={title} |
||||||
|
onChange={handleTitleChange} |
||||||
|
inputProps={{ maxLength: 40 }} |
||||||
|
sx={{ marginBottom: 2 }} |
||||||
|
/> |
||||||
|
<TextField |
||||||
|
label="Description" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
multiline |
||||||
|
rows={4} |
||||||
|
value={description} |
||||||
|
onChange={handleDescriptionChange} |
||||||
|
inputProps={{ maxLength: 180 }} |
||||||
|
sx={{ marginBottom: 2 }} |
||||||
|
/> |
||||||
|
{options.length > 0 && ( |
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<InputLabel id="Category">Select a Category</InputLabel> |
||||||
|
<Select |
||||||
|
labelId="Category" |
||||||
|
input={<OutlinedInput label="Select a Category" />} |
||||||
|
value={selectedOption?.id || ''} |
||||||
|
onChange={handleOptionChange} |
||||||
|
> |
||||||
|
{options.map((option) => ( |
||||||
|
<MenuItem key={option.id} value={option.id}> |
||||||
|
{option.name} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
)} |
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||||
|
<TextField |
||||||
|
label="Add a tag" |
||||||
|
value={inputValue} |
||||||
|
onChange={handleInputChange} |
||||||
|
onKeyDown={handleInputKeyDown} |
||||||
|
disabled={chips.length === 3} |
||||||
|
/> |
||||||
|
|
||||||
|
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||||
|
<AddIcon /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
<ChipContainer> |
||||||
|
{chips.map((chip, index) => ( |
||||||
|
<Chip |
||||||
|
key={index} |
||||||
|
label={chip} |
||||||
|
onDelete={() => handleChipDelete(index)} |
||||||
|
deleteIcon={<CloseIcon />} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ChipContainer> |
||||||
|
</FormControl> |
||||||
|
<Button variant="contained" color="primary" onClick={handleSubmit}> |
||||||
|
Submit |
||||||
|
</Button> |
||||||
|
</ModalContent> |
||||||
|
</StyledModal> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,54 @@ |
|||||||
|
import React, { useState, useEffect } from 'react'; |
||||||
|
import { Menu, MenuItem } from '@mui/material'; |
||||||
|
import { CopyToClipboard } from 'react-copy-to-clipboard'; |
||||||
|
|
||||||
|
interface MousePosition { |
||||||
|
mouseX: number; |
||||||
|
mouseY: number; |
||||||
|
} |
||||||
|
|
||||||
|
export const GlobalContextMenu: React.FC = () => { |
||||||
|
const [mousePosition, setMousePosition] = useState<MousePosition | null>(null); |
||||||
|
const [textToCopy, setTextToCopy] = useState<string>(''); |
||||||
|
|
||||||
|
const handleContextMenu = (event: React.MouseEvent) => { |
||||||
|
event.preventDefault(); |
||||||
|
const selection = window.getSelection()?.toString(); |
||||||
|
if (selection) { |
||||||
|
setTextToCopy(selection); |
||||||
|
setMousePosition({ |
||||||
|
mouseX: event.clientX - 2, |
||||||
|
mouseY: event.clientY - 4, |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const handleClose = () => { |
||||||
|
setMousePosition(null); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
document.addEventListener('contextmenu', handleContextMenu as any); |
||||||
|
return () => { |
||||||
|
document.removeEventListener('contextmenu', handleContextMenu as any); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Menu |
||||||
|
open={mousePosition !== null} |
||||||
|
onClose={handleClose} |
||||||
|
anchorReference="anchorPosition" |
||||||
|
anchorPosition={ |
||||||
|
mousePosition !== null |
||||||
|
? { top: mousePosition.mouseY, left: mousePosition.mouseX } |
||||||
|
: undefined |
||||||
|
} |
||||||
|
> |
||||||
|
<CopyToClipboard text={textToCopy} onCopy={handleClose}> |
||||||
|
<MenuItem>Copy</MenuItem> |
||||||
|
</CopyToClipboard> |
||||||
|
</Menu> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
@ -0,0 +1,74 @@ |
|||||||
|
import React, { useCallback } from 'react'; |
||||||
|
import { Box, Button, TextField, Typography, Modal } from '@mui/material'; |
||||||
|
import { useDropzone, DropzoneRootProps, DropzoneInputProps } from 'react-dropzone'; |
||||||
|
import Compressor from 'compressorjs'; |
||||||
|
|
||||||
|
const toBase64 = (file: File): Promise<string | ArrayBuffer | null> => |
||||||
|
new Promise((resolve, reject) => { |
||||||
|
const reader = new FileReader(); |
||||||
|
reader.readAsDataURL(file); |
||||||
|
reader.onload = () => resolve(reader.result); |
||||||
|
reader.onerror = (error) => { |
||||||
|
reject(error); |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
interface ImageUploaderProps { |
||||||
|
children: React.ReactNode; |
||||||
|
onPick: (base64Img: string) => void; |
||||||
|
} |
||||||
|
|
||||||
|
const ImageUploader: React.FC<ImageUploaderProps> = ({ children, onPick }) => { |
||||||
|
const onDrop = useCallback(async (acceptedFiles: File[]) => { |
||||||
|
|
||||||
|
if (acceptedFiles.length > 1) { |
||||||
|
return |
||||||
|
} |
||||||
|
let compressedFile: File | undefined |
||||||
|
|
||||||
|
try { |
||||||
|
const image = acceptedFiles[0] |
||||||
|
await new Promise<void>((resolve) => { |
||||||
|
new Compressor(image, { |
||||||
|
quality: 0.6, |
||||||
|
maxWidth: 1200, |
||||||
|
success(result) { |
||||||
|
const file = new File([result], 'name', { |
||||||
|
type: image.type |
||||||
|
}) |
||||||
|
compressedFile = file |
||||||
|
resolve() |
||||||
|
}, |
||||||
|
error(err) {} |
||||||
|
}) |
||||||
|
}) |
||||||
|
if (!compressedFile) return |
||||||
|
const base64Img = await toBase64(compressedFile) |
||||||
|
|
||||||
|
onPick(base64Img as string) |
||||||
|
} catch (error) { |
||||||
|
console.error(error) |
||||||
|
} |
||||||
|
}, [onPick]); |
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive }: { getRootProps: () => DropzoneRootProps; getInputProps: () => DropzoneInputProps; isDragActive: boolean } = useDropzone({ |
||||||
|
onDrop, |
||||||
|
accept: { |
||||||
|
'image/*': [] |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
{...getRootProps()} |
||||||
|
sx={{ |
||||||
|
display: 'flex' |
||||||
|
}} |
||||||
|
> |
||||||
|
<input {...getInputProps()} /> |
||||||
|
{children} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
}; |
||||||
|
|
||||||
|
export default ImageUploader; |
@ -0,0 +1,47 @@ |
|||||||
|
import React, { useState, useEffect, useRef } from 'react' |
||||||
|
import { useInView } from 'react-intersection-observer' |
||||||
|
import CircularProgress from '@mui/material/CircularProgress' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onLoadMore: () => Promise<void> |
||||||
|
} |
||||||
|
|
||||||
|
const LazyLoad: React.FC<Props> = ({ onLoadMore }) => { |
||||||
|
const [isFetching, setIsFetching] = useState<boolean>(false) |
||||||
|
|
||||||
|
const firstLoad = useRef(false) |
||||||
|
const [ref, inView] = useInView({ |
||||||
|
threshold: 0.7 |
||||||
|
}) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (inView) { |
||||||
|
setIsFetching(true) |
||||||
|
onLoadMore().finally(() => { |
||||||
|
setIsFetching(false) |
||||||
|
firstLoad.current = true |
||||||
|
}) |
||||||
|
} |
||||||
|
}, [inView]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
ref={ref} |
||||||
|
style={{ |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center', |
||||||
|
minHeight: '25px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<div |
||||||
|
style={{ |
||||||
|
visibility: isFetching ? 'visible' : 'hidden' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CircularProgress /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default LazyLoad |
@ -0,0 +1,37 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import LinearProgress, { |
||||||
|
LinearProgressProps |
||||||
|
} from '@mui/material/LinearProgress' |
||||||
|
import Typography from '@mui/material/Typography' |
||||||
|
import Box from '@mui/material/Box' |
||||||
|
import { useTheme } from '@mui/material' |
||||||
|
|
||||||
|
interface LoaderBarProps { |
||||||
|
message: string |
||||||
|
} |
||||||
|
export const LoaderBar = ({ message }: LoaderBarProps) => { |
||||||
|
const theme = useTheme() |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
position: 'fixed', |
||||||
|
bottom: '10px', |
||||||
|
right: '10px', |
||||||
|
zIndex: 1500, |
||||||
|
width: '250px', |
||||||
|
backgroundColor: theme.palette.background.paper, |
||||||
|
borderRadius: '5px', |
||||||
|
padding: '5px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box sx={{ width: '100%', mr: 1 }}> |
||||||
|
<LinearProgress color="secondary" /> |
||||||
|
</Box> |
||||||
|
<Box sx={{ minWidth: 35 }}> |
||||||
|
<Typography variant="body2">{message}</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,211 @@ |
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */ |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
CircularProgress, |
||||||
|
Modal, |
||||||
|
Typography, |
||||||
|
useTheme, |
||||||
|
} from "@mui/material"; |
||||||
|
import React, { useCallback, useEffect, useState, useRef } from "react"; |
||||||
|
import { CircleSVG } from "../../../assets/svgs/CircleSVG"; |
||||||
|
import { EmptyCircleSVG } from "../../../assets/svgs/EmptyCircleSVG"; |
||||||
|
import { styled } from "@mui/system"; |
||||||
|
|
||||||
|
interface Publish { |
||||||
|
resources: any[]; |
||||||
|
action: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface MultiplePublishProps { |
||||||
|
publishes: Publish; |
||||||
|
isOpen: boolean; |
||||||
|
onSubmit: ()=> void |
||||||
|
onError: (message?: string)=> void |
||||||
|
} |
||||||
|
export const MultiplePublish = ({ publishes, isOpen, onSubmit, onError}: MultiplePublishProps) => { |
||||||
|
const theme = useTheme(); |
||||||
|
const listOfSuccessfulPublishesRef = useRef([]) |
||||||
|
const [listOfSuccessfulPublishes, setListOfSuccessfulPublishes] = useState< |
||||||
|
any[] |
||||||
|
>([]); |
||||||
|
const [listOfUnsuccessfulPublishes, setListOfUnSuccessfulPublishes] = useState< |
||||||
|
any[] |
||||||
|
>([]); |
||||||
|
const [currentlyInPublish, setCurrentlyInPublish] = useState(null); |
||||||
|
const hasStarted = useRef(false); |
||||||
|
const publish = useCallback(async (pub: any) => { |
||||||
|
const lengthOfResources = pub?.resources?.length |
||||||
|
const lengthOfTimeout = lengthOfResources * 30000 |
||||||
|
return await qortalRequestWithTimeout(pub, lengthOfTimeout); |
||||||
|
}, []); |
||||||
|
const [isPublishing, setIsPublishing] = useState(true) |
||||||
|
|
||||||
|
const handlePublish = useCallback( |
||||||
|
async (pub: any) => { |
||||||
|
try { |
||||||
|
setCurrentlyInPublish(pub?.identifier); |
||||||
|
setIsPublishing(true) |
||||||
|
const res = await publish(pub); |
||||||
|
|
||||||
|
onSubmit() |
||||||
|
setListOfUnSuccessfulPublishes([]) |
||||||
|
|
||||||
|
} catch (error: any) { |
||||||
|
const unsuccessfulPublishes = error?.error?.unsuccessfulPublishes || [] |
||||||
|
if(error?.error === 'User declined request'){ |
||||||
|
onError() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if(error?.error === 'The request timed out'){ |
||||||
|
onError("The request timed out") |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
if(unsuccessfulPublishes?.length > 0){ |
||||||
|
setListOfUnSuccessfulPublishes(unsuccessfulPublishes) |
||||||
|
|
||||||
|
} |
||||||
|
} finally { |
||||||
|
|
||||||
|
setIsPublishing(false) |
||||||
|
} |
||||||
|
}, |
||||||
|
[publish] |
||||||
|
); |
||||||
|
|
||||||
|
const retry = ()=> { |
||||||
|
let newlistOfMultiplePublishes: any[] = []; |
||||||
|
listOfUnsuccessfulPublishes?.forEach((item)=> { |
||||||
|
const findPub = publishes?.resources.find((res: any)=> res?.identifier === item.identifier) |
||||||
|
if(findPub){ |
||||||
|
newlistOfMultiplePublishes.push(findPub) |
||||||
|
} |
||||||
|
}) |
||||||
|
const multiplePublish = { |
||||||
|
...publishes, |
||||||
|
resources: newlistOfMultiplePublishes |
||||||
|
}; |
||||||
|
handlePublish(multiplePublish) |
||||||
|
} |
||||||
|
|
||||||
|
const startPublish = useCallback( |
||||||
|
async (pubs: any) => { |
||||||
|
await handlePublish(pubs); |
||||||
|
}, |
||||||
|
[handlePublish, onSubmit, listOfSuccessfulPublishes, publishes] |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (publishes && !hasStarted.current) { |
||||||
|
hasStarted.current = true; |
||||||
|
startPublish(publishes); |
||||||
|
} |
||||||
|
}, [startPublish, publishes, listOfSuccessfulPublishes]); |
||||||
|
|
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
open={isOpen} |
||||||
|
aria-labelledby="modal-title" |
||||||
|
aria-describedby="modal-description" |
||||||
|
> |
||||||
|
<ModalBody |
||||||
|
sx={{ |
||||||
|
minHeight: "50vh", |
||||||
|
}} |
||||||
|
> |
||||||
|
{publishes?.resources?.map((publish: any) => { |
||||||
|
const unpublished = listOfUnsuccessfulPublishes.map(item => item?.identifier) |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: "flex", |
||||||
|
gap: "20px", |
||||||
|
justifyContent: "space-between", |
||||||
|
alignItems: "center", |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography>{publish?.identifier}</Typography> |
||||||
|
{!isPublishing && hasStarted.current ? ( |
||||||
|
<> |
||||||
|
{!unpublished.includes(publish.identifier) ? ( |
||||||
|
<CircleSVG |
||||||
|
color={theme.palette.text.primary} |
||||||
|
height="24px" |
||||||
|
width="24px" |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<EmptyCircleSVG |
||||||
|
color={theme.palette.text.primary} |
||||||
|
height="24px" |
||||||
|
width="24px" |
||||||
|
/> |
||||||
|
)} |
||||||
|
</> |
||||||
|
): <CircularProgress size={16} color="secondary"/>} |
||||||
|
|
||||||
|
</Box> |
||||||
|
); |
||||||
|
})} |
||||||
|
{!isPublishing && listOfUnsuccessfulPublishes.length > 0 && ( |
||||||
|
<> |
||||||
|
<Typography sx={{ |
||||||
|
marginTop: '20px', |
||||||
|
fontSize: '16px' |
||||||
|
}}>Some files were not published. Please try again. It's important that all the files get published. Maybe wait a couple minutes if the error keeps occurring</Typography> |
||||||
|
<Button variant="contained" onClick={()=> { |
||||||
|
retry() |
||||||
|
}}>Try again</Button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
</ModalBody> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
|
||||||
|
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", |
||||||
|
}, |
||||||
|
})); |
@ -0,0 +1,43 @@ |
|||||||
|
import React from 'react' |
||||||
|
import CircularProgress from '@mui/material/CircularProgress' |
||||||
|
import Box from '@mui/system/Box' |
||||||
|
import { useTheme } from '@mui/material' |
||||||
|
|
||||||
|
interface PageLoaderProps { |
||||||
|
size?: number |
||||||
|
thickness?: number |
||||||
|
} |
||||||
|
|
||||||
|
const PageLoader: React.FC<PageLoaderProps> = ({ |
||||||
|
size = 40, |
||||||
|
thickness = 5 |
||||||
|
}) => { |
||||||
|
const theme = useTheme() |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center', |
||||||
|
alignItems: 'center', |
||||||
|
height: '100vh', |
||||||
|
width: '100%', |
||||||
|
position: 'fixed', |
||||||
|
top: 0, |
||||||
|
left: 0, |
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.25)', |
||||||
|
zIndex: 200000 |
||||||
|
}} |
||||||
|
> |
||||||
|
<CircularProgress |
||||||
|
size={size} |
||||||
|
thickness={thickness} |
||||||
|
sx={{ |
||||||
|
color: theme.palette.secondary.main |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default PageLoader |
@ -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 |
@ -0,0 +1,281 @@ |
|||||||
|
import React, { useState } from 'react' |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
Modal, |
||||||
|
TextField, |
||||||
|
Typography, |
||||||
|
Select, |
||||||
|
MenuItem, |
||||||
|
FormControl, |
||||||
|
InputLabel, |
||||||
|
SelectChangeEvent, |
||||||
|
OutlinedInput, |
||||||
|
Chip, |
||||||
|
IconButton |
||||||
|
} from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { useDropzone } from 'react-dropzone' |
||||||
|
import { usePublishVideo } from './PublishVideo' |
||||||
|
import { toBase64 } from '../../utils/toBase64' |
||||||
|
import AddIcon from '@mui/icons-material/Add' |
||||||
|
import CloseIcon from '@mui/icons-material/Close' |
||||||
|
const StyledModal = styled(Modal)(({ theme }) => ({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center' |
||||||
|
})) |
||||||
|
|
||||||
|
const ChipContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
flexWrap: 'wrap', |
||||||
|
'& > *': { |
||||||
|
margin: '4px' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const ModalContent = styled(Box)(({ theme }) => ({ |
||||||
|
backgroundColor: theme.palette.background.paper, |
||||||
|
padding: theme.spacing(4), |
||||||
|
borderRadius: theme.spacing(1), |
||||||
|
width: '40%', |
||||||
|
'&:focus': { |
||||||
|
outline: 'none' |
||||||
|
} |
||||||
|
})) |
||||||
|
|
||||||
|
interface PostModalProps { |
||||||
|
open: boolean |
||||||
|
onClose: () => void |
||||||
|
onPublish: (value: any) => Promise<void> |
||||||
|
post: any |
||||||
|
mode?: string |
||||||
|
metadata?: any |
||||||
|
} |
||||||
|
|
||||||
|
interface SelectOption { |
||||||
|
id: string |
||||||
|
name: string |
||||||
|
} |
||||||
|
|
||||||
|
const PostPublishModal: React.FC<PostModalProps> = ({ |
||||||
|
open, |
||||||
|
onClose, |
||||||
|
onPublish, |
||||||
|
post, |
||||||
|
mode, |
||||||
|
metadata |
||||||
|
}) => { |
||||||
|
const [file, setFile] = useState<File | null>(null) |
||||||
|
const [title, setTitle] = useState('') |
||||||
|
const [description, setDescription] = useState('') |
||||||
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||||
|
null |
||||||
|
) |
||||||
|
const [inputValue, setInputValue] = useState<string>('') |
||||||
|
const [chips, setChips] = useState<string[]>([]) |
||||||
|
|
||||||
|
const [options, setOptions] = useState<SelectOption[]>([]) |
||||||
|
const [tags, setTags] = useState<string[]>([]) |
||||||
|
const { publishVideo } = usePublishVideo() |
||||||
|
const { getRootProps, getInputProps } = useDropzone({ |
||||||
|
accept: { |
||||||
|
'video/*': [] |
||||||
|
}, |
||||||
|
maxFiles: 1, |
||||||
|
onDrop: (acceptedFiles) => { |
||||||
|
setFile(acceptedFiles[0]) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
if (post.title) { |
||||||
|
setTitle(post.title) |
||||||
|
} |
||||||
|
// if (post.description) {
|
||||||
|
// setDescription(post.description)
|
||||||
|
// }
|
||||||
|
}, [post]) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
if (mode === 'edit' && metadata) { |
||||||
|
if (metadata.description) { |
||||||
|
setDescription(metadata.description) |
||||||
|
} |
||||||
|
|
||||||
|
const findCategory = options.find( |
||||||
|
(option) => option.id === metadata?.category |
||||||
|
) |
||||||
|
if (findCategory) { |
||||||
|
setSelectedOption(findCategory) |
||||||
|
} |
||||||
|
|
||||||
|
if (!metadata?.tags || !Array.isArray(metadata?.tags)) return |
||||||
|
|
||||||
|
setChips(metadata.tags.slice(0, -2)) |
||||||
|
} |
||||||
|
}, [mode, metadata, options]) |
||||||
|
|
||||||
|
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
||||||
|
setTitle(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleDescriptionChange = ( |
||||||
|
event: React.ChangeEvent<HTMLInputElement> |
||||||
|
) => { |
||||||
|
setDescription(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||||
|
const optionId = event.target.value |
||||||
|
const selectedOption = options.find((option) => option.id === optionId) |
||||||
|
setSelectedOption(selectedOption || null) |
||||||
|
} |
||||||
|
|
||||||
|
const handleChipDelete = (index: number) => { |
||||||
|
const newChips = [...chips] |
||||||
|
newChips.splice(index, 1) |
||||||
|
setChips(newChips) |
||||||
|
} |
||||||
|
|
||||||
|
const handleSubmit = async () => { |
||||||
|
const formattedTags: { [key: string]: string } = {} |
||||||
|
chips.forEach((tag, i) => { |
||||||
|
formattedTags[`tag${i + 1}`] = tag |
||||||
|
}) |
||||||
|
|
||||||
|
try { |
||||||
|
await onPublish({ |
||||||
|
title, |
||||||
|
description, |
||||||
|
tags: chips, |
||||||
|
category: selectedOption?.id || '' |
||||||
|
}) |
||||||
|
setFile(null) |
||||||
|
setTitle('') |
||||||
|
setDescription('') |
||||||
|
onClose() |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputChange = (event: any) => { |
||||||
|
setInputValue(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
const handleInputKeyDown = (event: any) => { |
||||||
|
if (event.key === 'Enter' && inputValue !== '') { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} else { |
||||||
|
event.preventDefault() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const addChip = () => { |
||||||
|
if (chips.length < 3) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getListCategories = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const url = `/arbitrary/categories` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
setOptions(responseData) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getListCategories() |
||||||
|
}, [getListCategories]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<StyledModal open={open} onClose={onClose}> |
||||||
|
<ModalContent> |
||||||
|
<Typography variant="h6" component="h2" gutterBottom> |
||||||
|
Upload Blog Post |
||||||
|
</Typography> |
||||||
|
|
||||||
|
<TextField |
||||||
|
label="Post Title" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
value={title} |
||||||
|
onChange={handleTitleChange} |
||||||
|
inputProps={{ maxLength: 40 }} |
||||||
|
sx={{ marginBottom: 2 }} |
||||||
|
disabled |
||||||
|
/> |
||||||
|
<TextField |
||||||
|
label="Post Description" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
multiline |
||||||
|
rows={4} |
||||||
|
value={description} |
||||||
|
onChange={handleDescriptionChange} |
||||||
|
inputProps={{ maxLength: 180 }} |
||||||
|
sx={{ marginBottom: 2 }} |
||||||
|
/> |
||||||
|
{options.length > 0 && ( |
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<InputLabel id="Category">Select a Category</InputLabel> |
||||||
|
<Select |
||||||
|
labelId="Category" |
||||||
|
input={<OutlinedInput label="Select a Category" />} |
||||||
|
value={selectedOption?.id || ''} |
||||||
|
onChange={handleOptionChange} |
||||||
|
> |
||||||
|
{options.map((option) => ( |
||||||
|
<MenuItem key={option.id} value={option.id}> |
||||||
|
{option.name} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
)} |
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||||
|
<TextField |
||||||
|
label="Add a tag" |
||||||
|
value={inputValue} |
||||||
|
onChange={handleInputChange} |
||||||
|
onKeyDown={handleInputKeyDown} |
||||||
|
disabled={chips.length === 3} |
||||||
|
/> |
||||||
|
|
||||||
|
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||||
|
<AddIcon /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
<ChipContainer> |
||||||
|
{chips.map((chip, index) => ( |
||||||
|
<Chip |
||||||
|
key={index} |
||||||
|
label={chip} |
||||||
|
onDelete={() => handleChipDelete(index)} |
||||||
|
deleteIcon={<CloseIcon />} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ChipContainer> |
||||||
|
</FormControl> |
||||||
|
<Button variant="contained" color="primary" onClick={handleSubmit}> |
||||||
|
Submit |
||||||
|
</Button> |
||||||
|
</ModalContent> |
||||||
|
</StyledModal> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default PostPublishModal |
@ -0,0 +1,106 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { setNotification } from '../../state/features/notificationsSlice' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import ShortUniqueId from 'short-unique-id' |
||||||
|
|
||||||
|
const uid = new ShortUniqueId() |
||||||
|
|
||||||
|
interface IPublishVideo { |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
base64: string |
||||||
|
category: string |
||||||
|
} |
||||||
|
|
||||||
|
export const usePublishAudio = () => { |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const dispatch = useDispatch() |
||||||
|
const publishAudio = async ({ |
||||||
|
title, |
||||||
|
description, |
||||||
|
base64, |
||||||
|
category, |
||||||
|
...rest |
||||||
|
}: IPublishVideo) => { |
||||||
|
let address |
||||||
|
let name |
||||||
|
let errorMsg = '' |
||||||
|
|
||||||
|
address = user?.address |
||||||
|
name = user?.name || '' |
||||||
|
|
||||||
|
const missingFields = [] |
||||||
|
if (!address) { |
||||||
|
errorMsg = "Cannot post: your address isn't available" |
||||||
|
} |
||||||
|
if (!name) { |
||||||
|
errorMsg = 'Cannot post without a name' |
||||||
|
} |
||||||
|
if (!title) missingFields.push('title') |
||||||
|
if (missingFields.length > 0) { |
||||||
|
const missingFieldsString = missingFields.join(', ') |
||||||
|
const errMsg = `Missing: ${missingFieldsString}` |
||||||
|
errorMsg = errMsg |
||||||
|
} |
||||||
|
|
||||||
|
if (errorMsg) { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: errorMsg, |
||||||
|
alertType: 'error' |
||||||
|
}) |
||||||
|
) |
||||||
|
throw new Error(errorMsg) |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const id = uid() |
||||||
|
|
||||||
|
const identifier = `qaudio_qblog_${id}` |
||||||
|
|
||||||
|
const resourceResponse = await qortalRequest({ |
||||||
|
action: 'PUBLISH_QDN_RESOURCE', |
||||||
|
name: name, |
||||||
|
service: 'AUDIO', |
||||||
|
data64: base64, |
||||||
|
title: title, |
||||||
|
description: description, |
||||||
|
category: category, |
||||||
|
...rest, |
||||||
|
identifier: identifier |
||||||
|
}) |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: 'Audio successfully published', |
||||||
|
alertType: 'success' |
||||||
|
}) |
||||||
|
) |
||||||
|
return resourceResponse |
||||||
|
} catch (error: any) { |
||||||
|
let notificationObj = null |
||||||
|
if (typeof error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error || 'Failed to publish audio', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else if (typeof error?.error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.error || 'Failed to publish audio', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.message || error?.message || 'Failed to publish audio', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} |
||||||
|
if (!notificationObj) return |
||||||
|
dispatch(setNotification(notificationObj)) |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
return { |
||||||
|
publishAudio |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,113 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { setNotification } from '../../state/features/notificationsSlice' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import ShortUniqueId from 'short-unique-id' |
||||||
|
|
||||||
|
const uid = new ShortUniqueId() |
||||||
|
|
||||||
|
interface IPublishGeneric { |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
base64: string |
||||||
|
category: string |
||||||
|
service: string |
||||||
|
identifierPrefix: string |
||||||
|
filename: string |
||||||
|
} |
||||||
|
|
||||||
|
export const usePublishGeneric = () => { |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const dispatch = useDispatch() |
||||||
|
const publishGeneric = async ({ |
||||||
|
service, |
||||||
|
identifierPrefix, |
||||||
|
filename, |
||||||
|
title, |
||||||
|
description, |
||||||
|
base64, |
||||||
|
category, |
||||||
|
...rest |
||||||
|
}: IPublishGeneric) => { |
||||||
|
let address |
||||||
|
let name |
||||||
|
let errorMsg = '' |
||||||
|
|
||||||
|
address = user?.address |
||||||
|
name = user?.name || '' |
||||||
|
|
||||||
|
const missingFields = [] |
||||||
|
if (!address) { |
||||||
|
errorMsg = "Cannot post: your address isn't available" |
||||||
|
} |
||||||
|
if (!name) { |
||||||
|
errorMsg = 'Cannot post without a name' |
||||||
|
} |
||||||
|
if (!title) missingFields.push('title') |
||||||
|
if (missingFields.length > 0) { |
||||||
|
const missingFieldsString = missingFields.join(', ') |
||||||
|
const errMsg = `Missing: ${missingFieldsString}` |
||||||
|
errorMsg = errMsg |
||||||
|
} |
||||||
|
|
||||||
|
if (errorMsg) { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: errorMsg, |
||||||
|
alertType: 'error' |
||||||
|
}) |
||||||
|
) |
||||||
|
throw new Error(errorMsg) |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const id = uid() |
||||||
|
|
||||||
|
const identifier = `${identifierPrefix}_${id}` |
||||||
|
|
||||||
|
const resourceResponse = await qortalRequest({ |
||||||
|
action: 'PUBLISH_QDN_RESOURCE', |
||||||
|
name: name, |
||||||
|
service: service, |
||||||
|
data64: base64, |
||||||
|
title: title, |
||||||
|
description: description, |
||||||
|
category: category, |
||||||
|
filename, |
||||||
|
...rest, |
||||||
|
identifier: identifier |
||||||
|
}) |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: `${service} successfully published`, |
||||||
|
alertType: 'success' |
||||||
|
}) |
||||||
|
) |
||||||
|
return resourceResponse |
||||||
|
} catch (error: any) { |
||||||
|
let notificationObj = null |
||||||
|
if (typeof error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error || `Failed to publish ${service}`, |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else if (typeof error?.error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.error || `Failed to publish ${service}`, |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else { |
||||||
|
notificationObj = { |
||||||
|
msg: |
||||||
|
error?.message || error?.message || `Failed to publish ${service}`, |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} |
||||||
|
if (!notificationObj) return |
||||||
|
dispatch(setNotification(notificationObj)) |
||||||
|
} |
||||||
|
} |
||||||
|
return { |
||||||
|
publishGeneric |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,106 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { setNotification } from '../../state/features/notificationsSlice' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import ShortUniqueId from 'short-unique-id' |
||||||
|
|
||||||
|
const uid = new ShortUniqueId() |
||||||
|
|
||||||
|
interface IPublishVideo { |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
base64: string |
||||||
|
category: string |
||||||
|
} |
||||||
|
|
||||||
|
export const usePublishVideo = () => { |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const dispatch = useDispatch() |
||||||
|
const publishVideo = async ({ |
||||||
|
title, |
||||||
|
description, |
||||||
|
base64, |
||||||
|
category, |
||||||
|
...rest |
||||||
|
}: IPublishVideo) => { |
||||||
|
let address |
||||||
|
let name |
||||||
|
let errorMsg = '' |
||||||
|
|
||||||
|
address = user?.address |
||||||
|
name = user?.name || '' |
||||||
|
|
||||||
|
const missingFields = [] |
||||||
|
if (!address) { |
||||||
|
errorMsg = "Cannot post: your address isn't available" |
||||||
|
} |
||||||
|
if (!name) { |
||||||
|
errorMsg = 'Cannot post without a name' |
||||||
|
} |
||||||
|
if (!title) missingFields.push('title') |
||||||
|
if (missingFields.length > 0) { |
||||||
|
const missingFieldsString = missingFields.join(', ') |
||||||
|
const errMsg = `Missing: ${missingFieldsString}` |
||||||
|
errorMsg = errMsg |
||||||
|
} |
||||||
|
|
||||||
|
if (errorMsg) { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: errorMsg, |
||||||
|
alertType: 'error' |
||||||
|
}) |
||||||
|
) |
||||||
|
throw new Error(errorMsg) |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const id = uid() |
||||||
|
|
||||||
|
const identifier = `qvideo_qblog_${id}` |
||||||
|
|
||||||
|
const resourceResponse = await qortalRequest({ |
||||||
|
action: 'PUBLISH_QDN_RESOURCE', |
||||||
|
name: name, |
||||||
|
service: 'VIDEO', |
||||||
|
data64: base64, |
||||||
|
title: title, |
||||||
|
description: description, |
||||||
|
category: category, |
||||||
|
...rest, |
||||||
|
identifier: identifier |
||||||
|
}) |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: 'Video successfully published', |
||||||
|
alertType: 'success' |
||||||
|
}) |
||||||
|
) |
||||||
|
return resourceResponse |
||||||
|
} catch (error: any) { |
||||||
|
let notificationObj = null |
||||||
|
if (typeof error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error || 'Failed to publish video', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else if (typeof error?.error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.error || 'Failed to publish video', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.message || 'Failed to publish video', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} |
||||||
|
if (!notificationObj) return |
||||||
|
dispatch(setNotification(notificationObj)) |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
return { |
||||||
|
publishVideo |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
import { Box } from "@mui/material"; |
||||||
|
|
||||||
|
export const Spacer = ({ height }: any) => { |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
height: height, |
||||||
|
display: 'flex', |
||||||
|
flexShrink: 0 |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,45 @@ |
|||||||
|
import { useMemo } from "react"; |
||||||
|
import DOMPurify from "dompurify"; |
||||||
|
import "react-quill/dist/quill.snow.css"; |
||||||
|
import "react-quill/dist/quill.core.css"; |
||||||
|
import "react-quill/dist/quill.bubble.css"; |
||||||
|
import { convertQortalLinks } from "./utils"; |
||||||
|
import { Box, styled } from "@mui/material"; |
||||||
|
|
||||||
|
|
||||||
|
const CrowdfundInlineContent = styled(Box)(({ theme }) => ({ |
||||||
|
display: "flex", |
||||||
|
fontFamily: "Mulish", |
||||||
|
fontSize: "19px", |
||||||
|
fontWeight: 400, |
||||||
|
letterSpacing: 0, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
width: '100%' |
||||||
|
})); |
||||||
|
|
||||||
|
export const DisplayHtml = ({ html, textColor }: any) => { |
||||||
|
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-display" |
||||||
|
style={{ |
||||||
|
color: textColor || 'white', |
||||||
|
fontWeight: 400, |
||||||
|
fontSize: '16px' |
||||||
|
}} |
||||||
|
dangerouslySetInnerHTML={{ __html: cleanContent }} |
||||||
|
/> |
||||||
|
</CrowdfundInlineContent> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,39 @@ |
|||||||
|
import React from "react"; |
||||||
|
import ReactQuill, { Quill } from "react-quill"; |
||||||
|
import "react-quill/dist/quill.snow.css"; |
||||||
|
import ImageResize from "quill-image-resize-module-react"; |
||||||
|
import './texteditor.css' |
||||||
|
Quill.register("modules/imageResize", ImageResize); |
||||||
|
|
||||||
|
const modules = { |
||||||
|
imageResize: { |
||||||
|
parchment: Quill.import("parchment"), |
||||||
|
modules: ["Resize", "DisplaySize"], |
||||||
|
}, |
||||||
|
toolbar: [ |
||||||
|
["bold", "italic", "underline", "strike"], // styled text
|
||||||
|
["blockquote", "code-block"], // blocks
|
||||||
|
[{ header: 1 }, { header: 2 }], // custom button values
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }], // lists
|
||||||
|
[{ script: "sub" }, { script: "super" }], // superscript/subscript
|
||||||
|
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
|
||||||
|
[{ direction: "rtl" }], // text direction
|
||||||
|
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
|
||||||
|
[{ header: [1, 2, 3, 4, 5, 6, false] }], // custom button values
|
||||||
|
[{ color: [] }, { background: [] }], // dropdown with defaults
|
||||||
|
[{ font: [] }], // font family
|
||||||
|
[{ align: [] }], // text align
|
||||||
|
["clean"], // remove formatting
|
||||||
|
// ["image"], // image
|
||||||
|
], |
||||||
|
}; |
||||||
|
export const TextEditor = ({ inlineContent, setInlineContent }: any) => { |
||||||
|
return ( |
||||||
|
<ReactQuill |
||||||
|
theme="snow" |
||||||
|
value={inlineContent} |
||||||
|
onChange={setInlineContent} |
||||||
|
modules={modules} |
||||||
|
/> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,71 @@ |
|||||||
|
.ql-editor { |
||||||
|
min-height: 200px; |
||||||
|
width: 100%; |
||||||
|
color: black; |
||||||
|
font-size: 16px; |
||||||
|
font-family: Roboto; |
||||||
|
max-height: 225px; |
||||||
|
overflow-y: scroll; |
||||||
|
padding: 0px !important; |
||||||
|
} |
||||||
|
|
||||||
|
.ql-editor::-webkit-scrollbar-track { |
||||||
|
background-color: transparent; |
||||||
|
cursor: default; |
||||||
|
} |
||||||
|
.ql-editor::-webkit-scrollbar-track:hover { |
||||||
|
background-color: transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.ql-editor::-webkit-scrollbar { |
||||||
|
width: 16px; |
||||||
|
height: 10px; |
||||||
|
background-color: rgba(229, 229, 229, 0.70); |
||||||
|
} |
||||||
|
|
||||||
|
.ql-editor::-webkit-scrollbar-thumb { |
||||||
|
background-color: #B0B0B0; |
||||||
|
border-radius: 8px; |
||||||
|
background-clip: content-box; |
||||||
|
border: 4px solid transparent; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
.ql-editor img { |
||||||
|
cursor: default; |
||||||
|
} |
||||||
|
|
||||||
|
.ql-editor-display { |
||||||
|
min-height: 20px; |
||||||
|
width: 100%; |
||||||
|
color: black; |
||||||
|
font-size: 16px; |
||||||
|
font-family: Roboto; |
||||||
|
padding: 0px !important; |
||||||
|
} |
||||||
|
|
||||||
|
.ql-editor-display img { |
||||||
|
cursor: default; |
||||||
|
} |
||||||
|
|
||||||
|
.ql-container { |
||||||
|
font-size: 16px |
||||||
|
} |
||||||
|
|
||||||
|
.ql-toolbar .ql-stroke { |
||||||
|
fill: none !important; |
||||||
|
stroke: black !important; |
||||||
|
} |
||||||
|
|
||||||
|
.ql-toolbar .ql-fill { |
||||||
|
fill: black !important; |
||||||
|
stroke: none !important; |
||||||
|
} |
||||||
|
|
||||||
|
.ql-toolbar .ql-picker { |
||||||
|
color: black !important; |
||||||
|
} |
||||||
|
|
||||||
|
.ql-toolbar .ql-picker-options { |
||||||
|
background-color: white !important; |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
export function convertQortalLinks(inputHtml: string) { |
||||||
|
// Regular expression to match 'qortal://...' URLs.
|
||||||
|
// This will stop at the first whitespace, comma, or HTML tag
|
||||||
|
var regex = /(qortal:\/\/[^\s,<]+)/g; |
||||||
|
|
||||||
|
// Replace matches in inputHtml with formatted anchor tag
|
||||||
|
var outputHtml = inputHtml.replace(regex, function (match) { |
||||||
|
return `<a href="${match}" class="qortal-link">${match}</a>`; |
||||||
|
}); |
||||||
|
|
||||||
|
return outputHtml; |
||||||
|
} |
||||||
|
|
||||||
|
export function extractTextFromHTML(htmlString: any, length = 150) { |
||||||
|
// Create a temporary DOM element
|
||||||
|
const tempDiv = document.createElement("div"); |
||||||
|
// Replace br tags and block-level tags with a space before setting the HTML content
|
||||||
|
const htmlWithSpaces = htmlString.replace(/<\/?(br|p|div|h[1-6]|ul|ol|li|blockquote)[^>]*>/gi, ' '); |
||||||
|
tempDiv.innerHTML = htmlWithSpaces; |
||||||
|
// Extract the text content
|
||||||
|
let text = tempDiv.textContent || tempDiv.innerText || ""; |
||||||
|
// Replace multiple spaces with a single space and trim
|
||||||
|
text = text.replace(/\s+/g, ' ').trim(); |
||||||
|
// Slice the text to the desired length
|
||||||
|
return text.slice(0, length); |
||||||
|
} |
@ -0,0 +1,289 @@ |
|||||||
|
import { |
||||||
|
Avatar, |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
Dialog, |
||||||
|
DialogActions, |
||||||
|
DialogContent, |
||||||
|
DialogTitle, |
||||||
|
Input, |
||||||
|
InputAdornment, |
||||||
|
InputLabel, |
||||||
|
Tooltip, |
||||||
|
Typography, |
||||||
|
useTheme |
||||||
|
} from '@mui/material' |
||||||
|
import React, { useCallback, useState } from 'react' |
||||||
|
import { CardContentContainerComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { StyledCardHeaderComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { StyledCardColComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { AuthorTextComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import { StyledCardContentComment } from '../../../pages/BlogList/PostPreview-styles' |
||||||
|
import MenuItem from '@mui/material/MenuItem' |
||||||
|
import Select, { SelectChangeEvent } from '@mui/material/Select' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../../state/store' |
||||||
|
import Portal from '../Portal' |
||||||
|
import MonetizationOnIcon from '@mui/icons-material/MonetizationOn' |
||||||
|
interface TippingProps { |
||||||
|
name: string |
||||||
|
onSubmit: () => void |
||||||
|
onClose: () => void |
||||||
|
onlyIcon?: boolean |
||||||
|
} |
||||||
|
import QORT from '../../../assets/img/qort.png' |
||||||
|
import ARRR from '../../../assets/img/arrr.png' |
||||||
|
import LTC from '../../../assets/img/ltc.png' |
||||||
|
import BTC from '../../../assets/img/btc.png' |
||||||
|
import DOGE from '../../../assets/img/doge.png' |
||||||
|
import DGB from '../../../assets/img/dgb.png' |
||||||
|
import RVN from '../../../assets/img/rvn.png' |
||||||
|
import { setNotification } from '../../../state/features/notificationsSlice' |
||||||
|
const coins = [ |
||||||
|
{ value: 'QORT', label: 'QORT' }, |
||||||
|
{ value: 'ARRR', label: 'ARRR' }, |
||||||
|
{ value: 'LTC', label: 'LTC' }, |
||||||
|
{ value: 'BTC', label: 'BTC' }, |
||||||
|
{ value: 'DOGE', label: 'DOGE' }, |
||||||
|
{ value: 'DGB', label: 'DGB' }, |
||||||
|
{ value: 'RVN', label: 'RVN' } |
||||||
|
] |
||||||
|
export const Tipping = ({ |
||||||
|
onSubmit, |
||||||
|
onClose, |
||||||
|
name, |
||||||
|
onlyIcon |
||||||
|
}: TippingProps) => { |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false) |
||||||
|
const [selectedCoin, setSelectedCoint] = useState<any>(coins[0]) |
||||||
|
const [amount, setAmount] = useState<number>(0) |
||||||
|
|
||||||
|
const dispatch = useDispatch() |
||||||
|
|
||||||
|
const resetValues = () => { |
||||||
|
setSelectedCoint(coins[0]) |
||||||
|
setAmount(0) |
||||||
|
setIsOpen(false) |
||||||
|
} |
||||||
|
|
||||||
|
const sendCoin = async () => { |
||||||
|
try { |
||||||
|
if (!name) return |
||||||
|
let res = await qortalRequest({ |
||||||
|
action: 'GET_NAME_DATA', |
||||||
|
name: name |
||||||
|
}) |
||||||
|
const address = res.owner |
||||||
|
if (!address || !amount || !selectedCoin?.value) return |
||||||
|
|
||||||
|
if (isNaN(amount)) return |
||||||
|
await qortalRequest({ |
||||||
|
action: 'SEND_COIN', |
||||||
|
coin: selectedCoin.value, |
||||||
|
destinationAddress: address, |
||||||
|
amount: amount |
||||||
|
}) |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: 'Coin successfully sent', |
||||||
|
alertType: 'success' |
||||||
|
}) |
||||||
|
) |
||||||
|
resetValues() |
||||||
|
onSubmit() |
||||||
|
} catch (error: any) { |
||||||
|
let notificationObj = null |
||||||
|
if (typeof error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error || 'Failed to send coin', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else if (typeof error?.error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.error || 'Failed to send coin', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.message || 'Failed to send coin', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} |
||||||
|
if (!notificationObj) return |
||||||
|
dispatch(setNotification(notificationObj)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||||
|
const optionId = event.target.value |
||||||
|
const selectedOption = coins.find( |
||||||
|
(option: any) => option.value === optionId |
||||||
|
) |
||||||
|
setSelectedCoint(selectedOption || null) |
||||||
|
} |
||||||
|
|
||||||
|
const getLogo = (coin: string) => { |
||||||
|
switch (coin) { |
||||||
|
case 'QORT': |
||||||
|
return QORT |
||||||
|
case 'ARRR': |
||||||
|
return ARRR |
||||||
|
case 'LTC': |
||||||
|
return LTC |
||||||
|
case 'BTC': |
||||||
|
return BTC |
||||||
|
case 'DOGE': |
||||||
|
return DOGE |
||||||
|
case 'DGB': |
||||||
|
return DGB |
||||||
|
case 'RVN': |
||||||
|
return RVN |
||||||
|
default: |
||||||
|
'' |
||||||
|
// code block
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'relative', |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: 1 |
||||||
|
}} |
||||||
|
> |
||||||
|
<Tooltip title={`Support ${name}`} arrow> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'relative', |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: 1, |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
onClick={() => setIsOpen((prev) => !prev)} |
||||||
|
> |
||||||
|
<MonetizationOnIcon |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
color: 'gold' |
||||||
|
}} |
||||||
|
></MonetizationOnIcon> |
||||||
|
{!onlyIcon && ( |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontSize: '14px' |
||||||
|
}} |
||||||
|
> |
||||||
|
Support |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Tooltip> |
||||||
|
{isOpen && ( |
||||||
|
<Portal> |
||||||
|
<Dialog |
||||||
|
open={isOpen} |
||||||
|
onClose={() => setIsOpen(false)} |
||||||
|
aria-labelledby="alert-dialog-title" |
||||||
|
aria-describedby="alert-dialog-description" |
||||||
|
> |
||||||
|
<DialogTitle id="alert-dialog-title"></DialogTitle> |
||||||
|
<DialogContent> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '300px', |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box> |
||||||
|
<InputLabel htmlFor="standard-adornment-name">To</InputLabel> |
||||||
|
<Input id="standard-adornment-name" value={name} disabled /> |
||||||
|
<InputLabel htmlFor="standard-adornment-coin"> |
||||||
|
Coin |
||||||
|
</InputLabel> |
||||||
|
<Select |
||||||
|
id="standard-adornment-coin" |
||||||
|
sx={{ width: '100%' }} |
||||||
|
defaultValue="" |
||||||
|
displayEmpty |
||||||
|
value={selectedCoin?.value || ''} |
||||||
|
onChange={handleOptionChange} |
||||||
|
renderValue={(value) => { |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
gap: 1, |
||||||
|
justifyContent: 'center', |
||||||
|
alignItems: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
{value && ( |
||||||
|
<img |
||||||
|
style={{ |
||||||
|
height: '25px', |
||||||
|
width: '25px' |
||||||
|
}} |
||||||
|
src={getLogo(value)} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{value} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
}} |
||||||
|
> |
||||||
|
{coins.map((option) => ( |
||||||
|
<MenuItem key={option.value} value={option.value}> |
||||||
|
{option.value} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
<InputLabel htmlFor="standard-adornment-amount"> |
||||||
|
Amount |
||||||
|
</InputLabel> |
||||||
|
<Input |
||||||
|
id="standard-adornment-amount" |
||||||
|
type="number" |
||||||
|
value={amount} |
||||||
|
onChange={(e) => setAmount(+e.target.value)} |
||||||
|
startAdornment={ |
||||||
|
<InputAdornment position="start"> |
||||||
|
<img |
||||||
|
style={{ |
||||||
|
height: '15px', |
||||||
|
width: '15px' |
||||||
|
}} |
||||||
|
src={getLogo(selectedCoin?.value || '')} |
||||||
|
/> |
||||||
|
</InputAdornment> |
||||||
|
} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<Button |
||||||
|
variant="contained" |
||||||
|
onClick={() => { |
||||||
|
setIsOpen(false) |
||||||
|
resetValues() |
||||||
|
onClose() |
||||||
|
}} |
||||||
|
> |
||||||
|
Close |
||||||
|
</Button> |
||||||
|
<Button variant="contained" onClick={sendCoin}> |
||||||
|
Send Coin |
||||||
|
</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
</Portal> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,55 @@ |
|||||||
|
import { styled } from '@mui/system' |
||||||
|
import { |
||||||
|
AppBar, |
||||||
|
Toolbar, |
||||||
|
Typography, |
||||||
|
Menu, |
||||||
|
MenuItem |
||||||
|
} from '@mui/material' |
||||||
|
|
||||||
|
export const CustomAppBar = styled(AppBar)(({ theme }) => ({ |
||||||
|
backgroundColor: theme.palette.mode === "light" ? theme.palette.background.default : "#19191b", |
||||||
|
color: theme.palette.text.primary |
||||||
|
})) |
||||||
|
|
||||||
|
export const CustomToolbar = styled(Toolbar)({ |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'space-between', |
||||||
|
alignItems: 'center' |
||||||
|
}) |
||||||
|
|
||||||
|
export const CustomTitle = styled(Typography)(({ theme }) => ({ |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Raleway, Arial', |
||||||
|
fontSize: '18px' |
||||||
|
})) |
||||||
|
|
||||||
|
export const StyledAppBar = styled(AppBar)(({ theme }) => ({ |
||||||
|
backgroundColor: theme.palette.primary.main |
||||||
|
})) |
||||||
|
|
||||||
|
export const StyledToolbar = styled(Toolbar)(({ theme }) => ({ |
||||||
|
justifyContent: 'space-between' |
||||||
|
})) |
||||||
|
|
||||||
|
export const StyledMenu = styled(Menu)(({ theme }) => ({ |
||||||
|
marginTop: theme.spacing(2), |
||||||
|
overflow: 'hidden', |
||||||
|
padding: 0, |
||||||
|
})) |
||||||
|
|
||||||
|
export const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ |
||||||
|
width: '100%', |
||||||
|
whiteSpace: 'nowrap', |
||||||
|
maxWidth: '300px', |
||||||
|
overflow: 'hidden', |
||||||
|
textOverflow: 'ellipsis', |
||||||
|
fontSize: "16px", |
||||||
|
fontFamily: "Arial", |
||||||
|
padding: "12px 10px", |
||||||
|
transition: "all 0.3s ease-in-out", |
||||||
|
"&:hover": { |
||||||
|
cursor: "pointer", |
||||||
|
filter: "brightness(1.1)" |
||||||
|
} |
||||||
|
})) |
@ -0,0 +1,135 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { |
||||||
|
AppBar, |
||||||
|
Toolbar, |
||||||
|
Typography, |
||||||
|
IconButton, |
||||||
|
Menu, |
||||||
|
MenuItem, |
||||||
|
Box, |
||||||
|
Button |
||||||
|
} from '@mui/material' |
||||||
|
|
||||||
|
import { |
||||||
|
CustomAppBar, |
||||||
|
CustomToolbar, |
||||||
|
CustomTitle, |
||||||
|
StyledAppBar, |
||||||
|
StyledToolbar, |
||||||
|
StyledMenu, |
||||||
|
StyledMenuItem |
||||||
|
} from './UserNavbar-styles' |
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom' |
||||||
|
import { Menu as MenuIcon } from '@mui/icons-material' |
||||||
|
import { removePrefix } from '../../../utils/blogIdformats' |
||||||
|
import { QblogLogoContainer } from '../../layout/Navbar/Navbar-styles' |
||||||
|
import QblogLogo from '../../../assets/img/qBlogLogo.png' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
title: string |
||||||
|
menuItems: any[] |
||||||
|
name: string |
||||||
|
blogId: string |
||||||
|
} |
||||||
|
|
||||||
|
export const UserNavbar: React.FC<Props> = ({ |
||||||
|
title, |
||||||
|
menuItems, |
||||||
|
name, |
||||||
|
blogId |
||||||
|
}) => { |
||||||
|
const navigate = useNavigate() |
||||||
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null) |
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { |
||||||
|
setAnchorEl(event.currentTarget) |
||||||
|
} |
||||||
|
|
||||||
|
const handleClose = () => { |
||||||
|
setAnchorEl(null) |
||||||
|
} |
||||||
|
|
||||||
|
const goToPost = (item: any) => { |
||||||
|
if (!name) return |
||||||
|
const { postId } = item |
||||||
|
|
||||||
|
const str = postId |
||||||
|
const arr = str.split('-post-') |
||||||
|
const str1 = arr[0] |
||||||
|
const str2 = arr[1] |
||||||
|
const blogId = removePrefix(str1) |
||||||
|
navigate(`/${name}/${blogId}/${str2}`) |
||||||
|
} |
||||||
|
|
||||||
|
const handleAction = (action: () => void) => { |
||||||
|
handleClose() |
||||||
|
setTimeout(() => { |
||||||
|
action() |
||||||
|
}, 100) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<CustomAppBar position="sticky"> |
||||||
|
<CustomToolbar variant="dense"> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<IconButton |
||||||
|
edge="start" |
||||||
|
color="inherit" |
||||||
|
aria-label="menu" |
||||||
|
onClick={handleClick} |
||||||
|
> |
||||||
|
<MenuIcon /> |
||||||
|
</IconButton> |
||||||
|
<CustomTitle |
||||||
|
variant="h6" |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
marginLeft: '10px' |
||||||
|
}} |
||||||
|
onClick={() => { |
||||||
|
navigate(`/${name}/${blogId}`) |
||||||
|
}} |
||||||
|
> |
||||||
|
{title} |
||||||
|
</CustomTitle> |
||||||
|
</Box> |
||||||
|
<StyledMenu |
||||||
|
anchorEl={anchorEl} |
||||||
|
open={Boolean(anchorEl)} |
||||||
|
onClose={handleClose} |
||||||
|
PaperProps={{ style: { width: '250px' } }} |
||||||
|
> |
||||||
|
{menuItems.map((item, index) => ( |
||||||
|
<StyledMenuItem |
||||||
|
key={index} |
||||||
|
onClick={() => handleAction(() => goToPost(item))} |
||||||
|
> |
||||||
|
{item.name} |
||||||
|
</StyledMenuItem> |
||||||
|
))} |
||||||
|
</StyledMenu> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<QblogLogoContainer |
||||||
|
src={QblogLogo} |
||||||
|
alt="Qblog Logo" |
||||||
|
onClick={() => { |
||||||
|
navigate(`/`) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</CustomToolbar> |
||||||
|
</CustomAppBar> |
||||||
|
) |
||||||
|
} |