@ -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,61 @@ |
|||||||
|
{ |
||||||
|
"name": "q-mail", |
||||||
|
"private": true, |
||||||
|
"version": "0.0.0", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "tsc && vite build", |
||||||
|
"preview": "vite preview" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@emotion/react": "^11.10.6", |
||||||
|
"@emotion/styled": "^11.10.6", |
||||||
|
"@mui/icons-material": "^5.11.11", |
||||||
|
"@mui/material": "^5.11.13", |
||||||
|
"@reduxjs/toolkit": "^1.9.3", |
||||||
|
"@tiptap/core": "^2.0.4", |
||||||
|
"@tiptap/extension-highlight": "^2.0.4", |
||||||
|
"@tiptap/extension-underline": "^2.0.4", |
||||||
|
"@tiptap/starter-kit": "^2.0.4", |
||||||
|
"@types/react-grid-layout": "^1.3.2", |
||||||
|
"axios": "^1.3.4", |
||||||
|
"compressorjs": "^1.2.1", |
||||||
|
"dompurify": "^3.0.3", |
||||||
|
"flexlayout-react": "^0.7.9", |
||||||
|
"localforage": "^1.10.0", |
||||||
|
"moment": "^2.29.4", |
||||||
|
"philliplm-react-modern-audio-player": "^1.4.6", |
||||||
|
"react": "^18.2.0", |
||||||
|
"react-dnd": "^16.0.1", |
||||||
|
"react-dnd-html5-backend": "^16.0.1", |
||||||
|
"react-dom": "^18.2.0", |
||||||
|
"react-dropzone": "^14.2.3", |
||||||
|
"react-grid-layout": "^1.3.4", |
||||||
|
"react-intersection-observer": "^9.4.3", |
||||||
|
"react-joyride": "^2.5.4", |
||||||
|
"react-masonry-css": "^1.0.16", |
||||||
|
"react-redux": "^8.0.5", |
||||||
|
"react-resize-detector": "^8.0.4", |
||||||
|
"react-router-dom": "^6.9.0", |
||||||
|
"react-toastify": "^9.1.2", |
||||||
|
"react-virtuoso": "^4.3.3", |
||||||
|
"short-unique-id": "^4.4.4", |
||||||
|
"slate": "^0.91.4", |
||||||
|
"slate-history": "^0.86.0", |
||||||
|
"slate-react": "^0.91.11" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@mui/types": "^7.2.3", |
||||||
|
"@types/dompurify": "^3.0.2", |
||||||
|
"@types/react": "^18.0.28", |
||||||
|
"@types/react-dom": "^18.0.11", |
||||||
|
"@vitejs/plugin-legacy": "^4.0.3", |
||||||
|
"@vitejs/plugin-react-swc": "^3.2.0", |
||||||
|
"core-js": "^3.30.2", |
||||||
|
"prettier": "^2.8.6", |
||||||
|
"typescript": "^4.9.3", |
||||||
|
"vite": "^4.2.0", |
||||||
|
"worker-loader": "^3.0.8" |
||||||
|
} |
||||||
|
} |
@ -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,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,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,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,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,5 @@ |
|||||||
|
export interface SVGProps { |
||||||
|
color: string |
||||||
|
height: string |
||||||
|
width: string |
||||||
|
} |
@ -0,0 +1,218 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import { styled, useTheme } from '@mui/material/styles' |
||||||
|
import Box from '@mui/material/Box' |
||||||
|
import Typography from '@mui/material/Typography' |
||||||
|
|
||||||
|
import AudiotrackIcon from '@mui/icons-material/Audiotrack' |
||||||
|
import { MyContext } from '../wrappers/DownloadWrapper' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../state/store' |
||||||
|
import { CircularProgress } from '@mui/material' |
||||||
|
import { |
||||||
|
setCurrAudio, |
||||||
|
setShowingAudioPlayer |
||||||
|
} from '../state/features/globalSlice' |
||||||
|
|
||||||
|
const Widget = styled('div')(({ theme }) => ({ |
||||||
|
padding: 16, |
||||||
|
borderRadius: 16, |
||||||
|
maxWidth: '100%', |
||||||
|
position: 'relative', |
||||||
|
zIndex: 1, |
||||||
|
// backgroundColor:
|
||||||
|
// theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.4)',
|
||||||
|
backdropFilter: 'blur(40px)', |
||||||
|
background: 'skyblue', |
||||||
|
transition: '0.2s all', |
||||||
|
'&:hover': { |
||||||
|
opacity: 0.75 |
||||||
|
} |
||||||
|
})) |
||||||
|
|
||||||
|
const CoverImage = styled('div')({ |
||||||
|
width: 100, |
||||||
|
height: 100, |
||||||
|
objectFit: 'cover', |
||||||
|
overflow: 'hidden', |
||||||
|
flexShrink: 0, |
||||||
|
borderRadius: 8, |
||||||
|
backgroundColor: 'rgba(0,0,0,0.08)', |
||||||
|
'& > img': { |
||||||
|
width: '100%' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const TinyText = styled(Typography)({ |
||||||
|
fontSize: '0.75rem', |
||||||
|
opacity: 0.38, |
||||||
|
fontWeight: 500, |
||||||
|
letterSpacing: 0.2 |
||||||
|
}) |
||||||
|
|
||||||
|
interface IAudioElement { |
||||||
|
onClick: () => void |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
author: string |
||||||
|
audioInfo?: any |
||||||
|
postId?: string |
||||||
|
user?: string |
||||||
|
} |
||||||
|
|
||||||
|
export default function AudioElement({ |
||||||
|
onClick, |
||||||
|
title, |
||||||
|
description, |
||||||
|
author, |
||||||
|
audioInfo, |
||||||
|
postId, |
||||||
|
user |
||||||
|
}: IAudioElement) { |
||||||
|
const { downloadVideo } = React.useContext(MyContext) |
||||||
|
const [isLoading, setIsLoading] = React.useState<boolean>(false) |
||||||
|
const { downloads } = useSelector((state: RootState) => state.global) |
||||||
|
const dispatch = useDispatch() |
||||||
|
const download = React.useMemo(() => { |
||||||
|
if (!downloads || !audioInfo?.identifier) return {} |
||||||
|
const findDownload = downloads[audioInfo?.identifier] |
||||||
|
|
||||||
|
if (!findDownload) return {} |
||||||
|
return findDownload |
||||||
|
}, [downloads, audioInfo]) |
||||||
|
|
||||||
|
const resourceStatus = React.useMemo(() => { |
||||||
|
return download?.status || {} |
||||||
|
}, [download]) |
||||||
|
const handlePlay = () => { |
||||||
|
if (!postId) return |
||||||
|
const { name, service, identifier } = audioInfo |
||||||
|
|
||||||
|
if (download && resourceStatus?.status === 'READY') { |
||||||
|
dispatch(setShowingAudioPlayer(true)) |
||||||
|
dispatch(setCurrAudio(identifier)) |
||||||
|
return |
||||||
|
} |
||||||
|
setIsLoading(true) |
||||||
|
downloadVideo({ |
||||||
|
name, |
||||||
|
service, |
||||||
|
identifier, |
||||||
|
blogPost: { |
||||||
|
postId, |
||||||
|
user, |
||||||
|
audioTitle: title, |
||||||
|
audioDescription: description, |
||||||
|
audioAuthor: author |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(setCurrAudio(identifier)) |
||||||
|
dispatch(setShowingAudioPlayer(true)) |
||||||
|
} |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
if (resourceStatus?.status === 'READY') { |
||||||
|
setIsLoading(false) |
||||||
|
} |
||||||
|
}, [resourceStatus]) |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
onClick={handlePlay} |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
overflow: 'hidden', |
||||||
|
position: 'relative', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Widget> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}> |
||||||
|
<CoverImage> |
||||||
|
<AudiotrackIcon |
||||||
|
sx={{ |
||||||
|
width: '90%', |
||||||
|
height: 'auto' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</CoverImage> |
||||||
|
<Box sx={{ ml: 1.5, minWidth: 0 }}> |
||||||
|
<Typography |
||||||
|
variant="caption" |
||||||
|
color="text.secondary" |
||||||
|
fontWeight={500} |
||||||
|
> |
||||||
|
{author} |
||||||
|
</Typography> |
||||||
|
<Typography noWrap> |
||||||
|
<b>{title}</b> |
||||||
|
</Typography> |
||||||
|
<Typography noWrap letterSpacing={-0.25}> |
||||||
|
{description} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
{((resourceStatus.status && resourceStatus?.status !== 'READY') || |
||||||
|
isLoading) && ( |
||||||
|
<Box |
||||||
|
position="absolute" |
||||||
|
top={0} |
||||||
|
left={0} |
||||||
|
right={0} |
||||||
|
bottom={0} |
||||||
|
display="flex" |
||||||
|
justifyContent="center" |
||||||
|
alignItems="center" |
||||||
|
zIndex={4999} |
||||||
|
bgcolor="rgba(0, 0, 0, 0.6)" |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: '10px', |
||||||
|
padding: '16px', |
||||||
|
borderRadius: '16px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CircularProgress color="secondary" /> |
||||||
|
{resourceStatus && ( |
||||||
|
<Typography |
||||||
|
variant="subtitle2" |
||||||
|
component="div" |
||||||
|
sx={{ |
||||||
|
color: 'white', |
||||||
|
fontSize: '14px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{resourceStatus?.status === 'REFETCHING' ? ( |
||||||
|
<> |
||||||
|
<> |
||||||
|
{( |
||||||
|
(resourceStatus?.localChunkCount / |
||||||
|
resourceStatus?.totalChunkCount) * |
||||||
|
100 |
||||||
|
)?.toFixed(0)} |
||||||
|
% |
||||||
|
</> |
||||||
|
|
||||||
|
<> Refetching in 2 minutes</> |
||||||
|
</> |
||||||
|
) : resourceStatus?.status === 'DOWNLOADED' ? ( |
||||||
|
<>Download Completed: building audio...</> |
||||||
|
) : resourceStatus?.status !== 'READY' ? ( |
||||||
|
<> |
||||||
|
{( |
||||||
|
(resourceStatus?.localChunkCount / |
||||||
|
resourceStatus?.totalChunkCount) * |
||||||
|
100 |
||||||
|
)?.toFixed(0)} |
||||||
|
% |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<>Download Completed: fetching audio...</> |
||||||
|
)} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
</Widget> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
@ -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,503 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import { styled, useTheme } from '@mui/material/styles' |
||||||
|
import Box from '@mui/material/Box' |
||||||
|
import Typography from '@mui/material/Typography' |
||||||
|
import AudiotrackIcon from '@mui/icons-material/Audiotrack' |
||||||
|
import { MyContext } from '../wrappers/DownloadWrapper' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../state/store' |
||||||
|
import { CircularProgress } from '@mui/material' |
||||||
|
import AttachFileIcon from '@mui/icons-material/AttachFile' |
||||||
|
import { |
||||||
|
setCurrAudio, |
||||||
|
setShowingAudioPlayer |
||||||
|
} from '../state/features/globalSlice' |
||||||
|
import { |
||||||
|
base64ToUint8Array, |
||||||
|
objectToUint8ArrayFromResponse |
||||||
|
} from '../utils/toBase64' |
||||||
|
import { setNotification } from '../state/features/notificationsSlice' |
||||||
|
|
||||||
|
const Widget = styled('div')(({ theme }) => ({ |
||||||
|
padding: 8, |
||||||
|
borderRadius: 10, |
||||||
|
maxWidth: 350, |
||||||
|
position: 'relative', |
||||||
|
zIndex: 1, |
||||||
|
// backgroundColor:
|
||||||
|
// theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.4)',
|
||||||
|
backdropFilter: 'blur(40px)', |
||||||
|
background: 'skyblue', |
||||||
|
transition: '0.2s all', |
||||||
|
'&:hover': { |
||||||
|
opacity: 0.75 |
||||||
|
} |
||||||
|
})) |
||||||
|
|
||||||
|
const CoverImage = styled('div')({ |
||||||
|
width: 40, |
||||||
|
height: 40, |
||||||
|
objectFit: 'cover', |
||||||
|
overflow: 'hidden', |
||||||
|
flexShrink: 0, |
||||||
|
borderRadius: 8, |
||||||
|
backgroundColor: 'rgba(0,0,0,0.08)', |
||||||
|
'& > img': { |
||||||
|
width: '100%' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const TinyText = styled(Typography)({ |
||||||
|
fontSize: '0.75rem', |
||||||
|
opacity: 0.38, |
||||||
|
fontWeight: 500, |
||||||
|
letterSpacing: 0.2 |
||||||
|
}) |
||||||
|
|
||||||
|
interface IAudioElement { |
||||||
|
title: string |
||||||
|
description?: string |
||||||
|
author?: string |
||||||
|
fileInfo?: any |
||||||
|
postId?: string |
||||||
|
user?: string |
||||||
|
children?: React.ReactNode |
||||||
|
mimeType?: string |
||||||
|
disable?: boolean |
||||||
|
mode?: string |
||||||
|
otherUser?: string |
||||||
|
} |
||||||
|
|
||||||
|
interface CustomWindow extends Window { |
||||||
|
showSaveFilePicker: any // Replace 'any' with the appropriate type if you know it
|
||||||
|
} |
||||||
|
|
||||||
|
const customWindow = window as unknown as CustomWindow |
||||||
|
|
||||||
|
export default function FileElement({ |
||||||
|
title, |
||||||
|
description, |
||||||
|
author, |
||||||
|
fileInfo, |
||||||
|
postId = '', |
||||||
|
user, |
||||||
|
children, |
||||||
|
mimeType, |
||||||
|
disable, |
||||||
|
mode, |
||||||
|
otherUser |
||||||
|
}: IAudioElement) { |
||||||
|
const { downloadVideo } = React.useContext(MyContext) |
||||||
|
const [isLoading, setIsLoading] = React.useState<boolean>(false) |
||||||
|
const [fileProperties, setFileProperties] = React.useState<any>(null) |
||||||
|
const [downloadLoader, setDownloadLoader] = React.useState<any>(false) |
||||||
|
|
||||||
|
const [pdfSrc, setPdfSrc] = React.useState('') |
||||||
|
const { downloads } = useSelector((state: RootState) => state.global) |
||||||
|
const { user: username } = useSelector((state: RootState) => state.auth) |
||||||
|
const hasCommencedDownload = React.useRef(false) |
||||||
|
const dispatch = useDispatch() |
||||||
|
const download = React.useMemo(() => { |
||||||
|
if (!downloads || !fileInfo?.identifier) return {} |
||||||
|
const findDownload = downloads[fileInfo?.identifier] |
||||||
|
|
||||||
|
if (!findDownload) return {} |
||||||
|
return findDownload |
||||||
|
}, [downloads, fileInfo]) |
||||||
|
|
||||||
|
const resourceStatus = React.useMemo(() => { |
||||||
|
return download?.status || {} |
||||||
|
}, [download]) |
||||||
|
const saveFileToDisk = async (blob: any, fileName: any) => { |
||||||
|
try { |
||||||
|
const fileHandle = await customWindow.showSaveFilePicker({ |
||||||
|
suggestedName: fileName, |
||||||
|
types: [ |
||||||
|
{ |
||||||
|
description: 'File' |
||||||
|
} |
||||||
|
] |
||||||
|
}) |
||||||
|
const writeFile = async (fileHandle: any, contents: any) => { |
||||||
|
const writable = await fileHandle.createWritable() |
||||||
|
await writable.write(contents) |
||||||
|
await writable.close() |
||||||
|
} |
||||||
|
writeFile(fileHandle, blob).then(() => console.log('FILE SAVED')) |
||||||
|
} catch (error) { |
||||||
|
console.log(error) |
||||||
|
} |
||||||
|
} |
||||||
|
const handlePlay = async () => { |
||||||
|
if (disable) return |
||||||
|
hasCommencedDownload.current = true |
||||||
|
if ( |
||||||
|
resourceStatus?.status === 'READY' && |
||||||
|
download?.url && |
||||||
|
download?.blogPost?.filename |
||||||
|
) { |
||||||
|
if (downloadLoader) return |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: 'Saving file... please wait', |
||||||
|
alertType: 'info' |
||||||
|
}) |
||||||
|
) |
||||||
|
setDownloadLoader(true) |
||||||
|
try { |
||||||
|
const { name, service, identifier } = fileInfo |
||||||
|
if (mode === 'mail') { |
||||||
|
let res = await qortalRequest({ |
||||||
|
action: 'FETCH_QDN_RESOURCE', |
||||||
|
name: name, |
||||||
|
service: service, |
||||||
|
identifier: identifier, |
||||||
|
encoding: 'base64' |
||||||
|
}) |
||||||
|
// const toUnit8Array = base64ToUint8Array(res)
|
||||||
|
const resName = await qortalRequest({ |
||||||
|
action: 'GET_NAME_DATA', |
||||||
|
// change this
|
||||||
|
name: otherUser |
||||||
|
}) |
||||||
|
if (!resName?.owner) |
||||||
|
throw new Error('Unable to locate details to decrypt file') |
||||||
|
|
||||||
|
const recipientAddress = resName.owner |
||||||
|
const resAddress = await qortalRequest({ |
||||||
|
action: 'GET_ACCOUNT_DATA', |
||||||
|
address: recipientAddress |
||||||
|
}) |
||||||
|
if (!resAddress?.publicKey) |
||||||
|
throw new Error('Unable to locate details to decrypt file') |
||||||
|
const recipientPublicKey = resAddress.publicKey |
||||||
|
let requestEncryptBody: any = { |
||||||
|
action: 'DECRYPT_DATA', |
||||||
|
encryptedData: res, |
||||||
|
publicKey: recipientPublicKey |
||||||
|
} |
||||||
|
const resDecrypt = await qortalRequest(requestEncryptBody) |
||||||
|
|
||||||
|
if (!resDecrypt) throw new Error('Unable to decrypt file') |
||||||
|
const decryptToUnit8Array = base64ToUint8Array(resDecrypt) |
||||||
|
let blob = null |
||||||
|
if (download?.blogPost?.mimeType) { |
||||||
|
blob = new Blob([decryptToUnit8Array], { |
||||||
|
type: download?.blogPost?.mimeType |
||||||
|
}) |
||||||
|
} else { |
||||||
|
blob = new Blob([decryptToUnit8Array]) |
||||||
|
} |
||||||
|
|
||||||
|
if (!blob) throw new Error('Unable build file into blob') |
||||||
|
await qortalRequest({ |
||||||
|
action: 'SAVE_FILE', |
||||||
|
blob, |
||||||
|
filename: |
||||||
|
download?.blogPost?.originalFilename || |
||||||
|
download?.blogPost?.filename, |
||||||
|
mimeType: download?.blogPost?.mimeType || '' |
||||||
|
}) |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
const url = `/arbitrary/${service}/${name}/${identifier}` |
||||||
|
fetch(url) |
||||||
|
.then((response) => response.blob()) |
||||||
|
.then(async (blob) => { |
||||||
|
await qortalRequest({ |
||||||
|
action: 'SAVE_FILE', |
||||||
|
blob, |
||||||
|
filename: download?.blogPost?.filename, |
||||||
|
mimeType: download?.blogPost?.mimeType || '' |
||||||
|
}) |
||||||
|
// saveAs(blob, download?.blogPost?.filename)
|
||||||
|
}) |
||||||
|
.catch((error) => { |
||||||
|
console.error('Error fetching the video:', error) |
||||||
|
// clearInterval(intervalId)
|
||||||
|
}) |
||||||
|
} catch (error: any) { |
||||||
|
let notificationObj = null |
||||||
|
if (typeof error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error || 'Failed to send message', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else if (typeof error?.error === 'string') { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.error || 'Failed to send message', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} else { |
||||||
|
notificationObj = { |
||||||
|
msg: error?.message || 'Failed to send message', |
||||||
|
alertType: 'error' |
||||||
|
} |
||||||
|
} |
||||||
|
if (!notificationObj) return |
||||||
|
dispatch(setNotification(notificationObj)) |
||||||
|
} finally { |
||||||
|
setDownloadLoader(false) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
if (!postId && mode !== 'mail') return |
||||||
|
const { name, service, identifier } = fileInfo |
||||||
|
let filename = fileProperties?.filename |
||||||
|
let mimeType = fileProperties?.mimeType |
||||||
|
if (!fileProperties) { |
||||||
|
try { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: 'Downloading file... please wait', |
||||||
|
alertType: 'info' |
||||||
|
}) |
||||||
|
) |
||||||
|
let res = await qortalRequest({ |
||||||
|
action: 'GET_QDN_RESOURCE_PROPERTIES', |
||||||
|
name: name, |
||||||
|
service: service, |
||||||
|
identifier: identifier |
||||||
|
}) |
||||||
|
setFileProperties(res) |
||||||
|
filename = res?.filename |
||||||
|
mimeType = res?.mimeType |
||||||
|
} catch (error: any) { |
||||||
|
console.log({ error }) |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: error?.message || 'Error with download. Please try again', |
||||||
|
alertType: 'error' |
||||||
|
}) |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
if (!filename) return |
||||||
|
|
||||||
|
downloadVideo({ |
||||||
|
name, |
||||||
|
service, |
||||||
|
identifier, |
||||||
|
blogPost: { |
||||||
|
postId, |
||||||
|
user, |
||||||
|
audioTitle: title, |
||||||
|
audioDescription: description, |
||||||
|
audioAuthor: author, |
||||||
|
filename, |
||||||
|
mimeType, |
||||||
|
originalFilename: fileInfo?.originalFilename |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
if ( |
||||||
|
resourceStatus?.status === 'READY' && |
||||||
|
download?.url && |
||||||
|
download?.blogPost?.filename && |
||||||
|
hasCommencedDownload.current |
||||||
|
) { |
||||||
|
setIsLoading(false) |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: 'Download completed. Click to save file', |
||||||
|
alertType: 'info' |
||||||
|
}) |
||||||
|
) |
||||||
|
} |
||||||
|
}, [resourceStatus, download]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
onClick={handlePlay} |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
overflow: 'hidden', |
||||||
|
position: 'relative', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
> |
||||||
|
{children && ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
position: 'relative', |
||||||
|
gap: '7px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{children}{' '} |
||||||
|
{(resourceStatus.status && resourceStatus?.status !== 'READY') || |
||||||
|
isLoading ? ( |
||||||
|
<CircularProgress color="secondary" size={14} /> |
||||||
|
) : resourceStatus?.status === 'READY' ? ( |
||||||
|
<> |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontSize: '14px' |
||||||
|
}} |
||||||
|
> |
||||||
|
Ready to save: click here |
||||||
|
</Typography> |
||||||
|
{downloadLoader && ( |
||||||
|
<CircularProgress color="secondary" size={14} /> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) : null} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
{!children && ( |
||||||
|
<Widget> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}> |
||||||
|
<CoverImage> |
||||||
|
<AttachFileIcon |
||||||
|
sx={{ |
||||||
|
width: '90%', |
||||||
|
height: 'auto' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</CoverImage> |
||||||
|
<Box sx={{ ml: 1.5, minWidth: 0 }}> |
||||||
|
<Typography |
||||||
|
variant="caption" |
||||||
|
color="text.secondary" |
||||||
|
fontWeight={500} |
||||||
|
> |
||||||
|
{author} |
||||||
|
</Typography> |
||||||
|
<Typography |
||||||
|
noWrap |
||||||
|
sx={{ |
||||||
|
fontSize: '16px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<b>{title}</b> |
||||||
|
</Typography> |
||||||
|
<Typography |
||||||
|
noWrap |
||||||
|
letterSpacing={-0.25} |
||||||
|
sx={{ |
||||||
|
fontSize: '14px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{description} |
||||||
|
</Typography> |
||||||
|
{mimeType && ( |
||||||
|
<Typography |
||||||
|
noWrap |
||||||
|
letterSpacing={-0.25} |
||||||
|
sx={{ |
||||||
|
fontSize: '12px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{mimeType} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
{((resourceStatus.status && resourceStatus?.status !== 'READY') || |
||||||
|
isLoading) && ( |
||||||
|
<Box |
||||||
|
position="absolute" |
||||||
|
top={0} |
||||||
|
left={0} |
||||||
|
right={0} |
||||||
|
bottom={0} |
||||||
|
display="flex" |
||||||
|
justifyContent="center" |
||||||
|
alignItems="center" |
||||||
|
zIndex={4999} |
||||||
|
bgcolor="rgba(0, 0, 0, 0.6)" |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: '10px', |
||||||
|
padding: '8px', |
||||||
|
borderRadius: '10px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CircularProgress color="secondary" /> |
||||||
|
{resourceStatus && ( |
||||||
|
<Typography |
||||||
|
variant="subtitle2" |
||||||
|
component="div" |
||||||
|
sx={{ |
||||||
|
color: 'white', |
||||||
|
fontSize: '14px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{resourceStatus?.status === 'REFETCHING' ? ( |
||||||
|
<> |
||||||
|
<> |
||||||
|
{( |
||||||
|
(resourceStatus?.localChunkCount / |
||||||
|
resourceStatus?.totalChunkCount) * |
||||||
|
100 |
||||||
|
)?.toFixed(0)} |
||||||
|
% |
||||||
|
</> |
||||||
|
|
||||||
|
<> Refetching in 2 minutes</> |
||||||
|
</> |
||||||
|
) : resourceStatus?.status === 'DOWNLOADED' ? ( |
||||||
|
<>Download Completed: building file...</> |
||||||
|
) : resourceStatus?.status !== 'READY' ? ( |
||||||
|
<> |
||||||
|
{( |
||||||
|
(resourceStatus?.localChunkCount / |
||||||
|
resourceStatus?.totalChunkCount) * |
||||||
|
100 |
||||||
|
)?.toFixed(0)} |
||||||
|
% |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<>Download Completed: fetching file...</> |
||||||
|
)} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
{resourceStatus?.status === 'READY' && |
||||||
|
download?.url && |
||||||
|
download?.blogPost?.filename && ( |
||||||
|
<Box |
||||||
|
position="absolute" |
||||||
|
top={0} |
||||||
|
left={0} |
||||||
|
right={0} |
||||||
|
bottom={0} |
||||||
|
display="flex" |
||||||
|
justifyContent="center" |
||||||
|
alignItems="center" |
||||||
|
zIndex={4999} |
||||||
|
bgcolor="rgba(0, 0, 0, 0.6)" |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'row', |
||||||
|
gap: '10px', |
||||||
|
padding: '8px', |
||||||
|
borderRadius: '10px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
variant="subtitle2" |
||||||
|
component="div" |
||||||
|
sx={{ |
||||||
|
color: 'white', |
||||||
|
fontSize: '14px' |
||||||
|
}} |
||||||
|
> |
||||||
|
Ready to save: click here |
||||||
|
</Typography> |
||||||
|
{downloadLoader && ( |
||||||
|
<CircularProgress color="secondary" size={14} /> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
</Widget> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
@ -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_q-blog` |
||||||
|
const response = await qortalRequest({ |
||||||
|
action: 'GET_LIST_ITEMS', |
||||||
|
list_name: listName |
||||||
|
}) |
||||||
|
setBlockedNames(response) |
||||||
|
} catch (error) { |
||||||
|
onClose() |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getBlockedNames() |
||||||
|
}, [getBlockedNames]) |
||||||
|
|
||||||
|
const removeFromBlockList = async (name: string) => { |
||||||
|
try { |
||||||
|
const response = await qortalRequest({ |
||||||
|
action: 'DELETE_LIST_ITEM', |
||||||
|
list_name: 'blockedNames_q-blog', |
||||||
|
item: name |
||||||
|
}) |
||||||
|
|
||||||
|
if (response === true) { |
||||||
|
setBlockedNames((prev) => prev.filter((n) => n !== name)) |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<StyledModal open={open} onClose={onClose}> |
||||||
|
<ModalContent> |
||||||
|
<ModalText>Manage blocked names</ModalText> |
||||||
|
<List |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flex: '1', |
||||||
|
overflow: 'auto' |
||||||
|
}} |
||||||
|
> |
||||||
|
{blockedNames.map((name, index) => ( |
||||||
|
<ListItem |
||||||
|
key={name + index} |
||||||
|
sx={{ |
||||||
|
display: 'flex' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography>{name}</Typography> |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial' |
||||||
|
}} |
||||||
|
onClick={() => removeFromBlockList(name)} |
||||||
|
> |
||||||
|
Remove |
||||||
|
</Button> |
||||||
|
</ListItem> |
||||||
|
))} |
||||||
|
</List> |
||||||
|
<Button variant="contained" color="primary" onClick={onClose}> |
||||||
|
Close |
||||||
|
</Button> |
||||||
|
</ModalContent> |
||||||
|
</StyledModal> |
||||||
|
) |
||||||
|
} |
@ -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,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,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,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> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { Box, Typography } from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { Description, Movie } from '@mui/icons-material' |
||||||
|
|
||||||
|
interface VideoProps { |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
} |
||||||
|
|
||||||
|
const StyledBox = styled(Box)` |
||||||
|
margin: 20px 0px; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
` |
||||||
|
|
||||||
|
const Title = styled(Typography)`` |
||||||
|
|
||||||
|
const DescriptionIcon = styled(Description)` |
||||||
|
color: #666; |
||||||
|
margin-right: 0.5rem; |
||||||
|
` |
||||||
|
|
||||||
|
const MovieIcon = styled(Movie)` |
||||||
|
color: #666; |
||||||
|
margin-right: 0.5rem; |
||||||
|
` |
||||||
|
|
||||||
|
export const VideoContent: React.FC<VideoProps> = ({ title, description }) => { |
||||||
|
return ( |
||||||
|
<StyledBox> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
alignItems: 'flex-start' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box display="flex" alignItems="center"> |
||||||
|
<MovieIcon /> |
||||||
|
<Title variant="h4">{title}</Title> |
||||||
|
</Box> |
||||||
|
|
||||||
|
<Box display="flex" alignItems="center"> |
||||||
|
<DescriptionIcon /> |
||||||
|
<Typography variant="body1">{description}</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</StyledBox> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,226 @@ |
|||||||
|
import React, { useState, useEffect } from 'react' |
||||||
|
import { styled, Box } from '@mui/system' |
||||||
|
import { |
||||||
|
Drawer, |
||||||
|
List, |
||||||
|
ListItem, |
||||||
|
ListItemText, |
||||||
|
Typography, |
||||||
|
ButtonBase, |
||||||
|
Button, |
||||||
|
Tooltip |
||||||
|
} from '@mui/material' |
||||||
|
import VideoCallIcon from '@mui/icons-material/VideoCall' |
||||||
|
import VideoModal from './VideoPublishModal' |
||||||
|
import { useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
|
||||||
|
interface VideoPanelProps { |
||||||
|
onSelect: (video: Video) => void |
||||||
|
height?: string |
||||||
|
width?: string |
||||||
|
} |
||||||
|
|
||||||
|
interface VideoApiResponse { |
||||||
|
videos: Video[] |
||||||
|
} |
||||||
|
|
||||||
|
const Panel = styled('div')` |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
width: 100%; |
||||||
|
padding-bottom: 10px; |
||||||
|
height: 100%; |
||||||
|
overflow: hidden; |
||||||
|
|
||||||
|
&::-webkit-scrollbar { |
||||||
|
width: 8px; |
||||||
|
height: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb { |
||||||
|
background-color: #888; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover { |
||||||
|
background-color: #555; |
||||||
|
} |
||||||
|
` |
||||||
|
|
||||||
|
const PublishButton = styled(Button)` |
||||||
|
/* position: absolute; |
||||||
|
bottom: 20px; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
margin: auto; */ |
||||||
|
max-width: 80%; |
||||||
|
` |
||||||
|
|
||||||
|
export const VideoPanel: React.FC<VideoPanelProps> = ({ |
||||||
|
onSelect, |
||||||
|
height, |
||||||
|
width |
||||||
|
}) => { |
||||||
|
const [isOpen, setIsOpen] = useState(false) |
||||||
|
const [videos, setVideos] = useState<Video[]>([]) |
||||||
|
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false) |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
|
||||||
|
const fetchVideos = React.useCallback(async (): Promise<Video[]> => { |
||||||
|
if (!user?.name) return [] |
||||||
|
// Replace this URL with the actual API endpoint
|
||||||
|
let res |
||||||
|
try { |
||||||
|
res = await qortalRequest({ |
||||||
|
action: 'LIST_QDN_RESOURCES', |
||||||
|
service: 'VIDEO', |
||||||
|
name: user.name, |
||||||
|
includeMetadata: true, |
||||||
|
limit: 100, |
||||||
|
offset: 0, |
||||||
|
reverse: true |
||||||
|
}) |
||||||
|
} catch (error) { |
||||||
|
} |
||||||
|
|
||||||
|
return res |
||||||
|
}, [user]) |
||||||
|
useEffect(() => { |
||||||
|
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleToggle = () => { |
||||||
|
setIsOpen(!isOpen) |
||||||
|
} |
||||||
|
|
||||||
|
const handleClick = (video: Video) => { |
||||||
|
onSelect(video) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Tooltip title="Add a video" arrow> |
||||||
|
<VideoCallIcon |
||||||
|
onClick={handleToggle} |
||||||
|
sx={{ |
||||||
|
height: height || '30px', |
||||||
|
width: width || 'auto', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
></VideoCallIcon> |
||||||
|
</Tooltip> |
||||||
|
<Drawer |
||||||
|
anchor="right" |
||||||
|
open={isOpen} |
||||||
|
onClose={handleToggle} |
||||||
|
ModalProps={{ |
||||||
|
keepMounted: true // Better performance on mobile
|
||||||
|
}} |
||||||
|
sx={{ |
||||||
|
'& .MuiPaper-root': { |
||||||
|
width: '400px' |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<Panel> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
alignItems: 'center', |
||||||
|
flex: '0 0' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
variant="h5" |
||||||
|
component="div" |
||||||
|
sx={{ flexGrow: 1, mt: 2, mb: 1 }} |
||||||
|
> |
||||||
|
Select Video |
||||||
|
</Typography> |
||||||
|
<Typography |
||||||
|
variant="subtitle2" |
||||||
|
component="div" |
||||||
|
sx={{ flexGrow: 1, mb: 2 }} |
||||||
|
> |
||||||
|
List of videos in QDN under your name |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
|
||||||
|
<List |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flex: '1', |
||||||
|
overflow: 'auto' |
||||||
|
}} |
||||||
|
> |
||||||
|
{videos.map((video) => ( |
||||||
|
<ListItem key={video.identifier}> |
||||||
|
<ButtonBase |
||||||
|
onClick={() => handleClick(video)} |
||||||
|
sx={{ width: '100%' }} |
||||||
|
> |
||||||
|
<ListItemText |
||||||
|
primary={video?.metadata?.title || ''} |
||||||
|
secondary={video?.metadata?.description || ''} |
||||||
|
/> |
||||||
|
</ButtonBase> |
||||||
|
</ListItem> |
||||||
|
))} |
||||||
|
</List> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center', |
||||||
|
flex: '0 0 50px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<PublishButton |
||||||
|
variant="contained" |
||||||
|
onClick={() => setIsOpenVideoModal(true)} |
||||||
|
> |
||||||
|
Publish new video |
||||||
|
</PublishButton> |
||||||
|
</Box> |
||||||
|
</Panel> |
||||||
|
</Drawer> |
||||||
|
<VideoModal |
||||||
|
onClose={() => { |
||||||
|
setIsOpenVideoModal(false) |
||||||
|
}} |
||||||
|
open={isOpenVideoModal} |
||||||
|
onPublish={(value) => { |
||||||
|
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||||
|
setIsOpenVideoModal(false) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
// Add this to your 'types.ts' file
|
||||||
|
export interface Video { |
||||||
|
name: string |
||||||
|
service: string |
||||||
|
identifier: string |
||||||
|
metadata: { |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
tags: string[] |
||||||
|
category: string |
||||||
|
categoryName: string |
||||||
|
} |
||||||
|
size: number |
||||||
|
created: number |
||||||
|
updated: number |
||||||
|
} |
@ -0,0 +1,492 @@ |
|||||||
|
import React, { useContext, useMemo, useRef, useState } from 'react' |
||||||
|
import ReactDOM from 'react-dom' |
||||||
|
import { Box, IconButton, Slider } from '@mui/material' |
||||||
|
import { CircularProgress, Typography } from '@mui/material' |
||||||
|
|
||||||
|
import { |
||||||
|
PlayArrow, |
||||||
|
Pause, |
||||||
|
VolumeUp, |
||||||
|
Fullscreen, |
||||||
|
PictureInPicture |
||||||
|
} from '@mui/icons-material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { MyContext } from '../../wrappers/DownloadWrapper' |
||||||
|
import { useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
|
||||||
|
const VideoContainer = styled(Box)` |
||||||
|
position: relative; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
margin: 0px; |
||||||
|
padding: 0px; |
||||||
|
` |
||||||
|
|
||||||
|
const VideoElement = styled('video')` |
||||||
|
width: 100%; |
||||||
|
height: auto; |
||||||
|
background: rgb(33, 33, 33); |
||||||
|
` |
||||||
|
|
||||||
|
const ControlsContainer = styled(Box)` |
||||||
|
position: absolute; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
bottom: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
padding: 8px; |
||||||
|
background-color: rgba(0, 0, 0, 0.6); |
||||||
|
` |
||||||
|
|
||||||
|
interface VideoPlayerProps { |
||||||
|
src?: string |
||||||
|
poster?: string |
||||||
|
name?: string |
||||||
|
identifier?: string |
||||||
|
service?: string |
||||||
|
autoplay?: boolean |
||||||
|
from?: string | null |
||||||
|
setCount?: () => void |
||||||
|
customStyle?: any |
||||||
|
user?: string |
||||||
|
postId?: string |
||||||
|
} |
||||||
|
|
||||||
|
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ |
||||||
|
poster, |
||||||
|
name, |
||||||
|
identifier, |
||||||
|
service, |
||||||
|
autoplay = true, |
||||||
|
from = null, |
||||||
|
setCount, |
||||||
|
customStyle = {}, |
||||||
|
user = '', |
||||||
|
postId = '' |
||||||
|
}) => { |
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null) |
||||||
|
const [playing, setPlaying] = useState(false) |
||||||
|
const [volume, setVolume] = useState(1) |
||||||
|
const [progress, setProgress] = useState(0) |
||||||
|
const [isLoading, setIsLoading] = useState(false) |
||||||
|
const [startPlay, setStartPlay] = useState(false) |
||||||
|
|
||||||
|
const { downloads } = useSelector((state: RootState) => state.global) |
||||||
|
|
||||||
|
const download = useMemo(() => { |
||||||
|
if (!downloads || !identifier) return {} |
||||||
|
const findDownload = downloads[identifier] |
||||||
|
|
||||||
|
if (!findDownload) return {} |
||||||
|
return findDownload |
||||||
|
}, [downloads, identifier]) |
||||||
|
|
||||||
|
const src = useMemo(() => { |
||||||
|
return download?.url || '' |
||||||
|
}, [download?.url]) |
||||||
|
const resourceStatus = useMemo(() => { |
||||||
|
return download?.status || {} |
||||||
|
}, [download]) |
||||||
|
|
||||||
|
const toggleRef = useRef<any>(null) |
||||||
|
const { downloadVideo } = useContext(MyContext) |
||||||
|
const togglePlay = async () => { |
||||||
|
if (!videoRef.current) return |
||||||
|
setStartPlay(true) |
||||||
|
if (!src) { |
||||||
|
const el = document.getElementById('videoWrapper') |
||||||
|
if (el) { |
||||||
|
el?.parentElement?.removeChild(el) |
||||||
|
} |
||||||
|
ReactDOM.flushSync(() => { |
||||||
|
setIsLoading(true) |
||||||
|
}) |
||||||
|
getSrc() |
||||||
|
} |
||||||
|
if (playing) { |
||||||
|
videoRef.current.pause() |
||||||
|
} else { |
||||||
|
videoRef.current.play() |
||||||
|
} |
||||||
|
setPlaying(!playing) |
||||||
|
} |
||||||
|
|
||||||
|
const onVolumeChange = (_: any, value: number | number[]) => { |
||||||
|
if (!videoRef.current) return |
||||||
|
videoRef.current.volume = value as number |
||||||
|
setVolume(value as number) |
||||||
|
} |
||||||
|
|
||||||
|
const onProgressChange = (_: any, value: number | number[]) => { |
||||||
|
if (!videoRef.current) return |
||||||
|
videoRef.current.currentTime = value as number |
||||||
|
setProgress(value as number) |
||||||
|
if (!playing) { |
||||||
|
videoRef.current.play() |
||||||
|
setPlaying(true) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleEnded = () => { |
||||||
|
setPlaying(false) |
||||||
|
} |
||||||
|
|
||||||
|
const updateProgress = () => { |
||||||
|
if (!videoRef.current) return |
||||||
|
setProgress(videoRef.current.currentTime) |
||||||
|
} |
||||||
|
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false) |
||||||
|
|
||||||
|
const enterFullscreen = () => { |
||||||
|
if (!videoRef.current) return |
||||||
|
if (videoRef.current.requestFullscreen) { |
||||||
|
videoRef.current.requestFullscreen() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const exitFullscreen = () => { |
||||||
|
if (document.exitFullscreen) { |
||||||
|
document.exitFullscreen() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const toggleFullscreen = () => { |
||||||
|
if (!isFullscreen) { |
||||||
|
enterFullscreen() |
||||||
|
} else { |
||||||
|
exitFullscreen() |
||||||
|
} |
||||||
|
} |
||||||
|
const togglePictureInPicture = async () => { |
||||||
|
if (!videoRef.current) return |
||||||
|
if (document.pictureInPictureElement === videoRef.current) { |
||||||
|
await document.exitPictureInPicture() |
||||||
|
} else { |
||||||
|
await videoRef.current.requestPictureInPicture() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
const handleFullscreenChange = () => { |
||||||
|
setIsFullscreen(!!document.fullscreenElement) |
||||||
|
} |
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange) |
||||||
|
return () => { |
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange) |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleLoadedMetadata = () => { |
||||||
|
setIsLoading(false) |
||||||
|
} |
||||||
|
|
||||||
|
const handleCanPlay = () => { |
||||||
|
if (setCount) { |
||||||
|
setCount() |
||||||
|
} |
||||||
|
setIsLoading(false) |
||||||
|
} |
||||||
|
|
||||||
|
const getSrc = React.useCallback(async () => { |
||||||
|
if (!name || !identifier || !service || !postId || !user) return |
||||||
|
try { |
||||||
|
downloadVideo({ |
||||||
|
name, |
||||||
|
service, |
||||||
|
identifier, |
||||||
|
blogPost: { |
||||||
|
postId, |
||||||
|
user |
||||||
|
} |
||||||
|
}) |
||||||
|
} catch (error) {} |
||||||
|
}, [identifier, name, service]) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
const videoElement = videoRef.current |
||||||
|
|
||||||
|
const handleLeavePictureInPicture = async (event: any) => { |
||||||
|
const target = event?.target |
||||||
|
if (target) { |
||||||
|
target.pause() |
||||||
|
if (setPlaying) { |
||||||
|
setPlaying(false) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (videoElement) { |
||||||
|
videoElement.addEventListener( |
||||||
|
'leavepictureinpicture', |
||||||
|
handleLeavePictureInPicture |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (videoElement) { |
||||||
|
videoElement.removeEventListener( |
||||||
|
'leavepictureinpicture', |
||||||
|
handleLeavePictureInPicture |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
const videoElement = videoRef.current |
||||||
|
|
||||||
|
const minimizeVideo = async () => { |
||||||
|
if (!videoElement) return |
||||||
|
const handleClose = () => { |
||||||
|
if (videoElement && videoElement.parentElement) { |
||||||
|
const el = document.getElementById('videoWrapper') |
||||||
|
if (el) { |
||||||
|
el?.parentElement?.removeChild(el) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
const createCloseButton = (): HTMLButtonElement => { |
||||||
|
const closeButton = document.createElement('button') |
||||||
|
closeButton.textContent = 'X' |
||||||
|
closeButton.style.position = 'absolute' |
||||||
|
closeButton.style.top = '0' |
||||||
|
closeButton.style.right = '0' |
||||||
|
closeButton.style.backgroundColor = 'rgba(255, 255, 255, 0.7)' |
||||||
|
closeButton.style.border = 'none' |
||||||
|
closeButton.style.fontWeight = 'bold' |
||||||
|
closeButton.style.fontSize = '1.2rem' |
||||||
|
closeButton.style.cursor = 'pointer' |
||||||
|
closeButton.style.padding = '2px 8px' |
||||||
|
closeButton.style.borderRadius = '0 0 0 4px' |
||||||
|
|
||||||
|
closeButton.addEventListener('click', handleClose) |
||||||
|
|
||||||
|
return closeButton |
||||||
|
} |
||||||
|
const buttonClose = createCloseButton() |
||||||
|
const videoWrapper = document.createElement('div') |
||||||
|
videoWrapper.id = 'videoWrapper' |
||||||
|
videoWrapper.style.position = 'fixed' |
||||||
|
videoWrapper.style.zIndex = '900000009' |
||||||
|
videoWrapper.style.bottom = '0px' |
||||||
|
videoWrapper.style.right = '0px' |
||||||
|
|
||||||
|
videoElement.parentElement?.insertBefore(videoWrapper, videoElement) |
||||||
|
videoWrapper.appendChild(videoElement) |
||||||
|
|
||||||
|
videoWrapper.appendChild(buttonClose) |
||||||
|
videoElement.controls = true |
||||||
|
videoElement.style.height = 'auto' |
||||||
|
videoElement.style.width = '300px' |
||||||
|
|
||||||
|
document.body.appendChild(videoWrapper) |
||||||
|
} |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (videoElement) { |
||||||
|
if (videoElement && !videoElement.paused && !videoElement.ended) { |
||||||
|
minimizeVideo() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
function formatTime(seconds: number): string { |
||||||
|
seconds = Math.floor(seconds) |
||||||
|
|
||||||
|
let minutes: number | string = Math.floor(seconds / 60) |
||||||
|
let remainingSeconds: number | string = seconds % 60 |
||||||
|
|
||||||
|
if (minutes < 10) { |
||||||
|
minutes = '0' + minutes |
||||||
|
} |
||||||
|
if (remainingSeconds < 10) { |
||||||
|
remainingSeconds = '0' + remainingSeconds |
||||||
|
} |
||||||
|
|
||||||
|
return minutes + ':' + remainingSeconds |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<VideoContainer |
||||||
|
style={{ |
||||||
|
padding: from === 'create' ? '8px' : 0 |
||||||
|
}} |
||||||
|
> |
||||||
|
{isLoading && ( |
||||||
|
<Box |
||||||
|
position="absolute" |
||||||
|
top={0} |
||||||
|
left={0} |
||||||
|
right={0} |
||||||
|
bottom={0} |
||||||
|
display="flex" |
||||||
|
justifyContent="center" |
||||||
|
alignItems="center" |
||||||
|
zIndex={4999} |
||||||
|
bgcolor="rgba(0, 0, 0, 0.6)" |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: '10px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<CircularProgress color="secondary" /> |
||||||
|
{resourceStatus && ( |
||||||
|
<Typography |
||||||
|
variant="subtitle2" |
||||||
|
component="div" |
||||||
|
sx={{ |
||||||
|
color: 'white', |
||||||
|
fontSize: '18px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{resourceStatus?.status === 'REFETCHING' ? ( |
||||||
|
<> |
||||||
|
<> |
||||||
|
{( |
||||||
|
(resourceStatus?.localChunkCount / |
||||||
|
resourceStatus?.totalChunkCount) * |
||||||
|
100 |
||||||
|
)?.toFixed(0)} |
||||||
|
% |
||||||
|
</> |
||||||
|
|
||||||
|
<> Refetching in 2 minutes</> |
||||||
|
</> |
||||||
|
) : resourceStatus?.status === 'DOWNLOADED' ? ( |
||||||
|
<>Download Completed: building video...</> |
||||||
|
) : resourceStatus?.status !== 'READY' ? ( |
||||||
|
<> |
||||||
|
{( |
||||||
|
(resourceStatus?.localChunkCount / |
||||||
|
resourceStatus?.totalChunkCount) * |
||||||
|
100 |
||||||
|
)?.toFixed(0)} |
||||||
|
% |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<>Download Completed: fetching video...</> |
||||||
|
)} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
{((!src && !isLoading) || !startPlay) && ( |
||||||
|
<Box |
||||||
|
position="absolute" |
||||||
|
top={0} |
||||||
|
left={0} |
||||||
|
right={0} |
||||||
|
bottom={0} |
||||||
|
display="flex" |
||||||
|
justifyContent="center" |
||||||
|
alignItems="center" |
||||||
|
zIndex={500} |
||||||
|
bgcolor="rgba(0, 0, 0, 0.6)" |
||||||
|
onClick={() => { |
||||||
|
if (from === 'create') return |
||||||
|
|
||||||
|
togglePlay() |
||||||
|
}} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
> |
||||||
|
<PlayArrow |
||||||
|
sx={{ |
||||||
|
width: '50px', |
||||||
|
height: '50px', |
||||||
|
color: 'white' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
<VideoElement |
||||||
|
ref={videoRef} |
||||||
|
src={!startPlay ? '' : src} |
||||||
|
poster={poster} |
||||||
|
onTimeUpdate={updateProgress} |
||||||
|
autoPlay={autoplay} |
||||||
|
onEnded={handleEnded} |
||||||
|
// onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onCanPlay={handleCanPlay} |
||||||
|
preload="metadata" |
||||||
|
style={{ |
||||||
|
...customStyle |
||||||
|
}} |
||||||
|
/> |
||||||
|
<ControlsContainer |
||||||
|
style={{ |
||||||
|
bottom: from === 'create' ? '15px' : 0 |
||||||
|
}} |
||||||
|
> |
||||||
|
<IconButton |
||||||
|
sx={{ |
||||||
|
color: 'rgba(255, 255, 255, 0.7)' |
||||||
|
}} |
||||||
|
onClick={togglePlay} |
||||||
|
> |
||||||
|
{playing ? <Pause /> : <PlayArrow />} |
||||||
|
</IconButton> |
||||||
|
<Slider |
||||||
|
value={progress} |
||||||
|
onChange={onProgressChange} |
||||||
|
min={0} |
||||||
|
max={videoRef.current?.duration || 100} |
||||||
|
sx={{ flexGrow: 1, mx: 2 }} |
||||||
|
/> |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontSize: '14px', |
||||||
|
marginRight: '5px', |
||||||
|
color: 'rgba(255, 255, 255, 0.7)', |
||||||
|
visibility: |
||||||
|
!videoRef.current?.duration || !progress ? 'hidden' : 'visible' |
||||||
|
}} |
||||||
|
> |
||||||
|
{progress && videoRef.current?.duration && formatTime(progress)}/ |
||||||
|
{progress && |
||||||
|
videoRef.current?.duration && |
||||||
|
formatTime(videoRef.current?.duration)} |
||||||
|
</Typography> |
||||||
|
<VolumeUp /> |
||||||
|
<Slider |
||||||
|
value={volume} |
||||||
|
onChange={onVolumeChange} |
||||||
|
min={0} |
||||||
|
max={1} |
||||||
|
step={0.01} |
||||||
|
/> |
||||||
|
<IconButton |
||||||
|
sx={{ |
||||||
|
color: 'rgba(255, 255, 255, 0.7)', |
||||||
|
marginLeft: '15px' |
||||||
|
}} |
||||||
|
ref={toggleRef} |
||||||
|
onClick={togglePictureInPicture} |
||||||
|
> |
||||||
|
<PictureInPicture /> |
||||||
|
</IconButton> |
||||||
|
<IconButton |
||||||
|
sx={{ |
||||||
|
color: 'rgba(255, 255, 255, 0.7)' |
||||||
|
}} |
||||||
|
onClick={toggleFullscreen} |
||||||
|
> |
||||||
|
<Fullscreen /> |
||||||
|
</IconButton> |
||||||
|
</ControlsContainer> |
||||||
|
</VideoContainer> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,279 @@ |
|||||||
|
import React, { useState } from 'react' |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
Modal, |
||||||
|
TextField, |
||||||
|
Typography, |
||||||
|
Select, |
||||||
|
MenuItem, |
||||||
|
FormControl, |
||||||
|
InputLabel, |
||||||
|
SelectChangeEvent, |
||||||
|
OutlinedInput, |
||||||
|
Chip, |
||||||
|
IconButton |
||||||
|
} from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { useDropzone } from 'react-dropzone' |
||||||
|
import { usePublishVideo } from './PublishVideo' |
||||||
|
import { toBase64 } from '../../utils/toBase64' |
||||||
|
import AddIcon from '@mui/icons-material/Add' |
||||||
|
import CloseIcon from '@mui/icons-material/Close' |
||||||
|
const StyledModal = styled(Modal)(({ theme }) => ({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center' |
||||||
|
})) |
||||||
|
|
||||||
|
const ChipContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
flexWrap: 'wrap', |
||||||
|
'& > *': { |
||||||
|
margin: '4px' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const ModalContent = styled(Box)(({ theme }) => ({ |
||||||
|
backgroundColor: theme.palette.background.paper, |
||||||
|
padding: theme.spacing(4), |
||||||
|
borderRadius: theme.spacing(1), |
||||||
|
width: '40%', |
||||||
|
'&:focus': { |
||||||
|
outline: 'none' |
||||||
|
} |
||||||
|
})) |
||||||
|
|
||||||
|
interface VideoModalProps { |
||||||
|
open: boolean |
||||||
|
onClose: () => void |
||||||
|
onPublish: (value: any) => void |
||||||
|
} |
||||||
|
|
||||||
|
interface SelectOption { |
||||||
|
id: string |
||||||
|
name: string |
||||||
|
} |
||||||
|
|
||||||
|
const VideoModal: React.FC<VideoModalProps> = ({ |
||||||
|
open, |
||||||
|
onClose, |
||||||
|
onPublish |
||||||
|
}) => { |
||||||
|
const [file, setFile] = useState<File | null>(null) |
||||||
|
const [title, setTitle] = useState('') |
||||||
|
const [description, setDescription] = useState('') |
||||||
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||||
|
null |
||||||
|
) |
||||||
|
const [inputValue, setInputValue] = useState<string>('') |
||||||
|
const [chips, setChips] = useState<string[]>([]) |
||||||
|
|
||||||
|
const [options, setOptions] = useState<SelectOption[]>([]) |
||||||
|
const [tags, setTags] = useState<string[]>([]) |
||||||
|
const { publishVideo } = usePublishVideo() |
||||||
|
const { getRootProps, getInputProps } = useDropzone({ |
||||||
|
accept: { |
||||||
|
'video/*': [] |
||||||
|
}, |
||||||
|
maxFiles: 1, |
||||||
|
onDrop: (acceptedFiles) => { |
||||||
|
setFile(acceptedFiles[0]) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
||||||
|
setTitle(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleDescriptionChange = ( |
||||||
|
event: React.ChangeEvent<HTMLInputElement> |
||||||
|
) => { |
||||||
|
setDescription(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||||
|
const optionId = event.target.value |
||||||
|
const selectedOption = options.find((option) => option.id === optionId) |
||||||
|
setSelectedOption(selectedOption || null) |
||||||
|
} |
||||||
|
|
||||||
|
const handleChipDelete = (index: number) => { |
||||||
|
const newChips = [...chips] |
||||||
|
newChips.splice(index, 1) |
||||||
|
setChips(newChips) |
||||||
|
} |
||||||
|
|
||||||
|
const handleSubmit = async () => { |
||||||
|
const missingFields = [] |
||||||
|
|
||||||
|
if (!title) missingFields.push('title') |
||||||
|
if (!file) missingFields.push('file') |
||||||
|
if (missingFields.length > 0) { |
||||||
|
const missingFieldsString = missingFields.join(', ') |
||||||
|
const errMsg = `Missing: ${missingFieldsString}` |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
if (!file) return |
||||||
|
|
||||||
|
const formattedTags: { [key: string]: string } = {} |
||||||
|
chips.forEach((tag, i) => { |
||||||
|
formattedTags[`tag${i + 1}`] = tag |
||||||
|
}) |
||||||
|
|
||||||
|
|
||||||
|
try { |
||||||
|
const base64 = await toBase64(file) |
||||||
|
if (typeof base64 !== 'string') return |
||||||
|
const base64String = base64.split(',')[1] |
||||||
|
|
||||||
|
const res = await publishVideo({ |
||||||
|
title, |
||||||
|
description, |
||||||
|
base64: base64String, |
||||||
|
category: selectedOption?.id || '', |
||||||
|
...formattedTags |
||||||
|
}) |
||||||
|
onPublish(res) |
||||||
|
setFile(null) |
||||||
|
setTitle('') |
||||||
|
setDescription('') |
||||||
|
onClose() |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputChange = (event: any) => { |
||||||
|
setInputValue(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputKeyDown = (event: any) => { |
||||||
|
if (event.key === 'Enter' && inputValue !== '') { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} else { |
||||||
|
event.preventDefault() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const addChip = () => { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getListCategories = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const url = `/arbitrary/categories` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
setOptions(responseData) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getListCategories() |
||||||
|
}, [getListCategories]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<StyledModal open={open} onClose={onClose}> |
||||||
|
<ModalContent> |
||||||
|
<Typography variant="h6" component="h2" gutterBottom> |
||||||
|
Upload Video |
||||||
|
</Typography> |
||||||
|
<Box |
||||||
|
{...getRootProps()} |
||||||
|
sx={{ |
||||||
|
border: '1px dashed gray', |
||||||
|
padding: 2, |
||||||
|
textAlign: 'center', |
||||||
|
marginBottom: 2 |
||||||
|
}} |
||||||
|
> |
||||||
|
<input {...getInputProps()} /> |
||||||
|
<Typography> |
||||||
|
{file |
||||||
|
? file.name |
||||||
|
: 'Drag and drop a video file here or click to select a file'} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
<TextField |
||||||
|
label="Video Title" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
value={title} |
||||||
|
onChange={handleTitleChange} |
||||||
|
inputProps={{ maxLength: 40 }} |
||||||
|
sx={{ marginBottom: 2 }} |
||||||
|
/> |
||||||
|
<TextField |
||||||
|
label="Video Description" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
multiline |
||||||
|
rows={4} |
||||||
|
value={description} |
||||||
|
onChange={handleDescriptionChange} |
||||||
|
inputProps={{ maxLength: 180 }} |
||||||
|
sx={{ marginBottom: 2 }} |
||||||
|
/> |
||||||
|
{options.length > 0 && ( |
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<InputLabel id="Category">Select a Category</InputLabel> |
||||||
|
<Select |
||||||
|
labelId="Category" |
||||||
|
input={<OutlinedInput label="Select a Category" />} |
||||||
|
value={selectedOption?.id || ''} |
||||||
|
onChange={handleOptionChange} |
||||||
|
> |
||||||
|
{options.map((option) => ( |
||||||
|
<MenuItem key={option.id} value={option.id}> |
||||||
|
{option.name} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
)} |
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||||
|
<TextField |
||||||
|
label="Add a tag" |
||||||
|
value={inputValue} |
||||||
|
onChange={handleInputChange} |
||||||
|
onKeyDown={handleInputKeyDown} |
||||||
|
disabled={chips.length === 3} |
||||||
|
/> |
||||||
|
|
||||||
|
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||||
|
<AddIcon /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
<ChipContainer> |
||||||
|
{chips.map((chip, index) => ( |
||||||
|
<Chip |
||||||
|
key={index} |
||||||
|
label={chip} |
||||||
|
onDelete={() => handleChipDelete(index)} |
||||||
|
deleteIcon={<CloseIcon />} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ChipContainer> |
||||||
|
</FormControl> |
||||||
|
<Button variant="contained" color="primary" onClick={handleSubmit}> |
||||||
|
Submit |
||||||
|
</Button> |
||||||
|
</ModalContent> |
||||||
|
</StyledModal> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default VideoModal |
@ -0,0 +1,78 @@ |
|||||||
|
/* src/components/BlogEditor.css */ |
||||||
|
.blog-editor { |
||||||
|
max-width: 800px; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 1rem; |
||||||
|
line-height: 1.5; |
||||||
|
font-size: 18px; |
||||||
|
max-height: 50vh; |
||||||
|
overflow-y: auto; |
||||||
|
min-height: 200px; |
||||||
|
z-index: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.toolbar { |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.toolbar-button:focus { |
||||||
|
outline: none; |
||||||
|
} |
||||||
|
|
||||||
|
.code-block { |
||||||
|
background-color: #2c2b31; |
||||||
|
color: rgb(238, 234, 234); |
||||||
|
border-radius: 3px; |
||||||
|
padding: 10px; |
||||||
|
margin: 10px 0; |
||||||
|
font-family: 'Courier New', Courier, monospace; |
||||||
|
white-space: pre-wrap; |
||||||
|
overflow-x: auto; |
||||||
|
max-width: 100%; |
||||||
|
font-size: 14px; |
||||||
|
} |
||||||
|
|
||||||
|
.paragraph { |
||||||
|
font-size: 20px; |
||||||
|
margin: 0px; |
||||||
|
} |
||||||
|
|
||||||
|
.paragraph-mail { |
||||||
|
font-size: 16px; |
||||||
|
margin: 0px; |
||||||
|
} |
||||||
|
|
||||||
|
.toolbar-button { |
||||||
|
background-color: white; |
||||||
|
border: 1px solid gray; |
||||||
|
border-radius: 5px; |
||||||
|
margin-right: 5px; |
||||||
|
cursor: pointer; |
||||||
|
outline: none; |
||||||
|
height: 32px; |
||||||
|
width: 32px; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
.toolbar-button.active { |
||||||
|
background-color: lightgray; |
||||||
|
} |
||||||
|
|
||||||
|
.h2 { |
||||||
|
font-size: 25px |
||||||
|
} |
||||||
|
|
||||||
|
.h2 { |
||||||
|
font-size: 22px |
||||||
|
} |
||||||
|
|
||||||
|
.align-center { |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
@ -0,0 +1,579 @@ |
|||||||
|
// src/components/BlogEditor.tsx
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useCallback } from 'react'; |
||||||
|
import { createEditor, Descendant, Editor, Transforms, Range } from 'slate' |
||||||
|
import SvgIcon from '@material-ui/core/SvgIcon' |
||||||
|
import { |
||||||
|
Slate, |
||||||
|
Editable, |
||||||
|
withReact, |
||||||
|
RenderElementProps, |
||||||
|
RenderLeafProps, |
||||||
|
useSlate |
||||||
|
} from 'slate-react' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { CustomElement, CustomText, FormatMark } from './customTypes' |
||||||
|
import './BlogEditor.css' |
||||||
|
import { Modal, Box, TextField, Button } from '@mui/material' |
||||||
|
|
||||||
|
import { AlignCenterSVG } from '../../assets/svgs/AlignCenterSVG' |
||||||
|
import { BoldSVG } from '../../assets/svgs/BoldSVG' |
||||||
|
import { ItalicSVG } from '../../assets/svgs/ItalicSVG' |
||||||
|
import { UnderlineSVG } from '../../assets/svgs/UnderlineSVG' |
||||||
|
import { H2SVG } from '../../assets/svgs/H2SVG' |
||||||
|
import { H3SVG } from '../../assets/svgs/H3SVG' |
||||||
|
import { AlignLeftSVG } from '../../assets/svgs/AlignLeftSVG' |
||||||
|
import { AlignRightSVG } from '../../assets/svgs/AlignRightSVG' |
||||||
|
import { CodeBlockSVG } from '../../assets/svgs/CodeBlockSVG' |
||||||
|
import { LinkSVG } from '../../assets/svgs/LinkSVG' |
||||||
|
|
||||||
|
const initialValue: Descendant[] = [ |
||||||
|
{ |
||||||
|
type: 'paragraph', |
||||||
|
children: [{ text: 'Start writing your blog post...' }] |
||||||
|
} |
||||||
|
] |
||||||
|
|
||||||
|
interface MyComponentProps { |
||||||
|
addPostSection?: (value: any) => void |
||||||
|
editPostSection?: (value: any, section: any) => void |
||||||
|
defaultValue?: any |
||||||
|
section?: any |
||||||
|
value: any |
||||||
|
setValue: (value: any) => void |
||||||
|
editorKey?: number |
||||||
|
mode?: string |
||||||
|
disableMaxHeight?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
const ModalBox = styled(Box)(({ theme }) => ({ |
||||||
|
position: 'absolute', |
||||||
|
top: '50%', |
||||||
|
left: '50%', |
||||||
|
transform: 'translate(-50%, -50%)', |
||||||
|
backgroundColor: theme.palette.background.paper, |
||||||
|
boxShadow: theme.shadows[5], |
||||||
|
padding: theme.spacing(2, 4, 3), |
||||||
|
gap: '15px', |
||||||
|
borderRadius: '5px', |
||||||
|
alignItems: 'center', |
||||||
|
display: 'flex', |
||||||
|
flex: 0 |
||||||
|
})) |
||||||
|
|
||||||
|
const BlogEditor: React.FC<MyComponentProps> = ({ |
||||||
|
addPostSection, |
||||||
|
editPostSection, |
||||||
|
defaultValue, |
||||||
|
section, |
||||||
|
value, |
||||||
|
setValue, |
||||||
|
editorKey, |
||||||
|
mode, |
||||||
|
disableMaxHeight |
||||||
|
}) => { |
||||||
|
const editor = useMemo(() => withReact(createEditor()), []) |
||||||
|
|
||||||
|
// const [value, setValue] = useState(defaultValue || initialValue);
|
||||||
|
const isTextAlignmentActive = (editor: Editor, alignment: string) => { |
||||||
|
const [match] = Editor.nodes(editor, { |
||||||
|
match: (n) => { |
||||||
|
return n?.textAlign === alignment?.replace(/^align-/, '') |
||||||
|
} |
||||||
|
}) |
||||||
|
return !!match |
||||||
|
} |
||||||
|
|
||||||
|
const toggleTextAlignment = (editor: Editor, alignment: string) => { |
||||||
|
const isActive = isTextAlignmentActive(editor, alignment) |
||||||
|
Transforms.setNodes( |
||||||
|
editor, |
||||||
|
{ style: { textAlign: isActive ? 'inherit' : alignment } }, |
||||||
|
{ match: (n) => Editor.isBlock(editor, n) } |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const toggleMark = (editor: Editor, format: FormatMark) => { |
||||||
|
if ( |
||||||
|
format === 'align-left' || |
||||||
|
format === 'align-center' || |
||||||
|
format === 'align-right' |
||||||
|
) { |
||||||
|
toggleTextAlignment(editor, format) |
||||||
|
} else { |
||||||
|
const isActive = Editor?.marks(editor)?.[format] === true |
||||||
|
if (isActive) { |
||||||
|
Editor?.removeMark(editor, format) |
||||||
|
} else { |
||||||
|
Editor?.addMark(editor, format, true) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const newValue = useMemo(() => [...(value || initialValue)], [value]) |
||||||
|
|
||||||
|
const types = ['paragraph', 'heading-2', 'heading-3'] |
||||||
|
|
||||||
|
const setTextAlignment = (editor, alignment) => { |
||||||
|
const isActive = isTextAlignmentActive(editor, alignment) |
||||||
|
const alignmentType = '' |
||||||
|
Transforms?.setNodes( |
||||||
|
editor, |
||||||
|
{ |
||||||
|
textAlign: isActive ? null : alignment |
||||||
|
}, |
||||||
|
{ |
||||||
|
match: (n) => |
||||||
|
n.type === 'heading-2' || |
||||||
|
n.type === 'heading-3' || |
||||||
|
n.type === 'paragraph' |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const ToolbarButton: React.FC<{ |
||||||
|
format: FormatMark | string |
||||||
|
label: string |
||||||
|
editor: Editor |
||||||
|
children: React.ReactNode |
||||||
|
}> = ({ format, label, editor, children }) => { |
||||||
|
useSlate() |
||||||
|
|
||||||
|
let onClick = () => { |
||||||
|
if (format === 'heading-2' || format === 'heading-3') { |
||||||
|
toggleBlock(editor, format) |
||||||
|
} else if ( |
||||||
|
format === 'bold' || |
||||||
|
format === 'italic' || |
||||||
|
format === 'underline' || |
||||||
|
format === '' |
||||||
|
) { |
||||||
|
toggleMark(editor, format) |
||||||
|
} else if ( |
||||||
|
format === 'align-left' || |
||||||
|
format === 'align-center' || |
||||||
|
format === 'align-right' |
||||||
|
) { |
||||||
|
setTextAlignment(editor, format?.replace(/^align-/, '')) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let isActive = false |
||||||
|
|
||||||
|
try { |
||||||
|
if ( |
||||||
|
format === 'align-left' || |
||||||
|
format === 'align-center' || |
||||||
|
format === 'align-right' |
||||||
|
) { |
||||||
|
isActive = isTextAlignmentActive(editor, format) |
||||||
|
} else if (format === 'heading-2' || format === 'heading-3') { |
||||||
|
isActive = isBlockActive(editor, format) |
||||||
|
} else if ( |
||||||
|
format === 'bold' || |
||||||
|
format === 'italic' || |
||||||
|
format === 'underline' || |
||||||
|
format === '' |
||||||
|
) { |
||||||
|
isActive = Editor?.marks(editor)?.[format] === true |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
className={`toolbar-button ${isActive ? 'active' : ''}`} |
||||||
|
onMouseDown={(event) => { |
||||||
|
event.preventDefault() |
||||||
|
onClick() |
||||||
|
}} |
||||||
|
> |
||||||
|
{children ? children : label} |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const ToolbarButtonCodeBlock: React.FC<{ |
||||||
|
format: FormatMark | string |
||||||
|
label: string |
||||||
|
editor: Editor |
||||||
|
children: React.ReactNode |
||||||
|
}> = ({ format, label, editor, children }) => { |
||||||
|
const editor2 = useSlate() |
||||||
|
|
||||||
|
let onClick = () => { |
||||||
|
if (format === 'code-block') { |
||||||
|
toggleBlock(editor, 'code-block') |
||||||
|
} |
||||||
|
} |
||||||
|
let isActive = false |
||||||
|
try { |
||||||
|
if (format === 'code-block') { |
||||||
|
isActive = isBlockActive(editor, format) |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
className={`toolbar-button ${isActive ? 'active' : ''}`} |
||||||
|
onMouseDown={(event) => { |
||||||
|
event.preventDefault() |
||||||
|
onClick() |
||||||
|
}} |
||||||
|
> |
||||||
|
{children ? children : label} |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const ToolbarButtonAlign: React.FC<{ |
||||||
|
format: string |
||||||
|
label: string |
||||||
|
editor: Editor |
||||||
|
}> = ({ format, label, editor }) => { |
||||||
|
const isActive = |
||||||
|
Editor?.nodes(editor, { |
||||||
|
match: (n) => n?.align === format |
||||||
|
})?.length > 0 |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
className={`toolbar-button ${isActive ? 'active' : ''}`} |
||||||
|
onMouseDown={(event) => { |
||||||
|
event.preventDefault() |
||||||
|
Transforms?.setNodes( |
||||||
|
editor, |
||||||
|
{ align: format }, |
||||||
|
{ match: (n) => Editor?.isBlock(editor, n) } |
||||||
|
) |
||||||
|
}} |
||||||
|
> |
||||||
|
{label} |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const ToolbarButtonCodeLink: React.FC<{ |
||||||
|
format: FormatMark | string |
||||||
|
label: string |
||||||
|
editor: Editor |
||||||
|
children: React.ReactNode |
||||||
|
}> = ({ format, label, editor, children }) => { |
||||||
|
useSlate() |
||||||
|
|
||||||
|
let isActive = false |
||||||
|
try { |
||||||
|
if (format === 'link') { |
||||||
|
isActive = !!Editor?.marks(editor)?.link |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
className={`toolbar-button ${isActive ? 'active' : ''}`} |
||||||
|
onMouseDown={(event) => { |
||||||
|
event.preventDefault() |
||||||
|
const isActive2 = !!Editor?.marks(editor)?.link |
||||||
|
if (isActive2) { |
||||||
|
Editor?.removeMark(editor, 'link') |
||||||
|
return |
||||||
|
} |
||||||
|
// const url = window.prompt('Enter the URL of the link:')
|
||||||
|
setOpen(true) |
||||||
|
}} |
||||||
|
> |
||||||
|
{children ? children : label} |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
// Create a toggleBlock function and an isBlockActive function to handle block elements
|
||||||
|
const toggleBlock = (editor: Editor, format: string) => { |
||||||
|
const isActive = isBlockActive(editor, format) |
||||||
|
Transforms?.unwrapNodes(editor, { |
||||||
|
match: (n) => Editor?.isBlock(editor, n), |
||||||
|
split: true |
||||||
|
}) |
||||||
|
|
||||||
|
if (isActive) { |
||||||
|
Transforms?.setNodes(editor, { type: 'paragraph' }) |
||||||
|
} else { |
||||||
|
Transforms?.setNodes(editor, { type: format }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const isBlockActive = (editor: Editor, format: string) => { |
||||||
|
const [match] = Editor?.nodes(editor, { |
||||||
|
match: (n) => n?.type === format |
||||||
|
}) |
||||||
|
return !!match |
||||||
|
} |
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => { |
||||||
|
if (event.key === 'Enter' && isBlockActive(editor, 'code-block')) { |
||||||
|
event.preventDefault() |
||||||
|
editor?.insertText('\n') |
||||||
|
} |
||||||
|
|
||||||
|
if (event.key === 'ArrowDown' && isBlockActive(editor, 'code-block')) { |
||||||
|
event.preventDefault() |
||||||
|
Transforms?.insertNodes(editor, { |
||||||
|
type: 'paragraph', |
||||||
|
children: [{ text: '' }] |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleChange = (newValue: Descendant[]) => { |
||||||
|
setValue(newValue) |
||||||
|
} |
||||||
|
|
||||||
|
const toggleLink = (editor: Editor, url: string) => { |
||||||
|
const { selection } = editor |
||||||
|
|
||||||
|
if (selection && !Range.isCollapsed(selection)) { |
||||||
|
const isLink = Editor?.marks(editor)?.link === true |
||||||
|
const isInsideLink = isLinkActive(editor) |
||||||
|
|
||||||
|
if (isLink) { |
||||||
|
Editor?.removeMark(editor, 'link') |
||||||
|
} else if (url) { |
||||||
|
Editor?.addMark(editor, 'link', url) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
|
||||||
|
const initialValue = 'qortal://' |
||||||
|
const [inputValue, setInputValue] = useState(initialValue) |
||||||
|
|
||||||
|
const handleChangeLink = (event) => { |
||||||
|
const newValue = event?.target?.value |
||||||
|
if (newValue?.startsWith(initialValue)) { |
||||||
|
setInputValue(newValue) |
||||||
|
} |
||||||
|
} |
||||||
|
const isLinkActive = (editor: Editor) => { |
||||||
|
const [link] = Editor?.nodes(editor, { |
||||||
|
match: (n) => n?.type === 'link' |
||||||
|
}) |
||||||
|
return !!link |
||||||
|
} |
||||||
|
const handleSaveClick = () => { |
||||||
|
const marks = Editor?.marks(editor) |
||||||
|
const isLink = marks?.link === true |
||||||
|
|
||||||
|
if (isLink) { |
||||||
|
Editor?.removeMark(editor, 'link') |
||||||
|
return // Return early to skip the rest of the function
|
||||||
|
} |
||||||
|
toggleLink(editor, inputValue) |
||||||
|
setOpen(false) |
||||||
|
} |
||||||
|
|
||||||
|
const onClose = () => { |
||||||
|
setOpen(false) |
||||||
|
} |
||||||
|
|
||||||
|
const handlePaste = (event: React.ClipboardEvent) => { |
||||||
|
event.preventDefault() |
||||||
|
const text = event?.clipboardData?.getData('text/plain') |
||||||
|
const isCodeBlock = isBlockActive(editor, 'code-block') |
||||||
|
|
||||||
|
if (isCodeBlock) { |
||||||
|
const lines = text?.split('\n') |
||||||
|
const fragment: Descendant[] = [ |
||||||
|
{ |
||||||
|
type: 'code-block', |
||||||
|
children: lines?.map((line) => ({ |
||||||
|
type: 'code-line', |
||||||
|
children: [{ text: line }] |
||||||
|
})) |
||||||
|
} |
||||||
|
] |
||||||
|
|
||||||
|
Transforms?.insertFragment(editor, fragment) |
||||||
|
} else if (text) { |
||||||
|
const fragment = text?.split('\n').map((line) => ({ |
||||||
|
type: 'paragraph', |
||||||
|
children: [{ text: line }] |
||||||
|
})) |
||||||
|
|
||||||
|
Transforms?.insertFragment(editor, fragment) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
border: '1px solid', |
||||||
|
borderRadius: '5px', |
||||||
|
marginTop: '20px', |
||||||
|
padding: '10px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Slate |
||||||
|
editor={editor} |
||||||
|
value={newValue} |
||||||
|
onChange={(newValue) => handleChange(newValue)} |
||||||
|
key={editorKey || 1} |
||||||
|
> |
||||||
|
<div className="toolbar"> |
||||||
|
<ToolbarButton format="bold" label="B" editor={editor}> |
||||||
|
<BoldSVG height="24px" width="auto" /> |
||||||
|
</ToolbarButton> |
||||||
|
<ToolbarButton format="italic" label="I" editor={editor}> |
||||||
|
<ItalicSVG height="24px" width="auto" /> |
||||||
|
</ToolbarButton> |
||||||
|
<ToolbarButton format="underline" label="U" editor={editor}> |
||||||
|
<UnderlineSVG height="24px" width="auto" /> |
||||||
|
</ToolbarButton> |
||||||
|
|
||||||
|
<ToolbarButton format="heading-2" label="H2" editor={editor}> |
||||||
|
<H2SVG height="24px" width="auto" /> |
||||||
|
</ToolbarButton> |
||||||
|
<ToolbarButton format="heading-3" label="H3" editor={editor}> |
||||||
|
<H3SVG height="24px" width="auto" /> |
||||||
|
</ToolbarButton> |
||||||
|
<ToolbarButton format="align-left" label="L" editor={editor}> |
||||||
|
<AlignLeftSVG height="24px" width="auto" /> |
||||||
|
</ToolbarButton> |
||||||
|
<ToolbarButton format="align-center" label="C" editor={editor}> |
||||||
|
<AlignCenterSVG height="24px" width="auto" /> |
||||||
|
</ToolbarButton> |
||||||
|
<ToolbarButton format="align-right" label="R" editor={editor}> |
||||||
|
<AlignRightSVG height="24px" width="auto" /> |
||||||
|
</ToolbarButton> |
||||||
|
|
||||||
|
<ToolbarButtonCodeBlock |
||||||
|
format="code-block" |
||||||
|
label="Code" |
||||||
|
editor={editor} |
||||||
|
> |
||||||
|
<CodeBlockSVG height="24px" width="auto" /> |
||||||
|
</ToolbarButtonCodeBlock> |
||||||
|
<ToolbarButtonCodeLink format="link" label="Link" editor={editor}> |
||||||
|
<LinkSVG height="24px" width="auto" /> |
||||||
|
</ToolbarButtonCodeLink> |
||||||
|
</div> |
||||||
|
<Editable |
||||||
|
className="blog-editor" |
||||||
|
renderElement={(props) => renderElement({ ...props, mode })} |
||||||
|
renderLeaf={renderLeaf} |
||||||
|
onKeyDown={handleKeyDown} |
||||||
|
onPaste={handlePaste} |
||||||
|
mode={mode} |
||||||
|
style={{ |
||||||
|
maxHeight: disableMaxHeight && 'unset' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Slate> |
||||||
|
<Modal open={open} onClose={onClose}> |
||||||
|
<ModalBox> |
||||||
|
<TextField |
||||||
|
label="Link" |
||||||
|
value={inputValue} |
||||||
|
onChange={handleChangeLink} |
||||||
|
/> |
||||||
|
<Button variant="contained" onClick={handleSaveClick}> |
||||||
|
Save |
||||||
|
</Button> |
||||||
|
</ModalBox> |
||||||
|
</Modal> |
||||||
|
{editPostSection && ( |
||||||
|
<Button onClick={() => editPostSection(value, section)}> |
||||||
|
Edit Section |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default BlogEditor |
||||||
|
|
||||||
|
type ExtendedRenderElementProps = RenderElementProps & { mode?: string } |
||||||
|
|
||||||
|
export const renderElement = ({ |
||||||
|
attributes, |
||||||
|
children, |
||||||
|
element, |
||||||
|
mode |
||||||
|
}: ExtendedRenderElementProps) => { |
||||||
|
switch (element.type) { |
||||||
|
case 'block-quote': |
||||||
|
return <blockquote {...attributes}>{children}</blockquote> |
||||||
|
case 'heading-2': |
||||||
|
return ( |
||||||
|
<h2 |
||||||
|
className="h2" |
||||||
|
{...attributes} |
||||||
|
style={{ textAlign: element.textAlign }} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</h2> |
||||||
|
) |
||||||
|
case 'heading-3': |
||||||
|
return ( |
||||||
|
<h3 |
||||||
|
className="h3" |
||||||
|
{...attributes} |
||||||
|
style={{ textAlign: element.textAlign }} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</h3> |
||||||
|
) |
||||||
|
case 'code-block': |
||||||
|
return ( |
||||||
|
<pre {...attributes} className="code-block"> |
||||||
|
<code>{children}</code> |
||||||
|
</pre> |
||||||
|
) |
||||||
|
case 'code-line': |
||||||
|
return <div {...attributes}>{children}</div> |
||||||
|
case 'link': |
||||||
|
return ( |
||||||
|
<a href={element.url} {...attributes}> |
||||||
|
{children} |
||||||
|
</a> |
||||||
|
) |
||||||
|
default: |
||||||
|
return ( |
||||||
|
<p |
||||||
|
className={`paragraph${mode ? `-${mode}` : ''}`} |
||||||
|
{...attributes} |
||||||
|
style={{ textAlign: element.textAlign }} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</p> |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
export const renderLeaf = ({ attributes, children, leaf }: RenderLeafProps) => { |
||||||
|
let el = children |
||||||
|
|
||||||
|
if (leaf.bold) { |
||||||
|
el = <strong>{el}</strong> |
||||||
|
} |
||||||
|
|
||||||
|
if (leaf.italic) { |
||||||
|
el = <em>{el}</em> |
||||||
|
} |
||||||
|
|
||||||
|
if (leaf.underline) { |
||||||
|
el = <u>{el}</u> |
||||||
|
} |
||||||
|
|
||||||
|
if (leaf.link) { |
||||||
|
el = ( |
||||||
|
<a href={leaf.link} {...attributes}> |
||||||
|
{el} |
||||||
|
</a> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return <span {...attributes}>{el}</span> |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
import React, { useMemo } from 'react'; |
||||||
|
import { createEditor, Descendant, Editor } from 'slate'; |
||||||
|
import { withReact, Slate, Editable, RenderElementProps, RenderLeafProps } from 'slate-react'; |
||||||
|
import { renderElement, renderLeaf } from './BlogEditor'; |
||||||
|
|
||||||
|
interface ReadOnlySlateProps { |
||||||
|
content: any |
||||||
|
mode?: string |
||||||
|
} |
||||||
|
const ReadOnlySlate: React.FC<ReadOnlySlateProps> = ({ content, mode }) => { |
||||||
|
const editor = useMemo(() => withReact(createEditor()), []) |
||||||
|
const value = useMemo(() => content, [content]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<Slate editor={editor} value={value} onChange={() => {}}> |
||||||
|
<Editable |
||||||
|
readOnly |
||||||
|
renderElement={(props) => renderElement({ ...props, mode })} |
||||||
|
renderLeaf={renderLeaf} |
||||||
|
/> |
||||||
|
</Slate> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default ReadOnlySlate; |
@ -0,0 +1,47 @@ |
|||||||
|
// src/customTypes.ts
|
||||||
|
import { BaseEditor } from 'slate'; |
||||||
|
import { ReactEditor } from 'slate-react'; |
||||||
|
|
||||||
|
export type CustomText = { |
||||||
|
text: string |
||||||
|
bold?: boolean |
||||||
|
italic?: boolean |
||||||
|
underline?: boolean |
||||||
|
code?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export type HeadingElement = { |
||||||
|
type: 'heading' |
||||||
|
children: CustomText[] |
||||||
|
} |
||||||
|
|
||||||
|
export type BlockQuoteElement = { |
||||||
|
type: 'block-quote' |
||||||
|
children: CustomText[] |
||||||
|
} |
||||||
|
|
||||||
|
export type ParagraphElement = { |
||||||
|
type: 'paragraph' |
||||||
|
children: CustomText[] |
||||||
|
} |
||||||
|
|
||||||
|
export type CodeBlockElement = { |
||||||
|
type: 'code-block' |
||||||
|
children: CustomText[] |
||||||
|
} |
||||||
|
|
||||||
|
export type CustomElement = |
||||||
|
| HeadingElement |
||||||
|
| BlockQuoteElement |
||||||
|
| ParagraphElement |
||||||
|
| CodeBlockElement |
||||||
|
|
||||||
|
export type FormatMark = 'bold' | 'italic' | 'underline' | 'code' |
||||||
|
|
||||||
|
declare module 'slate' { |
||||||
|
interface CustomTypes { |
||||||
|
Editor: BaseEditor & ReactEditor; |
||||||
|
Element: CustomElement; |
||||||
|
Text: CustomText; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,112 @@ |
|||||||
|
import { AppBar, Button, Toolbar, Typography, Box } from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
|
||||||
|
export const QblogLogoContainer = styled('img')({ |
||||||
|
width: 'auto', |
||||||
|
height: 'auto', |
||||||
|
userSelect: 'none', |
||||||
|
objectFit: 'contain', |
||||||
|
cursor: 'pointer' |
||||||
|
}) |
||||||
|
|
||||||
|
export const CustomAppBar = styled(AppBar)(({ theme }) => ({ |
||||||
|
backgroundColor: theme.palette.mode === "light" ? theme.palette.background.default : "#19191b", |
||||||
|
[theme.breakpoints.only('xs')]: { |
||||||
|
gap: '15px', |
||||||
|
}, |
||||||
|
})) |
||||||
|
|
||||||
|
export const CustomToolbar = styled(Toolbar)({ |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'space-between', |
||||||
|
alignItems: 'center' |
||||||
|
}) |
||||||
|
|
||||||
|
export const CustomTitle = styled(Typography)({ |
||||||
|
fontWeight: 600, |
||||||
|
color: '#000000' |
||||||
|
}) |
||||||
|
|
||||||
|
export const StyledButton = styled(Button)(({ theme }) => ({ |
||||||
|
fontWeight: 600, |
||||||
|
color: theme.palette.text.primary |
||||||
|
})) |
||||||
|
|
||||||
|
export const CreateBlogButton = styled(Button)(({ theme }) => ({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'row', |
||||||
|
alignItems: 'center', |
||||||
|
padding: '8px 15px', |
||||||
|
borderRadius: "40px", |
||||||
|
gap: '4px', |
||||||
|
backgroundColor: theme.palette.secondary.main, |
||||||
|
color: '#fff', |
||||||
|
fontFamily: "Arial", |
||||||
|
transition: "all 0.3s ease-in-out", |
||||||
|
boxShadow: "none", |
||||||
|
"&:hover": { |
||||||
|
cursor: "pointer", |
||||||
|
boxShadow: "rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px;", |
||||||
|
backgroundColor: theme.palette.secondary.main, |
||||||
|
filter: "brightness(1.1)", |
||||||
|
} |
||||||
|
})) |
||||||
|
|
||||||
|
export const AuthenticateButton = styled(Button)({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'row', |
||||||
|
alignItems: 'center', |
||||||
|
padding: '8px 15px', |
||||||
|
borderRadius: "40px", |
||||||
|
gap: '4px', |
||||||
|
backgroundColor: "#4ACE91", |
||||||
|
color: '#fff', |
||||||
|
fontFamily: "Arial", |
||||||
|
transition: "all 0.3s ease-in-out", |
||||||
|
boxShadow: "none", |
||||||
|
"&:hover": { |
||||||
|
cursor: "pointer", |
||||||
|
boxShadow: "rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px;", |
||||||
|
backgroundColor: "#4ACE91", |
||||||
|
filter: "brightness(1.1)", |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
export const AvatarContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
"&:hover": { |
||||||
|
cursor: "pointer", |
||||||
|
"& #expand-icon": { |
||||||
|
transition: "all 0.3s ease-in-out", |
||||||
|
filter: "brightness(0.7)", |
||||||
|
} |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
export const DropdownContainer = styled(Box)(({ theme }) => ({ |
||||||
|
display: "flex", |
||||||
|
alignItems: "center", |
||||||
|
gap: "5px", |
||||||
|
backgroundColor: theme.palette.primary.main, |
||||||
|
padding: "10px 15px", |
||||||
|
transition: "all 0.4s ease-in-out", |
||||||
|
"&:hover": { |
||||||
|
cursor: "pointer", |
||||||
|
filter: "brightness(0.95)" |
||||||
|
} |
||||||
|
})); |
||||||
|
|
||||||
|
export const DropdownText = styled(Typography)(({ theme }) => ({ |
||||||
|
fontFamily: "Arial", |
||||||
|
fontSize: "16px", |
||||||
|
color: theme.palette.text.primary, |
||||||
|
userSelect: "none" |
||||||
|
})); |
||||||
|
|
||||||
|
export const NavbarName = styled(Typography)(({ theme }) => ({ |
||||||
|
fontFamily: "Arial", |
||||||
|
fontSize: "18px", |
||||||
|
color: theme.palette.text.primary, |
||||||
|
margin: "0 10px", |
||||||
|
})); |
@ -0,0 +1,169 @@ |
|||||||
|
import React, { useRef, useState } from 'react' |
||||||
|
import { Box, Popover, useTheme } from '@mui/material' |
||||||
|
import ExitToAppIcon from '@mui/icons-material/ExitToApp' |
||||||
|
import { useNavigate } from 'react-router-dom' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../../state/store' |
||||||
|
import { UserNavbar } from '../../common/UserNavbar/UserNavbar' |
||||||
|
import { removePrefix } from '../../../utils/blogIdformats' |
||||||
|
import { useLocation } from 'react-router-dom' |
||||||
|
import { BlockedNamesModal } from '../../common/BlockedNamesModal/BlockedNamesModal' |
||||||
|
|
||||||
|
import { |
||||||
|
AvatarContainer, |
||||||
|
CustomAppBar, |
||||||
|
CustomToolbar, |
||||||
|
DropdownContainer, |
||||||
|
DropdownText, |
||||||
|
QblogLogoContainer, |
||||||
|
AuthenticateButton, |
||||||
|
NavbarName |
||||||
|
} from './Navbar-styles' |
||||||
|
import { AccountCircleSVG } from '../../../assets/svgs/AccountCircleSVG' |
||||||
|
import QMailLogo from '../../../assets/img/qmaillogo.png' |
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' |
||||||
|
import PersonOffIcon from '@mui/icons-material/PersonOff' |
||||||
|
import { |
||||||
|
addFilteredPosts, |
||||||
|
setFilterValue, |
||||||
|
setIsFiltering |
||||||
|
} from '../../../state/features/blogSlice' |
||||||
|
interface Props { |
||||||
|
isAuthenticated: boolean |
||||||
|
userName: string | null |
||||||
|
userAvatar: string |
||||||
|
authenticate: () => void |
||||||
|
} |
||||||
|
|
||||||
|
const NavBar: React.FC<Props> = ({ |
||||||
|
isAuthenticated, |
||||||
|
userName, |
||||||
|
userAvatar, |
||||||
|
authenticate |
||||||
|
}) => { |
||||||
|
const navigate = useNavigate() |
||||||
|
const dispatch = useDispatch() |
||||||
|
const theme = useTheme() |
||||||
|
const { visitingBlog } = useSelector((state: RootState) => state.global) |
||||||
|
const location = useLocation() |
||||||
|
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) |
||||||
|
const [isOpenModal, setIsOpenModal] = React.useState<boolean>(false) |
||||||
|
const searchValRef = useRef('') |
||||||
|
const inputRef = useRef<HTMLInputElement>(null) |
||||||
|
const stripBlogId = removePrefix(visitingBlog?.blogId || '') |
||||||
|
if (visitingBlog?.navbarConfig && location?.pathname?.includes(stripBlogId)) { |
||||||
|
return ( |
||||||
|
<UserNavbar |
||||||
|
title={visitingBlog?.title || ''} |
||||||
|
menuItems={visitingBlog?.navbarConfig?.navItems || []} |
||||||
|
name={visitingBlog?.name || ''} |
||||||
|
blogId={visitingBlog?.blogId || ''} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { |
||||||
|
const target = event.currentTarget as unknown as HTMLButtonElement | null |
||||||
|
setAnchorEl(target) |
||||||
|
} |
||||||
|
|
||||||
|
const handleClose = () => { |
||||||
|
setAnchorEl(null) |
||||||
|
} |
||||||
|
const onClose = () => { |
||||||
|
setIsOpenModal(false) |
||||||
|
} |
||||||
|
const open = Boolean(anchorEl) |
||||||
|
const id = open ? 'simple-popover' : undefined |
||||||
|
|
||||||
|
return ( |
||||||
|
<CustomAppBar position="sticky" elevation={2}> |
||||||
|
<CustomToolbar variant="dense"> |
||||||
|
<QblogLogoContainer |
||||||
|
style={{ |
||||||
|
height: '32px' |
||||||
|
}} |
||||||
|
src={QMailLogo} |
||||||
|
alt="Q-Mail Logo" |
||||||
|
onClick={() => { |
||||||
|
navigate(`/`) |
||||||
|
dispatch(setIsFiltering(false)) |
||||||
|
dispatch(setFilterValue('')) |
||||||
|
dispatch(addFilteredPosts([])) |
||||||
|
searchValRef.current = '' |
||||||
|
if (!inputRef.current) return |
||||||
|
inputRef.current.value = '' |
||||||
|
}} |
||||||
|
/> |
||||||
|
|
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
{/* Add isAuthenticated && before username and wrap StyledButton in this condition*/} |
||||||
|
{!isAuthenticated && ( |
||||||
|
<AuthenticateButton onClick={authenticate}> |
||||||
|
<ExitToAppIcon /> |
||||||
|
Authenticate |
||||||
|
</AuthenticateButton> |
||||||
|
)} |
||||||
|
|
||||||
|
{isAuthenticated && userName && ( |
||||||
|
<AvatarContainer onClick={handleClick}> |
||||||
|
<NavbarName>{userName}</NavbarName> |
||||||
|
{!userAvatar ? ( |
||||||
|
<AccountCircleSVG |
||||||
|
color={theme.palette.text.primary} |
||||||
|
width="32" |
||||||
|
height="32" |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<img |
||||||
|
src={userAvatar} |
||||||
|
alt="User Avatar" |
||||||
|
width="32" |
||||||
|
height="32" |
||||||
|
style={{ |
||||||
|
borderRadius: '50%' |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
<ExpandMoreIcon id="expand-icon" sx={{ color: '#ACB6BF' }} /> |
||||||
|
</AvatarContainer> |
||||||
|
)} |
||||||
|
<Popover |
||||||
|
id={id} |
||||||
|
open={open} |
||||||
|
anchorEl={anchorEl} |
||||||
|
onClose={handleClose} |
||||||
|
anchorOrigin={{ |
||||||
|
vertical: 'bottom', |
||||||
|
horizontal: 'left' |
||||||
|
}} |
||||||
|
> |
||||||
|
<DropdownContainer |
||||||
|
onClick={() => { |
||||||
|
setIsOpenModal(true) |
||||||
|
handleClose() |
||||||
|
}} |
||||||
|
> |
||||||
|
<PersonOffIcon |
||||||
|
sx={{ |
||||||
|
color: '#e35050' |
||||||
|
}} |
||||||
|
/> |
||||||
|
<DropdownText>Blocked Names</DropdownText> |
||||||
|
</DropdownContainer> |
||||||
|
</Popover> |
||||||
|
{isOpenModal && ( |
||||||
|
<BlockedNamesModal open={isOpenModal} onClose={onClose} /> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</CustomToolbar> |
||||||
|
</CustomAppBar> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default NavBar |
@ -0,0 +1,70 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import Button from '@mui/material/Button' |
||||||
|
import Dialog from '@mui/material/Dialog' |
||||||
|
import DialogActions from '@mui/material/DialogActions' |
||||||
|
import DialogContent from '@mui/material/DialogContent' |
||||||
|
import DialogContentText from '@mui/material/DialogContentText' |
||||||
|
import DialogTitle from '@mui/material/DialogTitle' |
||||||
|
import localForage from 'localforage' |
||||||
|
import { useTheme } from '@mui/material' |
||||||
|
const generalLocal = localForage.createInstance({ |
||||||
|
name: 'q-blog-general' |
||||||
|
}) |
||||||
|
|
||||||
|
export default function ConsentModal() { |
||||||
|
const theme = useTheme() |
||||||
|
|
||||||
|
const [open, setOpen] = React.useState(false) |
||||||
|
|
||||||
|
const handleClose = () => { |
||||||
|
setOpen(false) |
||||||
|
} |
||||||
|
|
||||||
|
const getIsConsented = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const hasConsented = await generalLocal.getItem('general-consent') |
||||||
|
if (hasConsented) return |
||||||
|
|
||||||
|
setOpen(true) |
||||||
|
generalLocal.setItem('general-consent', true) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getIsConsented() |
||||||
|
}, []) |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<Dialog |
||||||
|
open={open} |
||||||
|
onClose={handleClose} |
||||||
|
aria-labelledby="alert-dialog-title" |
||||||
|
aria-describedby="alert-dialog-description" |
||||||
|
> |
||||||
|
<DialogTitle id="alert-dialog-title">Welcome</DialogTitle> |
||||||
|
<DialogContent> |
||||||
|
<DialogContentText id="alert-dialog-description"> |
||||||
|
The Qortal community, along with its development team and the |
||||||
|
creators of this application, cannot be held accountable for any |
||||||
|
content published or displayed. Furthermore, they bear no |
||||||
|
responsibility for any data loss that may occur as a result of using |
||||||
|
this application. |
||||||
|
</DialogContentText> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial' |
||||||
|
}} |
||||||
|
onClick={handleClose} |
||||||
|
autoFocus |
||||||
|
> |
||||||
|
Close |
||||||
|
</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,247 @@ |
|||||||
|
import React, { useState } from 'react' |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
TextField, |
||||||
|
Typography, |
||||||
|
Modal, |
||||||
|
Select, |
||||||
|
MenuItem, |
||||||
|
FormControl, |
||||||
|
InputLabel, |
||||||
|
SelectChangeEvent, |
||||||
|
OutlinedInput, |
||||||
|
Chip, |
||||||
|
IconButton |
||||||
|
} from '@mui/material' |
||||||
|
import { useDispatch } from 'react-redux' |
||||||
|
import { togglePublishBlogModal } from '../../state/features/globalSlice' |
||||||
|
import AddIcon from '@mui/icons-material/Add' |
||||||
|
import CloseIcon from '@mui/icons-material/Close' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
interface SelectOption { |
||||||
|
id: string |
||||||
|
name: string |
||||||
|
} |
||||||
|
interface MyModalProps { |
||||||
|
open: boolean |
||||||
|
onClose: () => void |
||||||
|
onPublish: ( |
||||||
|
title: string, |
||||||
|
description: string, |
||||||
|
category: string, |
||||||
|
tags: string[] |
||||||
|
) => Promise<void> |
||||||
|
currentBlog: any |
||||||
|
} |
||||||
|
|
||||||
|
const ChipContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
flexWrap: 'wrap', |
||||||
|
'& > *': { |
||||||
|
margin: '4px' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const MyModal: React.FC<MyModalProps> = ({ |
||||||
|
open, |
||||||
|
onClose, |
||||||
|
onPublish, |
||||||
|
currentBlog |
||||||
|
}) => { |
||||||
|
const dispatch = useDispatch() |
||||||
|
|
||||||
|
const [title, setTitle] = useState<string>('') |
||||||
|
const [description, setDescription] = useState<string>('') |
||||||
|
const [errorMessage, setErrorMessage] = useState<string>('') |
||||||
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||||
|
null |
||||||
|
) |
||||||
|
const [inputValue, setInputValue] = useState<string>('') |
||||||
|
const [chips, setChips] = useState<string[]>([]) |
||||||
|
|
||||||
|
const [options, setOptions] = useState<SelectOption[]>([]) |
||||||
|
React.useEffect(() => { |
||||||
|
if (currentBlog) { |
||||||
|
setTitle(currentBlog?.title || '') |
||||||
|
setDescription(currentBlog?.description || '') |
||||||
|
const findCategory = options.find( |
||||||
|
(option) => option.id === currentBlog?.category |
||||||
|
) |
||||||
|
if (!findCategory) return |
||||||
|
setSelectedOption(findCategory) |
||||||
|
if (!currentBlog?.tags || !Array.isArray(currentBlog.tags)) return |
||||||
|
setChips(currentBlog.tags) |
||||||
|
} |
||||||
|
}, [currentBlog, options]) |
||||||
|
|
||||||
|
const handlePublish = async (): Promise<void> => { |
||||||
|
try { |
||||||
|
await onPublish(title, description, selectedOption?.id || '', chips) |
||||||
|
handleClose() |
||||||
|
} catch (error: any) { |
||||||
|
setErrorMessage(error.message) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleClose = (): void => { |
||||||
|
setErrorMessage('') |
||||||
|
dispatch(togglePublishBlogModal(false)) |
||||||
|
onClose() |
||||||
|
} |
||||||
|
|
||||||
|
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||||
|
const optionId = event.target.value |
||||||
|
const selectedOption = options.find((option) => option.id === optionId) |
||||||
|
setSelectedOption(selectedOption || null) |
||||||
|
} |
||||||
|
|
||||||
|
const handleChipDelete = (index: number) => { |
||||||
|
const newChips = [...chips] |
||||||
|
newChips.splice(index, 1) |
||||||
|
setChips(newChips) |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputChange = (event: any) => { |
||||||
|
setInputValue(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputKeyDown = (event: any) => { |
||||||
|
if (event.key === 'Enter' && inputValue !== '') { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} else { |
||||||
|
event.preventDefault() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const addChip = () => { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getListCategories = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const url = `/arbitrary/categories` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
setOptions(responseData) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getListCategories() |
||||||
|
}, [getListCategories]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
open={open} |
||||||
|
onClose={onClose} |
||||||
|
aria-labelledby="modal-title" |
||||||
|
aria-describedby="modal-description" |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
top: '50%', |
||||||
|
left: '50%', |
||||||
|
transform: 'translate(-50%, -50%)', |
||||||
|
width: 400, |
||||||
|
bgcolor: 'background.paper', |
||||||
|
boxShadow: 24, |
||||||
|
p: 4, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: 2 |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography id="modal-title" variant="h6" component="h2"> |
||||||
|
Edit Blog |
||||||
|
</Typography> |
||||||
|
<TextField |
||||||
|
id="modal-title-input" |
||||||
|
label="Title" |
||||||
|
value={title} |
||||||
|
onChange={(e) => setTitle(e.target.value)} |
||||||
|
fullWidth |
||||||
|
/> |
||||||
|
<TextField |
||||||
|
id="modal-description-input" |
||||||
|
label="Description" |
||||||
|
value={description} |
||||||
|
onChange={(e) => setDescription(e.target.value)} |
||||||
|
multiline |
||||||
|
rows={4} |
||||||
|
fullWidth |
||||||
|
/> |
||||||
|
{options.length > 0 && ( |
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<InputLabel id="Category">Select a Category</InputLabel> |
||||||
|
<Select |
||||||
|
labelId="Category" |
||||||
|
input={<OutlinedInput label="Select a Category" />} |
||||||
|
value={selectedOption?.id || ''} |
||||||
|
onChange={handleOptionChange} |
||||||
|
> |
||||||
|
{options.map((option) => ( |
||||||
|
<MenuItem key={option.id} value={option.id}> |
||||||
|
{option.name} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
)} |
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||||
|
<TextField |
||||||
|
label="Add a tag" |
||||||
|
value={inputValue} |
||||||
|
onChange={handleInputChange} |
||||||
|
onKeyDown={handleInputKeyDown} |
||||||
|
disabled={chips.length === 3} |
||||||
|
/> |
||||||
|
|
||||||
|
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||||
|
<AddIcon /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
<ChipContainer> |
||||||
|
{chips.map((chip, index) => ( |
||||||
|
<Chip |
||||||
|
key={index} |
||||||
|
label={chip} |
||||||
|
onDelete={() => handleChipDelete(index)} |
||||||
|
deleteIcon={<CloseIcon />} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ChipContainer> |
||||||
|
</FormControl> |
||||||
|
{errorMessage && ( |
||||||
|
<Typography color="error" variant="body1"> |
||||||
|
{errorMessage} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}> |
||||||
|
<Button variant="outlined" color="error" onClick={handleClose}> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
<Button variant="contained" color="success" onClick={handlePublish}> |
||||||
|
Publish |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</Modal> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default MyModal |
@ -0,0 +1,281 @@ |
|||||||
|
import React, { ChangeEvent, useState } from 'react' |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
TextField, |
||||||
|
Typography, |
||||||
|
Modal, |
||||||
|
Select, |
||||||
|
MenuItem, |
||||||
|
FormControl, |
||||||
|
InputLabel, |
||||||
|
SelectChangeEvent, |
||||||
|
OutlinedInput, |
||||||
|
Chip, |
||||||
|
IconButton |
||||||
|
} from '@mui/material' |
||||||
|
import { useDispatch } from 'react-redux' |
||||||
|
import { togglePublishBlogModal } from '../../state/features/globalSlice' |
||||||
|
import AddIcon from '@mui/icons-material/Add' |
||||||
|
import CloseIcon from '@mui/icons-material/Close' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
interface SelectOption { |
||||||
|
id: string |
||||||
|
name: string |
||||||
|
} |
||||||
|
interface MyModalProps { |
||||||
|
open: boolean |
||||||
|
onClose: () => void |
||||||
|
onPublish: ( |
||||||
|
title: string, |
||||||
|
description: string, |
||||||
|
category: string, |
||||||
|
tags: string[], |
||||||
|
blogIdentifier: string |
||||||
|
) => Promise<void> |
||||||
|
username: string |
||||||
|
} |
||||||
|
|
||||||
|
const ChipContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
flexWrap: 'wrap', |
||||||
|
'& > *': { |
||||||
|
margin: '4px' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const MyModal: React.FC<MyModalProps> = ({ |
||||||
|
open, |
||||||
|
onClose, |
||||||
|
onPublish, |
||||||
|
username |
||||||
|
}) => { |
||||||
|
const dispatch = useDispatch() |
||||||
|
|
||||||
|
const [title, setTitle] = useState<string>('') |
||||||
|
const [description, setDescription] = useState<string>('') |
||||||
|
const [errorMessage, setErrorMessage] = useState<string>('') |
||||||
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||||
|
null |
||||||
|
) |
||||||
|
const [inputValue, setInputValue] = useState<string>('') |
||||||
|
const [chips, setChips] = useState<string[]>([]) |
||||||
|
const [blogIdentifier, setBlogIdentifier] = useState(username || '') |
||||||
|
const [options, setOptions] = useState<SelectOption[]>([]) |
||||||
|
const handlePublish = async (): Promise<void> => { |
||||||
|
try { |
||||||
|
await onPublish( |
||||||
|
title, |
||||||
|
description, |
||||||
|
selectedOption?.id || '', |
||||||
|
chips, |
||||||
|
blogIdentifier |
||||||
|
) |
||||||
|
handleClose() |
||||||
|
} catch (error: any) { |
||||||
|
setErrorMessage(error.message) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleClose = (): void => { |
||||||
|
setTitle('') |
||||||
|
setDescription('') |
||||||
|
setErrorMessage('') |
||||||
|
dispatch(togglePublishBlogModal(false)) |
||||||
|
onClose() |
||||||
|
} |
||||||
|
|
||||||
|
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||||
|
const optionId = event.target.value |
||||||
|
const selectedOption = options.find((option) => option.id === optionId) |
||||||
|
setSelectedOption(selectedOption || null) |
||||||
|
} |
||||||
|
|
||||||
|
const handleChipDelete = (index: number) => { |
||||||
|
const newChips = [...chips] |
||||||
|
newChips.splice(index, 1) |
||||||
|
setChips(newChips) |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputChange = (event: any) => { |
||||||
|
setInputValue(event.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputKeyDown = (event: any) => { |
||||||
|
if (event.key === 'Enter' && inputValue !== '') { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} else { |
||||||
|
event.preventDefault() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const addChip = () => { |
||||||
|
if (chips.length < 5) { |
||||||
|
setChips([...chips, inputValue]) |
||||||
|
setInputValue('') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getListCategories = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const url = `/arbitrary/categories` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
setOptions(responseData) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getListCategories() |
||||||
|
}, [getListCategories]) |
||||||
|
|
||||||
|
const handleInputChangeId = (event: ChangeEvent<HTMLInputElement>) => { |
||||||
|
// Replace any non-alphanumeric and non-space characters with an empty string
|
||||||
|
// Replace multiple spaces with a single dash and remove any dashes that come one after another
|
||||||
|
let newValue = event.target.value |
||||||
|
.replace(/[^a-zA-Z0-9\s-]/g, '') |
||||||
|
.replace(/\s+/g, '-') |
||||||
|
.replace(/-+/g, '-') |
||||||
|
.trim() |
||||||
|
|
||||||
|
if (newValue.toLowerCase().includes('post')) { |
||||||
|
// Replace the 'post' string with an empty string
|
||||||
|
newValue = newValue.replace(/post/gi, '') |
||||||
|
} |
||||||
|
if (newValue.toLowerCase().includes('q-blog')) { |
||||||
|
// Replace the 'q-blog' string with an empty string
|
||||||
|
newValue = newValue.replace(/q-blog/gi, '') |
||||||
|
} |
||||||
|
setBlogIdentifier(newValue) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
open={open} |
||||||
|
onClose={onClose} |
||||||
|
aria-labelledby="modal-title" |
||||||
|
aria-describedby="modal-description" |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
top: '50%', |
||||||
|
left: '50%', |
||||||
|
transform: 'translate(-50%, -50%)', |
||||||
|
width: 400, |
||||||
|
bgcolor: 'background.paper', |
||||||
|
boxShadow: 24, |
||||||
|
p: 4, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: 2, |
||||||
|
overflowY: 'auto', |
||||||
|
maxHeight: '95vh' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography id="modal-title" variant="h6" component="h2"> |
||||||
|
Create blog |
||||||
|
</Typography> |
||||||
|
<TextField |
||||||
|
id="modal-title-input" |
||||||
|
label="Url Preview" |
||||||
|
value={`/${username}/${blogIdentifier}`} |
||||||
|
// onChange={(e) => setTitle(e.target.value)}
|
||||||
|
fullWidth |
||||||
|
disabled={true} |
||||||
|
/> |
||||||
|
|
||||||
|
<TextField |
||||||
|
id="modal-blogId-input" |
||||||
|
label="Blog Id" |
||||||
|
value={blogIdentifier} |
||||||
|
onChange={handleInputChangeId} |
||||||
|
fullWidth |
||||||
|
inputProps={{ maxLength: 25 }} |
||||||
|
/> |
||||||
|
|
||||||
|
<TextField |
||||||
|
id="modal-title-input" |
||||||
|
label="Title" |
||||||
|
value={title} |
||||||
|
onChange={(e) => setTitle(e.target.value)} |
||||||
|
fullWidth |
||||||
|
/> |
||||||
|
<TextField |
||||||
|
id="modal-description-input" |
||||||
|
label="Description" |
||||||
|
value={description} |
||||||
|
onChange={(e) => setDescription(e.target.value)} |
||||||
|
multiline |
||||||
|
rows={4} |
||||||
|
fullWidth |
||||||
|
/> |
||||||
|
{options.length > 0 && ( |
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<InputLabel id="Category">Select a Category</InputLabel> |
||||||
|
<Select |
||||||
|
labelId="Category" |
||||||
|
input={<OutlinedInput label="Select a Category" />} |
||||||
|
value={selectedOption?.id || ''} |
||||||
|
onChange={handleOptionChange} |
||||||
|
> |
||||||
|
{options.map((option) => ( |
||||||
|
<MenuItem key={option.id} value={option.id}> |
||||||
|
{option.name} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
)} |
||||||
|
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||||
|
<TextField |
||||||
|
label="Add a tag" |
||||||
|
value={inputValue} |
||||||
|
onChange={handleInputChange} |
||||||
|
onKeyDown={handleInputKeyDown} |
||||||
|
disabled={chips.length === 3} |
||||||
|
/> |
||||||
|
|
||||||
|
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||||
|
<AddIcon /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
<ChipContainer> |
||||||
|
{chips.map((chip, index) => ( |
||||||
|
<Chip |
||||||
|
key={index} |
||||||
|
label={chip} |
||||||
|
onDelete={() => handleChipDelete(index)} |
||||||
|
deleteIcon={<CloseIcon />} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ChipContainer> |
||||||
|
</FormControl> |
||||||
|
{errorMessage && ( |
||||||
|
<Typography color="error" variant="body1"> |
||||||
|
{errorMessage} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}> |
||||||
|
<Button variant="outlined" color="error" onClick={handleClose}> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
<Button variant="contained" color="success" onClick={handlePublish}> |
||||||
|
Publish |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</Modal> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default MyModal |
@ -0,0 +1,47 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { Box, Modal, useTheme } from '@mui/material' |
||||||
|
|
||||||
|
interface MyModalProps { |
||||||
|
open: boolean |
||||||
|
onClose?: () => void |
||||||
|
onSubmit?: (obj: any) => Promise<void> |
||||||
|
children: any |
||||||
|
customStyles?: any |
||||||
|
} |
||||||
|
|
||||||
|
export const ReusableModal: React.FC<MyModalProps> = ({ |
||||||
|
open, |
||||||
|
onClose, |
||||||
|
onSubmit, |
||||||
|
children, |
||||||
|
customStyles = {} |
||||||
|
}) => { |
||||||
|
const theme = useTheme() |
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
open={open} |
||||||
|
onClose={onClose} |
||||||
|
aria-labelledby="modal-title" |
||||||
|
aria-describedby="modal-description" |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
top: '50%', |
||||||
|
left: '50%', |
||||||
|
transform: 'translate(-50%, -50%)', |
||||||
|
width: '75%', |
||||||
|
bgcolor: theme.palette.primary.main, |
||||||
|
boxShadow: 24, |
||||||
|
p: 4, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: 2, |
||||||
|
...customStyles |
||||||
|
}} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</Box> |
||||||
|
</Modal> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
export const MAIL_SERVICE_TYPE: 'MAIL_PRIVATE' = 'MAIL_PRIVATE' |
||||||
|
export const MAIL_ATTACHMENT_SERVICE_TYPE: 'ATTACHMENT_PRIVATE' = |
||||||
|
'ATTACHMENT_PRIVATE' |
@ -0,0 +1,61 @@ |
|||||||
|
// src/global.d.ts
|
||||||
|
interface QortalRequestOptions { |
||||||
|
action: string |
||||||
|
name?: string |
||||||
|
service?: string |
||||||
|
data64?: string |
||||||
|
title?: string |
||||||
|
description?: string |
||||||
|
category?: string |
||||||
|
tags?: string[] |
||||||
|
identifier?: string |
||||||
|
address?: string |
||||||
|
metaData?: string |
||||||
|
encoding?: string |
||||||
|
includeMetadata?: boolean |
||||||
|
limit?: numebr |
||||||
|
offset?: number |
||||||
|
reverse?: boolean |
||||||
|
resources?: any[] |
||||||
|
filename?: string |
||||||
|
list_name?: string |
||||||
|
item?: string |
||||||
|
items?: strings[] |
||||||
|
tag1?: string |
||||||
|
tag2?: string |
||||||
|
tag3?: string |
||||||
|
tag4?: string |
||||||
|
tag5?: string |
||||||
|
coin?: string |
||||||
|
destinationAddress?: string |
||||||
|
amount?: number |
||||||
|
blob?: Blob |
||||||
|
mimeType?: string |
||||||
|
mode?: string |
||||||
|
txGroupId?: string | number |
||||||
|
after?: number |
||||||
|
before?: number |
||||||
|
groupId?: string | number |
||||||
|
message?: string |
||||||
|
} |
||||||
|
|
||||||
|
declare function qortalRequest(options: QortalRequestOptions): Promise<any> |
||||||
|
declare function qortalRequestWithTimeout( |
||||||
|
options: QortalRequestOptions, |
||||||
|
time: number |
||||||
|
): Promise<any> |
||||||
|
|
||||||
|
declare global { |
||||||
|
interface Window { |
||||||
|
_qdnBase: any // Replace 'any' with the appropriate type if you know it
|
||||||
|
_qdnTheme: string |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
declare global { |
||||||
|
interface Window { |
||||||
|
showSaveFilePicker: ( |
||||||
|
options?: SaveFilePickerOptions |
||||||
|
) => Promise<FileSystemFileHandle> |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
import { useState } from 'react' |
||||||
|
import ConfirmationModal, { |
||||||
|
ModalProps |
||||||
|
} from '../components/common/ConfirmationModal' |
||||||
|
|
||||||
|
const useConfirmationModal = (props: any) => { |
||||||
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false) |
||||||
|
const [resolvePromise, setResolvePromise] = |
||||||
|
useState<(value: boolean) => void>() |
||||||
|
|
||||||
|
const showModal = async () => { |
||||||
|
setIsModalOpen(true) |
||||||
|
return new Promise<boolean>((resolve) => { |
||||||
|
setResolvePromise(() => resolve) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const handleUserAction = (userConfirmed: boolean) => { |
||||||
|
setIsModalOpen(false) |
||||||
|
resolvePromise?.(userConfirmed) |
||||||
|
} |
||||||
|
|
||||||
|
const Modal = () => ( |
||||||
|
<ConfirmationModal |
||||||
|
open={isModalOpen} |
||||||
|
title={props.title} |
||||||
|
message={props.message} |
||||||
|
handleConfirm={() => handleUserAction(true)} |
||||||
|
handleCancel={() => handleUserAction(false)} |
||||||
|
/> |
||||||
|
) |
||||||
|
|
||||||
|
return { Modal, showModal } |
||||||
|
} |
||||||
|
|
||||||
|
export default useConfirmationModal |
@ -0,0 +1,469 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { |
||||||
|
addPosts, |
||||||
|
addToHashMap, |
||||||
|
BlogPost, |
||||||
|
populateFavorites, |
||||||
|
setCountNewPosts, |
||||||
|
upsertFilteredPosts, |
||||||
|
upsertPosts, |
||||||
|
upsertPostsBeginning, |
||||||
|
upsertSubscriptionPosts |
||||||
|
} from '../state/features/blogSlice' |
||||||
|
import { |
||||||
|
setCurrentBlog, |
||||||
|
setIsLoadingGlobal, |
||||||
|
setUserAvatarHash |
||||||
|
} from '../state/features/globalSlice' |
||||||
|
import { RootState } from '../state/store' |
||||||
|
import { fetchAndEvaluatePosts } from '../utils/fetchPosts' |
||||||
|
import { fetchAndEvaluateMail } from '../utils/fetchMail' |
||||||
|
import { |
||||||
|
addToHashMapMail, |
||||||
|
upsertMessages, |
||||||
|
upsertMessagesBeginning |
||||||
|
} from '../state/features/mailSlice' |
||||||
|
import { MAIL_SERVICE_TYPE } from '../constants/mail' |
||||||
|
|
||||||
|
export const useFetchMail = () => { |
||||||
|
const dispatch = useDispatch() |
||||||
|
const hashMapPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.hashMapPosts |
||||||
|
) |
||||||
|
const hashMapMailMessages = useSelector( |
||||||
|
(state: RootState) => state.mail.hashMapMailMessages |
||||||
|
) |
||||||
|
const posts = useSelector((state: RootState) => state.blog.posts) |
||||||
|
const mailMessages = useSelector( |
||||||
|
(state: RootState) => state.mail.mailMessages |
||||||
|
) |
||||||
|
|
||||||
|
const filteredPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.filteredPosts |
||||||
|
) |
||||||
|
const favoritesLocal = useSelector( |
||||||
|
(state: RootState) => state.blog.favoritesLocal |
||||||
|
) |
||||||
|
const favorites = useSelector((state: RootState) => state.blog.favorites) |
||||||
|
const subscriptionPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.subscriptionPosts |
||||||
|
) |
||||||
|
const subscriptions = useSelector( |
||||||
|
(state: RootState) => state.blog.subscriptions |
||||||
|
) |
||||||
|
|
||||||
|
const checkAndUpdatePost = React.useCallback( |
||||||
|
(post: BlogPost) => { |
||||||
|
// Check if the post exists in hashMapPosts
|
||||||
|
const existingPost = hashMapPosts[post.id] |
||||||
|
if (!existingPost) { |
||||||
|
// If the post doesn't exist, add it to hashMapPosts
|
||||||
|
return true |
||||||
|
} else if ( |
||||||
|
post?.updated && |
||||||
|
existingPost?.updated && |
||||||
|
(!existingPost?.updated || post?.updated) > existingPost?.updated |
||||||
|
) { |
||||||
|
// If the post exists and its updated is more recent than the existing post's updated, update it in hashMapPosts
|
||||||
|
return true |
||||||
|
} else { |
||||||
|
return false |
||||||
|
} |
||||||
|
}, |
||||||
|
[hashMapPosts] |
||||||
|
) |
||||||
|
|
||||||
|
const getBlogPost = async (user: string, postId: string, content: any) => { |
||||||
|
const res = await fetchAndEvaluatePosts({ |
||||||
|
user, |
||||||
|
postId, |
||||||
|
content |
||||||
|
}) |
||||||
|
|
||||||
|
dispatch(addToHashMap(res)) |
||||||
|
} |
||||||
|
|
||||||
|
const getMailMessage = async (user: string, postId: string, content: any) => { |
||||||
|
const res = await fetchAndEvaluateMail({ |
||||||
|
user, |
||||||
|
postId, |
||||||
|
content |
||||||
|
}) |
||||||
|
|
||||||
|
dispatch(addToHashMapMail(res)) |
||||||
|
} |
||||||
|
|
||||||
|
const checkNewMessages = React.useCallback( |
||||||
|
async (recipientName: string, recipientAddress: string) => { |
||||||
|
try { |
||||||
|
const query = `qortal_qmail_${recipientName.slice( |
||||||
|
0, |
||||||
|
20 |
||||||
|
)}_${recipientAddress.slice(-6)}_mail_` |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=${MAIL_SERVICE_TYPE}&query=${query}&limit=20&includemetadata=true&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const latestPost = mailMessages[0] |
||||||
|
if (!latestPost) return |
||||||
|
const findPost = responseData?.findIndex( |
||||||
|
(item: any) => item?.identifier === latestPost?.id |
||||||
|
) |
||||||
|
if (findPost === -1) { |
||||||
|
return |
||||||
|
} |
||||||
|
const newArray = responseData.slice(0, findPost) |
||||||
|
const structureData = newArray.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: post?.created, |
||||||
|
updated: post?.updated, |
||||||
|
user: post.name, |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(upsertMessagesBeginning(structureData)) |
||||||
|
return |
||||||
|
} catch (error) {} |
||||||
|
}, |
||||||
|
[mailMessages] |
||||||
|
) |
||||||
|
|
||||||
|
const getNewPosts = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
dispatch(setCountNewPosts(0)) |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const latestPost = posts[0] |
||||||
|
if (!latestPost) return |
||||||
|
const findPost = responseData?.findIndex( |
||||||
|
(item: any) => item?.identifier === latestPost?.id |
||||||
|
) |
||||||
|
let fetchAll = responseData |
||||||
|
let willFetchAll = true |
||||||
|
if (findPost !== -1) { |
||||||
|
willFetchAll = false |
||||||
|
fetchAll = responseData.slice(0, findPost) |
||||||
|
} |
||||||
|
|
||||||
|
const structureData = fetchAll.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: post?.created, |
||||||
|
updated: post?.updated, |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
if (!willFetchAll) { |
||||||
|
dispatch(upsertPostsBeginning(structureData)) |
||||||
|
} |
||||||
|
if (willFetchAll) { |
||||||
|
dispatch(addPosts(structureData)) |
||||||
|
} |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, [posts, hashMapPosts]) |
||||||
|
|
||||||
|
const getBlogPosts = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const offset = posts.length |
||||||
|
|
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const structureData = responseData.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: post?.created, |
||||||
|
updated: post?.updated, |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(upsertPosts(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, [posts, hashMapPosts]) |
||||||
|
|
||||||
|
const getAvatar = async (user: string) => { |
||||||
|
try { |
||||||
|
let url = await qortalRequest({ |
||||||
|
action: 'GET_QDN_RESOURCE_URL', |
||||||
|
name: user, |
||||||
|
service: 'THUMBNAIL', |
||||||
|
identifier: 'qortal_avatar' |
||||||
|
}) |
||||||
|
dispatch( |
||||||
|
setUserAvatarHash({ |
||||||
|
name: user, |
||||||
|
url |
||||||
|
}) |
||||||
|
) |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
const getMailMessages = React.useCallback( |
||||||
|
async (recipientName: string, recipientAddress: string) => { |
||||||
|
try { |
||||||
|
const offset = mailMessages.length |
||||||
|
|
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const query = `qortal_qmail_${recipientName.slice( |
||||||
|
0, |
||||||
|
20 |
||||||
|
)}_${recipientAddress.slice(-6)}_mail_` |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=${MAIL_SERVICE_TYPE}&query=${query}&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const structureData = responseData.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: post?.created, |
||||||
|
updated: post?.updated, |
||||||
|
user: post.name, |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(upsertMessages(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
getAvatar(content.user) |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, |
||||||
|
[mailMessages, hashMapMailMessages] |
||||||
|
) |
||||||
|
const getBlogFilteredPosts = React.useCallback( |
||||||
|
async (filterValue: string) => { |
||||||
|
try { |
||||||
|
const offset = filteredPosts.length |
||||||
|
|
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true&name=${filterValue}` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const structureData = responseData.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: post?.created, |
||||||
|
updated: post?.updated, |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(upsertFilteredPosts(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, |
||||||
|
[filteredPosts, hashMapPosts] |
||||||
|
) |
||||||
|
|
||||||
|
const getBlogPostsSubscriptions = React.useCallback( |
||||||
|
async (username: string) => { |
||||||
|
try { |
||||||
|
const offset = subscriptionPosts.length |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&namefilter=q-blog-subscriptions-${username}&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const structureData = responseData.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: '', |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(upsertSubscriptionPosts(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, |
||||||
|
[subscriptionPosts, hashMapPosts, subscriptions] |
||||||
|
) |
||||||
|
|
||||||
|
const getBlogPostsFavorites = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const offset = favorites.length |
||||||
|
const favSlice = (favoritesLocal || []).slice(offset, 20) |
||||||
|
let favs = [] |
||||||
|
for (const item of favSlice) { |
||||||
|
try { |
||||||
|
// await qortalRequest({
|
||||||
|
// action: "SEARCH_QDN_RESOURCES",
|
||||||
|
// service: "THUMBNAIL",
|
||||||
|
// query: "search query goes here", // Optional - searches both "identifier" and "name" fields
|
||||||
|
// identifier: "search query goes here", // Optional - searches only the "identifier" field
|
||||||
|
// name: "search query goes here", // Optional - searches only the "name" field
|
||||||
|
// prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
|
||||||
|
// default: false, // Optional - if true, only resources without identifiers are returned
|
||||||
|
// includeStatus: false, // Optional - will take time to respond, so only request if necessary
|
||||||
|
// includeMetadata: false, // Optional - will take time to respond, so only request if necessary
|
||||||
|
// limit: 100,
|
||||||
|
// offset: 0,
|
||||||
|
// reverse: true
|
||||||
|
// });
|
||||||
|
//TODO - NAME SHOULD BE EXACT
|
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${item.id}&exactmatchnames=true&name=${item.user}&limit=20&includemetadata=true&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const data = await response.json() |
||||||
|
//
|
||||||
|
if (data.length > 0) { |
||||||
|
favs.push(data[0]) |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
const structureData = favs.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: '', |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(populateFavorites(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
} |
||||||
|
}, [hashMapPosts, favoritesLocal]) |
||||||
|
return { |
||||||
|
getBlogPosts, |
||||||
|
getBlogPostsFavorites, |
||||||
|
getBlogPostsSubscriptions, |
||||||
|
checkAndUpdatePost, |
||||||
|
getBlogPost, |
||||||
|
hashMapPosts, |
||||||
|
checkNewMessages, |
||||||
|
getNewPosts, |
||||||
|
getBlogFilteredPosts, |
||||||
|
getMailMessages |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,362 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { |
||||||
|
addPosts, |
||||||
|
addToHashMap, |
||||||
|
BlogPost, |
||||||
|
populateFavorites, |
||||||
|
setCountNewPosts, |
||||||
|
upsertFilteredPosts, |
||||||
|
upsertPosts, |
||||||
|
upsertPostsBeginning, |
||||||
|
upsertSubscriptionPosts |
||||||
|
} from '../state/features/blogSlice' |
||||||
|
import { |
||||||
|
setCurrentBlog, |
||||||
|
setIsLoadingGlobal |
||||||
|
} from '../state/features/globalSlice' |
||||||
|
import { RootState } from '../state/store' |
||||||
|
import { fetchAndEvaluatePosts } from '../utils/fetchPosts' |
||||||
|
|
||||||
|
export const useFetchPosts = () => { |
||||||
|
const dispatch = useDispatch() |
||||||
|
const hashMapPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.hashMapPosts |
||||||
|
) |
||||||
|
const posts = useSelector((state: RootState) => state.blog.posts) |
||||||
|
const filteredPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.filteredPosts |
||||||
|
) |
||||||
|
const favoritesLocal = useSelector( |
||||||
|
(state: RootState) => state.blog.favoritesLocal |
||||||
|
) |
||||||
|
const favorites = useSelector((state: RootState) => state.blog.favorites) |
||||||
|
const subscriptionPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.subscriptionPosts |
||||||
|
) |
||||||
|
const subscriptions = useSelector( |
||||||
|
(state: RootState) => state.blog.subscriptions |
||||||
|
) |
||||||
|
|
||||||
|
const checkAndUpdatePost = React.useCallback( |
||||||
|
(post: BlogPost) => { |
||||||
|
// Check if the post exists in hashMapPosts
|
||||||
|
const existingPost = hashMapPosts[post.id] |
||||||
|
if (!existingPost) { |
||||||
|
// If the post doesn't exist, add it to hashMapPosts
|
||||||
|
return true |
||||||
|
} else if ( |
||||||
|
post?.updated && |
||||||
|
existingPost?.updated && |
||||||
|
(!existingPost?.updated || post?.updated) > existingPost?.updated |
||||||
|
) { |
||||||
|
// If the post exists and its updated is more recent than the existing post's updated, update it in hashMapPosts
|
||||||
|
return true |
||||||
|
} else { |
||||||
|
return false |
||||||
|
} |
||||||
|
}, |
||||||
|
[hashMapPosts] |
||||||
|
) |
||||||
|
|
||||||
|
const getBlogPost = async (user: string, postId: string, content: any) => { |
||||||
|
const res = await fetchAndEvaluatePosts({ |
||||||
|
user, |
||||||
|
postId, |
||||||
|
content |
||||||
|
}) |
||||||
|
|
||||||
|
dispatch(addToHashMap(res)) |
||||||
|
} |
||||||
|
|
||||||
|
const checkNewMessages = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const latestPost = posts[0] |
||||||
|
if (!latestPost) return |
||||||
|
const findPost = responseData?.findIndex( |
||||||
|
(item: any) => item?.identifier === latestPost?.id |
||||||
|
) |
||||||
|
if (findPost === -1) { |
||||||
|
dispatch(setCountNewPosts(responseData.length)) |
||||||
|
return |
||||||
|
} |
||||||
|
const newArray = responseData.slice(0, findPost) |
||||||
|
dispatch(setCountNewPosts(newArray.length)) |
||||||
|
return |
||||||
|
} catch (error) {} |
||||||
|
}, [posts]) |
||||||
|
|
||||||
|
const getNewPosts = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
dispatch(setCountNewPosts(0)) |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const latestPost = posts[0] |
||||||
|
if (!latestPost) return |
||||||
|
const findPost = responseData?.findIndex( |
||||||
|
(item: any) => item?.identifier === latestPost?.id |
||||||
|
) |
||||||
|
let fetchAll = responseData |
||||||
|
let willFetchAll = true |
||||||
|
if (findPost !== -1) { |
||||||
|
willFetchAll = false |
||||||
|
fetchAll = responseData.slice(0, findPost) |
||||||
|
} |
||||||
|
|
||||||
|
const structureData = fetchAll.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: post?.created, |
||||||
|
updated: post?.updated, |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
if (!willFetchAll) { |
||||||
|
dispatch(upsertPostsBeginning(structureData)) |
||||||
|
} |
||||||
|
if (willFetchAll) { |
||||||
|
dispatch(addPosts(structureData)) |
||||||
|
} |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, [posts, hashMapPosts]) |
||||||
|
|
||||||
|
const getBlogPosts = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const offset = posts.length |
||||||
|
|
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const structureData = responseData.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: post?.created, |
||||||
|
updated: post?.updated, |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(upsertPosts(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, [posts, hashMapPosts]) |
||||||
|
const getBlogFilteredPosts = React.useCallback( |
||||||
|
async (filterValue: string) => { |
||||||
|
try { |
||||||
|
const offset = filteredPosts.length |
||||||
|
|
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true&name=${filterValue}` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const structureData = responseData.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: post?.created, |
||||||
|
updated: post?.updated, |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(upsertFilteredPosts(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, |
||||||
|
[filteredPosts, hashMapPosts] |
||||||
|
) |
||||||
|
|
||||||
|
const getBlogPostsSubscriptions = React.useCallback( |
||||||
|
async (username: string) => { |
||||||
|
try { |
||||||
|
const offset = subscriptionPosts.length |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=q-blog-&limit=20&includemetadata=true&offset=${offset}&reverse=true&namefilter=q-blog-subscriptions-${username}&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const structureData = responseData.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: '', |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(upsertSubscriptionPosts(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, |
||||||
|
[subscriptionPosts, hashMapPosts, subscriptions] |
||||||
|
) |
||||||
|
|
||||||
|
const getBlogPostsFavorites = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
const offset = favorites.length |
||||||
|
const favSlice = (favoritesLocal || []).slice(offset, 20) |
||||||
|
let favs = [] |
||||||
|
for (const item of favSlice) { |
||||||
|
try { |
||||||
|
// await qortalRequest({
|
||||||
|
// action: "SEARCH_QDN_RESOURCES",
|
||||||
|
// service: "THUMBNAIL",
|
||||||
|
// query: "search query goes here", // Optional - searches both "identifier" and "name" fields
|
||||||
|
// identifier: "search query goes here", // Optional - searches only the "identifier" field
|
||||||
|
// name: "search query goes here", // Optional - searches only the "name" field
|
||||||
|
// prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
|
||||||
|
// default: false, // Optional - if true, only resources without identifiers are returned
|
||||||
|
// includeStatus: false, // Optional - will take time to respond, so only request if necessary
|
||||||
|
// includeMetadata: false, // Optional - will take time to respond, so only request if necessary
|
||||||
|
// limit: 100,
|
||||||
|
// offset: 0,
|
||||||
|
// reverse: true
|
||||||
|
// });
|
||||||
|
//TODO - NAME SHOULD BE EXACT
|
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${item.id}&exactmatchnames=true&name=${item.user}&limit=20&includemetadata=true&reverse=true&excludeblocked=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const data = await response.json() |
||||||
|
//
|
||||||
|
if (data.length > 0) { |
||||||
|
favs.push(data[0]) |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
const structureData = favs.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: '', |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
dispatch(populateFavorites(structureData)) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
} |
||||||
|
}, [hashMapPosts, favoritesLocal]) |
||||||
|
return { |
||||||
|
getBlogPosts, |
||||||
|
getBlogPostsFavorites, |
||||||
|
getBlogPostsSubscriptions, |
||||||
|
checkAndUpdatePost, |
||||||
|
getBlogPost, |
||||||
|
hashMapPosts, |
||||||
|
checkNewMessages, |
||||||
|
getNewPosts, |
||||||
|
getBlogFilteredPosts |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,168 @@ |
|||||||
|
@font-face { |
||||||
|
font-family: 'CambonLight'; |
||||||
|
src: url('./styles/fonts/Cambon-Light.ttf') format('truetype'); |
||||||
|
} |
||||||
|
|
||||||
|
@font-face { |
||||||
|
font-family: 'Raleway'; |
||||||
|
src: url('./styles/fonts/Raleway.ttf') format('truetype'); |
||||||
|
} |
||||||
|
|
||||||
|
@font-face { |
||||||
|
font-family: 'Catamaran'; |
||||||
|
src: url('./styles/fonts/Catamaran.ttf') format('truetype'); |
||||||
|
} |
||||||
|
|
||||||
|
@font-face { |
||||||
|
font-family: 'Oxygen'; |
||||||
|
src: url('./styles/fonts/Oxygen.ttf') format('truetype'); |
||||||
|
} |
||||||
|
|
||||||
|
@font-face { |
||||||
|
font-family: 'Cairo'; |
||||||
|
src: url('./styles/fonts/Cairo.ttf') format('truetype'); |
||||||
|
} |
||||||
|
|
||||||
|
:root { |
||||||
|
padding: 0px; |
||||||
|
margin: 0px; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
.line-clamp { |
||||||
|
height: 100px; |
||||||
|
overflow: hidden; |
||||||
|
display: -webkit-box; |
||||||
|
-webkit-line-clamp: 5; /* number of lines to show */ |
||||||
|
-webkit-box-orient: vertical; |
||||||
|
text-overflow: ellipsis; |
||||||
|
} |
||||||
|
|
||||||
|
.edit-btn:hover { |
||||||
|
opacity: 0.75; |
||||||
|
transition: 0.2s all; |
||||||
|
} |
||||||
|
|
||||||
|
.post-image { |
||||||
|
max-width: 100%; |
||||||
|
border-radius: 5px; |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.grid-item { |
||||||
|
/* Other styles */ |
||||||
|
/* overflow: auto; */ |
||||||
|
} |
||||||
|
|
||||||
|
.grid-item-view { |
||||||
|
/* Other styles */ |
||||||
|
/* overflow: auto; */ |
||||||
|
} |
||||||
|
|
||||||
|
.test-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(4, 1fr); |
||||||
|
position: absolute; |
||||||
|
top: 0; |
||||||
|
bottom: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
min-height: 25px; |
||||||
|
} |
||||||
|
|
||||||
|
.test-grid-item { |
||||||
|
border: 1px solid powderblue; |
||||||
|
} |
||||||
|
|
||||||
|
body::-webkit-scrollbar-track { |
||||||
|
background-color: transparent; |
||||||
|
} |
||||||
|
body::-webkit-scrollbar-track:hover { |
||||||
|
background-color: transparent; |
||||||
|
} |
||||||
|
|
||||||
|
body::-webkit-scrollbar { |
||||||
|
width: 16px; |
||||||
|
height: 10px; |
||||||
|
background-color: white; |
||||||
|
} |
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb { |
||||||
|
background-color: #838eee; |
||||||
|
border-radius: 8px; |
||||||
|
background-clip: content-box; |
||||||
|
border: 4px solid transparent; |
||||||
|
} |
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb:hover { |
||||||
|
background-color: #6270f0; |
||||||
|
} |
||||||
|
|
||||||
|
.MuiList-root::-webkit-scrollbar-track { |
||||||
|
background-color: transparent; |
||||||
|
} |
||||||
|
.MuiList-root::-webkit-scrollbar-track:hover { |
||||||
|
background-color: transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.MuiList-root::-webkit-scrollbar { |
||||||
|
width: 14px; |
||||||
|
height: 10px; |
||||||
|
background-color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.MuiList-root::-webkit-scrollbar-thumb { |
||||||
|
background-color: lightgray; |
||||||
|
border-radius: 8px; |
||||||
|
background-clip: content-box; |
||||||
|
border: 4px solid transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.MuiList-root::-webkit-scrollbar-thumb:hover { |
||||||
|
background-color: lightslategray; |
||||||
|
} |
||||||
|
|
||||||
|
.my-masonry-grid { |
||||||
|
display: -webkit-box; /* Not needed if autoprefixing */ |
||||||
|
display: -ms-flexbox; /* Not needed if autoprefixing */ |
||||||
|
display: flex; |
||||||
|
margin-left: -20px; /* gutter size offset */ |
||||||
|
width: auto; |
||||||
|
padding: 15px 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.my-masonry-grid_column { |
||||||
|
padding-left: 20px; /* gutter size */ |
||||||
|
background-clip: padding-box; |
||||||
|
} |
||||||
|
|
||||||
|
/* Style your items */ |
||||||
|
.my-masonry-grid_column > li { |
||||||
|
/* change div to reference your elements you put in <Masonry> */ |
||||||
|
margin-bottom: 30px; |
||||||
|
} |
||||||
|
|
||||||
|
.my-svg path { |
||||||
|
fill: red; |
||||||
|
} |
||||||
|
|
||||||
|
.threadScroller::-webkit-scrollbar-track { |
||||||
|
background-color: transparent; |
||||||
|
} |
||||||
|
.threadScroller::-webkit-scrollbar-track:hover { |
||||||
|
background-color: transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.threadScroller::-webkit-scrollbar { |
||||||
|
width: 16px; |
||||||
|
height: 10px; |
||||||
|
background-color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.threadScroller::-webkit-scrollbar-thumb { |
||||||
|
background-color: #838eee; |
||||||
|
border-radius: 8px; |
||||||
|
background-clip: content-box; |
||||||
|
border: 4px solid transparent; |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
declare module 'webworker:getBlogWorker' { |
||||||
|
const value: new () => Worker; |
||||||
|
export default value; |
||||||
|
} |
||||||
|
|
||||||
|
declare module 'webworker:decodeBase64' { |
||||||
|
const value: new () => Worker |
||||||
|
export default value |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
export interface BlogContent { |
||||||
|
postContent: any[] |
||||||
|
title: string |
||||||
|
createdAt: number |
||||||
|
user?: any |
||||||
|
postId?: string |
||||||
|
layouts?: any |
||||||
|
} |
@ -0,0 +1,28 @@ |
|||||||
|
import React from 'react' |
||||||
|
import ReactDOM from 'react-dom/client' |
||||||
|
import App from './App' |
||||||
|
import './index.css' |
||||||
|
import { HashRouter, BrowserRouter } from 'react-router-dom' |
||||||
|
|
||||||
|
if (typeof global === 'undefined') { |
||||||
|
// Check if window is defined to avoid issues in non-browser environments
|
||||||
|
if (typeof window !== 'undefined') { |
||||||
|
;(window as any).global = window |
||||||
|
} |
||||||
|
} |
||||||
|
interface CustomWindow extends Window { |
||||||
|
_qdnBase: any // Replace 'any' with the appropriate type if you know it
|
||||||
|
} |
||||||
|
|
||||||
|
const customWindow = window as unknown as CustomWindow |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Now you can access the _qdnTheme property without TypeScript errors
|
||||||
|
const baseUrl = customWindow?._qdnBase || '' |
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( |
||||||
|
<BrowserRouter basename={baseUrl}> |
||||||
|
<App /> |
||||||
|
<div id="modal-root" /> |
||||||
|
</BrowserRouter> |
||||||
|
) |
@ -0,0 +1,855 @@ |
|||||||
|
import React, { useMemo, useRef, useState } from 'react' |
||||||
|
import { useParams } from 'react-router-dom' |
||||||
|
import { |
||||||
|
Button, |
||||||
|
Box, |
||||||
|
Typography, |
||||||
|
CardHeader, |
||||||
|
Avatar, |
||||||
|
useTheme |
||||||
|
} from '@mui/material' |
||||||
|
import { useNavigate } from 'react-router-dom' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import AudiotrackIcon from '@mui/icons-material/Audiotrack' |
||||||
|
import ReadOnlySlate from '../../components/editor/ReadOnlySlate' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import { checkStructure } from '../../utils/checkStructure' |
||||||
|
import { BlogContent } from '../../interfaces/interfaces' |
||||||
|
import { |
||||||
|
setAudio, |
||||||
|
setCurrAudio, |
||||||
|
setIsLoadingGlobal, |
||||||
|
setVisitingBlog |
||||||
|
} from '../../state/features/globalSlice' |
||||||
|
import { VideoPlayer } from '../../components/common/VideoPlayer' |
||||||
|
import { AudioPlayer, IPlaylist } from '../../components/common/AudioPlayer' |
||||||
|
import { Responsive, WidthProvider } from 'react-grid-layout' |
||||||
|
import '/node_modules/react-grid-layout/css/styles.css' |
||||||
|
import '/node_modules/react-resizable/css/styles.css' |
||||||
|
import DynamicHeightItem from '../../components/DynamicHeightItem' |
||||||
|
import { |
||||||
|
addPrefix, |
||||||
|
buildIdentifierFromCreateTitleIdAndId |
||||||
|
} from '../../utils/blogIdformats' |
||||||
|
import { DynamicHeightItemMinimal } from '../../components/DynamicHeightItemMinimal' |
||||||
|
import { ReusableModal } from '../../components/modals/ReusableModal' |
||||||
|
import AudioElement from '../../components/AudioElement' |
||||||
|
import ErrorBoundary from '../../components/common/ErrorBoundary' |
||||||
|
import { CommentSection } from '../../components/common/Comments/CommentSection' |
||||||
|
import { Tipping } from '../../components/common/Tipping/Tipping' |
||||||
|
import FileElement from '../../components/FileElement' |
||||||
|
const ResponsiveGridLayout = WidthProvider(Responsive) |
||||||
|
const initialMinHeight = 2 // Define an initial minimum height for grid items
|
||||||
|
|
||||||
|
const md = [ |
||||||
|
{ i: 'a', x: 0, y: 0, w: 4, h: initialMinHeight }, |
||||||
|
{ i: 'b', x: 6, y: 0, w: 4, h: initialMinHeight } |
||||||
|
] |
||||||
|
const sm = [ |
||||||
|
{ i: 'a', x: 0, y: 0, w: 6, h: initialMinHeight }, |
||||||
|
{ i: 'b', x: 6, y: 0, w: 6, h: initialMinHeight } |
||||||
|
] |
||||||
|
const xs = [ |
||||||
|
{ i: 'a', x: 0, y: 0, w: 6, h: initialMinHeight }, |
||||||
|
{ i: 'b', x: 6, y: 0, w: 6, h: initialMinHeight } |
||||||
|
] |
||||||
|
|
||||||
|
interface ILayoutGeneralSettings { |
||||||
|
padding: number |
||||||
|
blogPostType: string |
||||||
|
} |
||||||
|
export const BlogIndividualPost = () => { |
||||||
|
const { user, postId, blog } = useParams() |
||||||
|
const blogFull = React.useMemo(() => { |
||||||
|
if (!blog) return '' |
||||||
|
return addPrefix(blog) |
||||||
|
}, [blog]) |
||||||
|
const { user: userState } = useSelector((state: RootState) => state.auth) |
||||||
|
const { audios, audioPostId } = useSelector( |
||||||
|
(state: RootState) => state.global |
||||||
|
) |
||||||
|
|
||||||
|
const [avatarUrl, setAvatarUrl] = React.useState<string>('') |
||||||
|
const dispatch = useDispatch() |
||||||
|
const navigate = useNavigate() |
||||||
|
const theme = useTheme() |
||||||
|
// const [currAudio, setCurrAudio] = React.useState<number | null>(null)
|
||||||
|
const [layouts, setLayouts] = React.useState<any>({ md, sm, xs }) |
||||||
|
const [count, setCount] = React.useState<number>(1) |
||||||
|
const [layoutGeneralSettings, setLayoutGeneralSettings] = |
||||||
|
React.useState<ILayoutGeneralSettings | null>(null) |
||||||
|
const [currentBreakpoint, setCurrentBreakpoint] = React.useState<any>() |
||||||
|
const handleLayoutChange = (layout: any, layoutss: any) => { |
||||||
|
// const redoLayouts = setAutoHeight(layoutss)
|
||||||
|
setLayouts(layoutss) |
||||||
|
// saveLayoutsToLocalStorage(layoutss)
|
||||||
|
} |
||||||
|
const [blogContent, setBlogContent] = React.useState<BlogContent | null>(null) |
||||||
|
const [isOpenSwitchPlaylistModal, setisOpenSwitchPlaylistModal] = |
||||||
|
useState<boolean>(false) |
||||||
|
const tempSaveAudio = useRef<any>(null) |
||||||
|
const saveAudio = React.useRef<any>(null) |
||||||
|
|
||||||
|
const fullPostId = useMemo(() => { |
||||||
|
if (!blog || !postId) return '' |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const formBlogId = addPrefix(blog) |
||||||
|
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId) |
||||||
|
return formPostId |
||||||
|
}, [blog, postId]) |
||||||
|
const getBlogPost = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
if (!blog || !postId) return |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const formBlogId = addPrefix(blog) |
||||||
|
const formPostId = buildIdentifierFromCreateTitleIdAndId( |
||||||
|
formBlogId, |
||||||
|
postId |
||||||
|
) |
||||||
|
const url = `/arbitrary/BLOG_POST/${user}/${formPostId}` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const responseData = await response.json() |
||||||
|
|
||||||
|
if (checkStructure(responseData)) { |
||||||
|
setBlogContent(responseData) |
||||||
|
if (responseData?.layouts) { |
||||||
|
setLayouts(responseData?.layouts) |
||||||
|
} |
||||||
|
if (responseData?.layoutGeneralSettings) { |
||||||
|
setLayoutGeneralSettings(responseData.layoutGeneralSettings) |
||||||
|
} |
||||||
|
const filteredAudios = (responseData?.postContent || []).filter( |
||||||
|
(content: any) => content?.type === 'audio' |
||||||
|
) |
||||||
|
|
||||||
|
const transformAudios = filteredAudios?.map((fa: any) => { |
||||||
|
return { |
||||||
|
...(fa?.content || {}), |
||||||
|
id: fa?.id |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
if (!audios && transformAudios.length > 0) { |
||||||
|
saveAudio.current = { audios: transformAudios, postId: formPostId } |
||||||
|
dispatch(setAudio({ audios: transformAudios, postId: formPostId })) |
||||||
|
} else if ( |
||||||
|
formPostId === audioPostId && |
||||||
|
audios?.length !== transformAudios.length |
||||||
|
) { |
||||||
|
tempSaveAudio.current = { |
||||||
|
message: |
||||||
|
"This post's audio playlist has updated. Would you like to switch?" |
||||||
|
} |
||||||
|
setisOpenSwitchPlaylistModal(true) |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, [user, postId, blog]) |
||||||
|
React.useEffect(() => { |
||||||
|
getBlogPost() |
||||||
|
}, [postId]) |
||||||
|
|
||||||
|
const switchPlayList = () => { |
||||||
|
const filteredAudios = (blogContent?.postContent || []).filter( |
||||||
|
(content) => content?.type === 'audio' |
||||||
|
) |
||||||
|
|
||||||
|
const formatAudios = filteredAudios.map((fa) => { |
||||||
|
return { |
||||||
|
...(fa?.content || {}), |
||||||
|
id: fa?.id |
||||||
|
} |
||||||
|
}) |
||||||
|
if (!blog || !postId) return |
||||||
|
const formBlogId = addPrefix(blog) |
||||||
|
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId) |
||||||
|
dispatch(setAudio({ audios: formatAudios, postId: formPostId })) |
||||||
|
if (tempSaveAudio?.current?.currentSelection) { |
||||||
|
const findIndex = (formatAudios || []).findIndex( |
||||||
|
(item) => |
||||||
|
item?.identifier === |
||||||
|
tempSaveAudio?.current?.currentSelection?.content?.identifier |
||||||
|
) |
||||||
|
if (findIndex >= 0) { |
||||||
|
dispatch(setCurrAudio(findIndex)) |
||||||
|
} |
||||||
|
} |
||||||
|
setisOpenSwitchPlaylistModal(false) |
||||||
|
} |
||||||
|
|
||||||
|
const getAvatar = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
let url = await qortalRequest({ |
||||||
|
action: 'GET_QDN_RESOURCE_URL', |
||||||
|
name: user, |
||||||
|
service: 'THUMBNAIL', |
||||||
|
identifier: 'qortal_avatar' |
||||||
|
}) |
||||||
|
|
||||||
|
setAvatarUrl(url) |
||||||
|
} catch (error) {} |
||||||
|
}, [user]) |
||||||
|
React.useEffect(() => { |
||||||
|
getAvatar() |
||||||
|
}, []) |
||||||
|
|
||||||
|
const onBreakpointChange = React.useCallback((newBreakpoint: any) => { |
||||||
|
setCurrentBreakpoint(newBreakpoint) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const onResizeStop = React.useCallback((layout: any, layoutItem: any) => { |
||||||
|
// Update the layout state with the new position and size of the component
|
||||||
|
setCount((prev) => prev + 1) |
||||||
|
}, []) |
||||||
|
|
||||||
|
// const audios = React.useMemo<IPlaylist[]>(() => {
|
||||||
|
// const filteredAudios = (blogContent?.postContent || []).filter(
|
||||||
|
// (content) => content.type === 'audio'
|
||||||
|
// )
|
||||||
|
|
||||||
|
// return filteredAudios.map((fa) => {
|
||||||
|
// return {
|
||||||
|
// ...fa.content,
|
||||||
|
// id: fa.id
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// }, [blogContent])
|
||||||
|
|
||||||
|
const handleResize = () => { |
||||||
|
setCount((prev) => prev + 1) |
||||||
|
} |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
window.addEventListener('resize', handleResize) |
||||||
|
|
||||||
|
return () => { |
||||||
|
window.removeEventListener('resize', handleResize) |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleCount = React.useCallback(() => { |
||||||
|
// Update the layout state with the new position and size of the component
|
||||||
|
setCount((prev) => prev + 1) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const getBlog = React.useCallback(async () => { |
||||||
|
let name = user |
||||||
|
if (!name) return |
||||||
|
if (!blogFull) return |
||||||
|
try { |
||||||
|
const urlBlog = `/arbitrary/BLOG/${name}/${blogFull}` |
||||||
|
const response = await fetch(urlBlog, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
dispatch(setVisitingBlog({ ...responseData, name })) |
||||||
|
} catch (error) {} |
||||||
|
}, [user, blogFull]) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getBlog() |
||||||
|
}, [user, blogFull]) |
||||||
|
|
||||||
|
if (!blogContent) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
flexDirection: 'column' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
maxWidth: '1400px', |
||||||
|
// margin: '15px',
|
||||||
|
width: '95%', |
||||||
|
paddingBottom: '50px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{user === userState?.name && ( |
||||||
|
<Button |
||||||
|
sx={{ backgroundColor: theme.palette.secondary.main }} |
||||||
|
onClick={() => { |
||||||
|
navigate(`/${user}/${blog}/${postId}/edit`) |
||||||
|
}} |
||||||
|
> |
||||||
|
Edit Post |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: 1 |
||||||
|
}} |
||||||
|
> |
||||||
|
<CardHeader |
||||||
|
onClick={() => { |
||||||
|
navigate(`/${user}/${blog}`) |
||||||
|
}} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
'& .MuiCardHeader-content': { |
||||||
|
overflow: 'hidden' |
||||||
|
}, |
||||||
|
padding: '10px 0px' |
||||||
|
}} |
||||||
|
avatar={<Avatar src={avatarUrl} alt={`${user}'s avatar`} />} |
||||||
|
subheader={ |
||||||
|
<Typography |
||||||
|
sx={{ fontFamily: 'Cairo', fontSize: '25px' }} |
||||||
|
color={theme.palette.text.primary} |
||||||
|
>{` ${user}`}</Typography> |
||||||
|
} |
||||||
|
/> |
||||||
|
{user && ( |
||||||
|
<Tipping |
||||||
|
name={user || ''} |
||||||
|
onSubmit={() => { |
||||||
|
// setNameTip('')
|
||||||
|
}} |
||||||
|
onClose={() => { |
||||||
|
// setNameTip('')
|
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
gap: 1, |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
variant="h1" |
||||||
|
color="textPrimary" |
||||||
|
sx={{ |
||||||
|
textAlign: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
{blogContent?.title} |
||||||
|
</Typography> |
||||||
|
<CommentSection postId={fullPostId} /> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{(layoutGeneralSettings?.blogPostType === 'builder' || |
||||||
|
!layoutGeneralSettings?.blogPostType) && ( |
||||||
|
<Content |
||||||
|
layouts={layouts} |
||||||
|
blogContent={blogContent} |
||||||
|
onResizeStop={onResizeStop} |
||||||
|
onBreakpointChange={onBreakpointChange} |
||||||
|
handleLayoutChange={handleLayoutChange} |
||||||
|
> |
||||||
|
{blogContent?.postContent?.map((section: any) => { |
||||||
|
if (section?.type === 'editor') { |
||||||
|
return ( |
||||||
|
<div key={section?.id} className="grid-item-view"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItem |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={layoutGeneralSettings?.padding} |
||||||
|
> |
||||||
|
<ReadOnlySlate content={section.content} /> |
||||||
|
</DynamicHeightItem> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section?.type === 'image') { |
||||||
|
return ( |
||||||
|
<div key={section?.id} className="grid-item-view"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItem |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={layoutGeneralSettings?.padding} |
||||||
|
> |
||||||
|
<img |
||||||
|
src={section.content.image} |
||||||
|
className="post-image" |
||||||
|
/> |
||||||
|
</DynamicHeightItem> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section?.type === 'video') { |
||||||
|
return ( |
||||||
|
<div key={section?.id} className="grid-item-view"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItem |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={layoutGeneralSettings?.padding} |
||||||
|
> |
||||||
|
<VideoPlayer |
||||||
|
name={section.content.name} |
||||||
|
service={section.content.service} |
||||||
|
identifier={section.content.identifier} |
||||||
|
setCount={handleCount} |
||||||
|
user={user} |
||||||
|
postId={fullPostId} |
||||||
|
/> |
||||||
|
</DynamicHeightItem> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section?.type === 'audio') { |
||||||
|
return ( |
||||||
|
<div key={section?.id} className="grid-item-view"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItem |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={layoutGeneralSettings?.padding} |
||||||
|
> |
||||||
|
<AudioElement |
||||||
|
key={section.id} |
||||||
|
audioInfo={section.content} |
||||||
|
postId={fullPostId} |
||||||
|
user={user ? user : ''} |
||||||
|
onClick={() => { |
||||||
|
if (!blog || !postId) return |
||||||
|
|
||||||
|
const formBlogId = addPrefix(blog) |
||||||
|
const formPostId = |
||||||
|
buildIdentifierFromCreateTitleIdAndId( |
||||||
|
formBlogId, |
||||||
|
postId |
||||||
|
) |
||||||
|
if (audioPostId && formPostId !== audioPostId) { |
||||||
|
tempSaveAudio.current = { |
||||||
|
...(tempSaveAudio.current || {}), |
||||||
|
currentSelection: section, |
||||||
|
message: |
||||||
|
'You are current on a playlist. Would you like to switch?' |
||||||
|
} |
||||||
|
setisOpenSwitchPlaylistModal(true) |
||||||
|
} else { |
||||||
|
if (!audios && saveAudio?.current) { |
||||||
|
const findIndex = ( |
||||||
|
saveAudio?.current?.audios || [] |
||||||
|
).findIndex( |
||||||
|
(item: any) => |
||||||
|
item.identifier === |
||||||
|
section.content.identifier |
||||||
|
) |
||||||
|
dispatch(setAudio(saveAudio?.current)) |
||||||
|
dispatch(setCurrAudio(findIndex)) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const findIndex = (audios || []).findIndex( |
||||||
|
(item) => |
||||||
|
item.identifier === section.content.identifier |
||||||
|
) |
||||||
|
if (findIndex >= 0) { |
||||||
|
dispatch(setCurrAudio(findIndex)) |
||||||
|
} |
||||||
|
} |
||||||
|
}} |
||||||
|
title={section.content?.title} |
||||||
|
description={section.content?.description} |
||||||
|
author="" |
||||||
|
/> |
||||||
|
</DynamicHeightItem> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section?.type === 'file') { |
||||||
|
return ( |
||||||
|
<div key={section?.id} className="grid-item"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItemMinimal |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={0} |
||||||
|
> |
||||||
|
<FileElement |
||||||
|
key={section.id} |
||||||
|
fileInfo={section.content} |
||||||
|
postId={fullPostId} |
||||||
|
user={user ? user : ''} |
||||||
|
title={section.content?.title} |
||||||
|
description={section.content?.description} |
||||||
|
mimeType={section.content?.mimeType} |
||||||
|
author="" |
||||||
|
/> |
||||||
|
</DynamicHeightItemMinimal> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
})} |
||||||
|
</Content> |
||||||
|
)} |
||||||
|
{layoutGeneralSettings?.blogPostType === 'minimal' && ( |
||||||
|
<> |
||||||
|
{layouts?.rows?.map((row: any, rowIndex: number) => { |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
width: '100%', |
||||||
|
flexDirection: 'row', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center', |
||||||
|
marginTop: '25px', |
||||||
|
gap: 2 |
||||||
|
}} |
||||||
|
> |
||||||
|
{row?.ids?.map((elementId: string) => { |
||||||
|
const section: any = blogContent?.postContent?.find( |
||||||
|
(el) => el?.id === elementId |
||||||
|
) |
||||||
|
if (!section) return null |
||||||
|
if (section?.type === 'editor') { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={section?.id} |
||||||
|
className="grid-item" |
||||||
|
style={{ |
||||||
|
maxWidth: '800px', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
width: '100%' |
||||||
|
}} |
||||||
|
> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItemMinimal |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={0} |
||||||
|
> |
||||||
|
<ReadOnlySlate |
||||||
|
key={section.id} |
||||||
|
content={section.content} |
||||||
|
/> |
||||||
|
</DynamicHeightItemMinimal> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section?.type === 'image') { |
||||||
|
return ( |
||||||
|
<div key={section.id} className="grid-item"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItemMinimal |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
type="image" |
||||||
|
padding={0} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'relative', |
||||||
|
width: '100%', |
||||||
|
height: '100%' |
||||||
|
}} |
||||||
|
> |
||||||
|
<img |
||||||
|
src={section.content.image} |
||||||
|
className="post-image" |
||||||
|
style={{ |
||||||
|
objectFit: 'contain', |
||||||
|
maxHeight: '50vh' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</DynamicHeightItemMinimal> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (section?.type === 'video') { |
||||||
|
return ( |
||||||
|
<div key={section?.id} className="grid-item"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItemMinimal |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={0} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'relative', |
||||||
|
width: '100%', |
||||||
|
height: '100%' |
||||||
|
}} |
||||||
|
> |
||||||
|
<VideoPlayer |
||||||
|
name={section.content.name} |
||||||
|
service={section.content.service} |
||||||
|
identifier={section.content.identifier} |
||||||
|
customStyle={{ |
||||||
|
height: '50vh' |
||||||
|
}} |
||||||
|
user={user} |
||||||
|
postId={fullPostId} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</DynamicHeightItemMinimal> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section?.type === 'audio') { |
||||||
|
return ( |
||||||
|
<div key={section?.id} className="grid-item"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItemMinimal |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={0} |
||||||
|
> |
||||||
|
<AudioElement |
||||||
|
key={section.id} |
||||||
|
audioInfo={section.content} |
||||||
|
postId={fullPostId} |
||||||
|
user={user ? user : ''} |
||||||
|
onClick={() => { |
||||||
|
if (!blog || !postId) return |
||||||
|
const formBlogId = addPrefix(blog) |
||||||
|
const formPostId = |
||||||
|
buildIdentifierFromCreateTitleIdAndId( |
||||||
|
formBlogId, |
||||||
|
postId |
||||||
|
) |
||||||
|
if (formPostId !== audioPostId) { |
||||||
|
tempSaveAudio.current = { |
||||||
|
...(tempSaveAudio.current || {}), |
||||||
|
currentSelection: section, |
||||||
|
message: |
||||||
|
'You are current on a playlist. Would you like to switch?' |
||||||
|
} |
||||||
|
setisOpenSwitchPlaylistModal(true) |
||||||
|
} else { |
||||||
|
const findIndex = (audios || []).findIndex( |
||||||
|
(item) => |
||||||
|
item.identifier === |
||||||
|
section.content.identifier |
||||||
|
) |
||||||
|
if (findIndex >= 0) { |
||||||
|
dispatch(setCurrAudio(findIndex)) |
||||||
|
} |
||||||
|
} |
||||||
|
}} |
||||||
|
title={section.content?.title} |
||||||
|
description={section.content?.description} |
||||||
|
author="" |
||||||
|
/> |
||||||
|
</DynamicHeightItemMinimal> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section?.type === 'file') { |
||||||
|
return ( |
||||||
|
<div key={section?.id} className="grid-item"> |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography> |
||||||
|
Error loading content: Invalid Data |
||||||
|
</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<DynamicHeightItemMinimal |
||||||
|
layouts={layouts} |
||||||
|
setLayouts={setLayouts} |
||||||
|
i={section.id} |
||||||
|
breakpoint={currentBreakpoint} |
||||||
|
count={count} |
||||||
|
padding={0} |
||||||
|
> |
||||||
|
<FileElement |
||||||
|
key={section.id} |
||||||
|
fileInfo={section.content} |
||||||
|
postId={fullPostId} |
||||||
|
user={user ? user : ''} |
||||||
|
title={section.content?.title} |
||||||
|
description={section.content?.description} |
||||||
|
mimeType={section.content?.mimeType} |
||||||
|
author="" |
||||||
|
/> |
||||||
|
</DynamicHeightItemMinimal> |
||||||
|
</ErrorBoundary> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
})} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
})} |
||||||
|
</> |
||||||
|
)} |
||||||
|
<ReusableModal open={isOpenSwitchPlaylistModal}> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: 1 |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography> |
||||||
|
{tempSaveAudio?.current?.message |
||||||
|
? tempSaveAudio?.current?.message |
||||||
|
: 'You are current on a playlist. Would you like to switch?'} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
<Button |
||||||
|
variant="contained" |
||||||
|
onClick={() => setisOpenSwitchPlaylistModal(false)} |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
<Button variant="contained" onClick={switchPlayList}> |
||||||
|
Switch |
||||||
|
</Button> |
||||||
|
</ReusableModal> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const Content = ({ |
||||||
|
children, |
||||||
|
layouts, |
||||||
|
blogContent, |
||||||
|
onResizeStop, |
||||||
|
onBreakpointChange, |
||||||
|
handleLayoutChange |
||||||
|
}: any) => { |
||||||
|
if (layouts && blogContent?.layouts) { |
||||||
|
return ( |
||||||
|
<ErrorBoundary |
||||||
|
fallback={ |
||||||
|
<Typography>Error loading content: Invalid Layout</Typography> |
||||||
|
} |
||||||
|
> |
||||||
|
<ResponsiveGridLayout |
||||||
|
layouts={layouts} |
||||||
|
breakpoints={{ md: 996, sm: 768, xs: 480 }} |
||||||
|
cols={{ md: 4, sm: 3, xs: 1 }} |
||||||
|
measureBeforeMount={false} |
||||||
|
onLayoutChange={handleLayoutChange} |
||||||
|
autoSize={true} |
||||||
|
compactType={null} |
||||||
|
isBounded={true} |
||||||
|
resizeHandles={['se', 'sw', 'ne', 'nw']} |
||||||
|
rowHeight={25} |
||||||
|
onResizeStop={onResizeStop} |
||||||
|
onBreakpointChange={onBreakpointChange} |
||||||
|
isDraggable={false} |
||||||
|
isResizable={false} |
||||||
|
margin={[0, 0]} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</ResponsiveGridLayout> |
||||||
|
</ErrorBoundary> |
||||||
|
) |
||||||
|
} |
||||||
|
return children |
||||||
|
} |
@ -0,0 +1,298 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { useNavigate } from 'react-router-dom' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import { useParams } from 'react-router-dom' |
||||||
|
import { Typography, Box, Button, useTheme } from '@mui/material' |
||||||
|
import EditIcon from '@mui/icons-material/Edit' |
||||||
|
import BlogPostPreview from '../BlogList/PostPreview' |
||||||
|
import { |
||||||
|
setIsLoadingGlobal, |
||||||
|
setVisitingBlog, |
||||||
|
toggleEditBlogModal |
||||||
|
} from '../../state/features/globalSlice' |
||||||
|
import { |
||||||
|
addSubscription, |
||||||
|
BlogPost, |
||||||
|
removeSubscription |
||||||
|
} from '../../state/features/blogSlice' |
||||||
|
import { useFetchPosts } from '../../hooks/useFetchPosts' |
||||||
|
import LazyLoad from '../../components/common/LazyLoad' |
||||||
|
import { addPrefix, removePrefix } from '../../utils/blogIdformats' |
||||||
|
import Masonry from 'react-masonry-css' |
||||||
|
|
||||||
|
const breakpointColumnsObj = { |
||||||
|
default: 5, |
||||||
|
1600: 4, |
||||||
|
1300: 3, |
||||||
|
940: 2, |
||||||
|
700: 1, |
||||||
|
500: 1 |
||||||
|
} |
||||||
|
export const BlogIndividualProfile = () => { |
||||||
|
const navigate = useNavigate() |
||||||
|
const theme = useTheme() |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const { currentBlog } = useSelector((state: RootState) => state.global) |
||||||
|
const subscriptions = useSelector( |
||||||
|
(state: RootState) => state.blog.subscriptions |
||||||
|
) |
||||||
|
|
||||||
|
const { blog: blogShortVersion, user: username } = useParams() |
||||||
|
const blog = React.useMemo(() => { |
||||||
|
if (!blogShortVersion) return '' |
||||||
|
return addPrefix(blogShortVersion) |
||||||
|
}, [blogShortVersion]) |
||||||
|
const dispatch = useDispatch() |
||||||
|
const [userBlog, setUserBlog] = React.useState<any>(null) |
||||||
|
const { checkAndUpdatePost, getBlogPost, hashMapPosts } = useFetchPosts() |
||||||
|
|
||||||
|
const [blogPosts, setBlogPosts] = React.useState<BlogPost[]>([]) |
||||||
|
|
||||||
|
const getBlogPosts = React.useCallback(async () => { |
||||||
|
let name = username |
||||||
|
|
||||||
|
if (!name) return |
||||||
|
if (!blog) return |
||||||
|
|
||||||
|
try { |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const offset = blogPosts.length |
||||||
|
//TODO - NAME SHOULD BE EXACT
|
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=${blog}-post-&limit=20&exactmatchnames=true&name=${name}&includemetadata=true&offset=${offset}&reverse=true` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
|
||||||
|
const structureData = responseData.map((post: any): BlogPost => { |
||||||
|
return { |
||||||
|
title: post?.metadata?.title, |
||||||
|
category: post?.metadata?.category, |
||||||
|
categoryName: post?.metadata?.categoryName, |
||||||
|
tags: post?.metadata?.tags || [], |
||||||
|
description: post?.metadata?.description, |
||||||
|
createdAt: '', |
||||||
|
user: post.name, |
||||||
|
postImage: '', |
||||||
|
id: post.identifier |
||||||
|
} |
||||||
|
}) |
||||||
|
setBlogPosts(structureData) |
||||||
|
const copiedBlogPosts: BlogPost[] = [...blogPosts] |
||||||
|
structureData.forEach((post: BlogPost) => { |
||||||
|
const index = blogPosts.findIndex((p) => p.id === post.id) |
||||||
|
if (index !== -1) { |
||||||
|
copiedBlogPosts[index] = post |
||||||
|
} else { |
||||||
|
copiedBlogPosts.push(post) |
||||||
|
} |
||||||
|
}) |
||||||
|
setBlogPosts(copiedBlogPosts) |
||||||
|
|
||||||
|
for (const content of structureData) { |
||||||
|
if (content.user && content.id) { |
||||||
|
const res = checkAndUpdatePost(content) |
||||||
|
|
||||||
|
if (res) { |
||||||
|
getBlogPost(content.user, content.id, content) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, [username, blog, blogPosts]) |
||||||
|
const getBlog = React.useCallback(async () => { |
||||||
|
let name = username |
||||||
|
|
||||||
|
if (!name) return |
||||||
|
if (!blog) return |
||||||
|
try { |
||||||
|
const urlBlog = `/arbitrary/BLOG/${name}/${blog}` |
||||||
|
const response = await fetch(urlBlog, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
dispatch(setVisitingBlog({ ...responseData, name })) |
||||||
|
setUserBlog(responseData) |
||||||
|
} catch (error) {} |
||||||
|
}, [username, blog]) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getBlog() |
||||||
|
}, [username, blog]) |
||||||
|
const getPosts = React.useCallback(async () => { |
||||||
|
await getBlogPosts() |
||||||
|
}, [getBlogPosts]) |
||||||
|
|
||||||
|
const subscribe = async () => { |
||||||
|
try { |
||||||
|
if (!user?.name) return |
||||||
|
const body = { |
||||||
|
items: [username] |
||||||
|
} |
||||||
|
|
||||||
|
const listName = `q-blog-subscriptions-${user.name}` |
||||||
|
|
||||||
|
const response = await qortalRequest({ |
||||||
|
action: 'ADD_LIST_ITEMS', |
||||||
|
list_name: listName, |
||||||
|
items: [username] |
||||||
|
}) |
||||||
|
if (response === true) { |
||||||
|
dispatch(addSubscription(username)) |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
const unsubscribe = async () => { |
||||||
|
try { |
||||||
|
if (!user?.name) return |
||||||
|
|
||||||
|
const listName = `q-blog-subscriptions-${user.name}` |
||||||
|
|
||||||
|
const response = await qortalRequest({ |
||||||
|
action: 'DELETE_LIST_ITEM', |
||||||
|
list_name: listName, |
||||||
|
item: username |
||||||
|
}) |
||||||
|
if (response === true) { |
||||||
|
dispatch(removeSubscription(username)) |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
if (!userBlog) return null |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
gap: 1, |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
variant="h1" |
||||||
|
color="textPrimary" |
||||||
|
sx={{ |
||||||
|
textAlign: 'center', |
||||||
|
marginTop: '20px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{currentBlog?.blogId === blog ? currentBlog?.title : userBlog.title} |
||||||
|
</Typography> |
||||||
|
{currentBlog?.blogId === blog && ( |
||||||
|
<EditIcon |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
onClick={() => { |
||||||
|
dispatch(toggleEditBlogModal(true)) |
||||||
|
}} |
||||||
|
></EditIcon> |
||||||
|
)} |
||||||
|
{subscriptions.includes(username) && ( |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial' |
||||||
|
}} |
||||||
|
onClick={unsubscribe} |
||||||
|
> |
||||||
|
Unsubscribe |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
{!subscriptions.includes(username) && ( |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial' |
||||||
|
}} |
||||||
|
onClick={subscribe} |
||||||
|
> |
||||||
|
Subscribe |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
<Masonry |
||||||
|
breakpointCols={breakpointColumnsObj} |
||||||
|
className="my-masonry-grid" |
||||||
|
columnClassName="my-masonry-grid_column" |
||||||
|
style={{ backgroundColor: theme.palette.background.default }} |
||||||
|
> |
||||||
|
{blogPosts.map((post, index) => { |
||||||
|
const existingPost = hashMapPosts[post.id] |
||||||
|
let blogPost = post |
||||||
|
if (existingPost) { |
||||||
|
blogPost = existingPost |
||||||
|
} |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
gap: 1, |
||||||
|
alignItems: 'center', |
||||||
|
width: 'auto', |
||||||
|
position: 'relative', |
||||||
|
' @media (max-width: 450px)': { |
||||||
|
width: '100%' |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<BlogPostPreview |
||||||
|
onClick={() => { |
||||||
|
const str = blogPost.id |
||||||
|
const arr = str.split('-post-') |
||||||
|
const str1 = arr[0] |
||||||
|
|
||||||
|
const blogId = removePrefix(str1) |
||||||
|
const str2 = arr[1] |
||||||
|
navigate(`/${blogPost.user}/${blogId}/${str2}`) |
||||||
|
}} |
||||||
|
description={blogPost?.description} |
||||||
|
title={blogPost?.title} |
||||||
|
createdAt={blogPost?.createdAt} |
||||||
|
author={blogPost.user} |
||||||
|
postImage={blogPost?.postImage} |
||||||
|
blogPost={blogPost} |
||||||
|
/> |
||||||
|
|
||||||
|
{blogPost.user === user?.name && ( |
||||||
|
<EditIcon |
||||||
|
className="edit-btn" |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
zIndex: 10, |
||||||
|
bottom: '25px', |
||||||
|
right: '25px', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
onClick={() => { |
||||||
|
const str = blogPost.id |
||||||
|
const arr = str.split('-post-') |
||||||
|
const str1 = arr[0] |
||||||
|
const str2 = arr[1] |
||||||
|
const blogId = removePrefix(str1) |
||||||
|
navigate(`/${blogPost.user}/${blogId}/${str2}/edit`) |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
})} |
||||||
|
</Masonry> |
||||||
|
<LazyLoad onLoadMore={getPosts}></LazyLoad> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,230 @@ |
|||||||
|
import React, { FC, useCallback, useEffect, useRef } from 'react' |
||||||
|
import { useNavigate } from 'react-router-dom' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import EditIcon from '@mui/icons-material/Edit' |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
List, |
||||||
|
ListItem, |
||||||
|
Typography, |
||||||
|
useTheme |
||||||
|
} from '@mui/material' |
||||||
|
import BlogPostPreview from './PostPreview' |
||||||
|
import { useFetchPosts } from '../../hooks/useFetchPosts' |
||||||
|
import LazyLoad from '../../components/common/LazyLoad' |
||||||
|
import { removePrefix } from '../../utils/blogIdformats' |
||||||
|
import Masonry from 'react-masonry-css' |
||||||
|
|
||||||
|
const breakpointColumnsObj = { |
||||||
|
default: 5, |
||||||
|
1600: 4, |
||||||
|
1300: 3, |
||||||
|
940: 2, |
||||||
|
700: 1, |
||||||
|
500: 1 |
||||||
|
} |
||||||
|
interface BlogListProps { |
||||||
|
mode?: string |
||||||
|
} |
||||||
|
export const BlogList = ({ mode }: BlogListProps) => { |
||||||
|
const theme = useTheme() |
||||||
|
const prevVal = useRef('') |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const hashMapPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.hashMapPosts |
||||||
|
) |
||||||
|
const favoritesLocal = useSelector( |
||||||
|
(state: RootState) => state.blog.favoritesLocal |
||||||
|
) |
||||||
|
const subscriptionPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.subscriptionPosts |
||||||
|
) |
||||||
|
const countNewPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.countNewPosts |
||||||
|
) |
||||||
|
const isFiltering = useSelector((state: RootState) => state.blog.isFiltering) |
||||||
|
const filterValue = useSelector((state: RootState) => state.blog.filterValue) |
||||||
|
const filteredPosts = useSelector( |
||||||
|
(state: RootState) => state.blog.filteredPosts |
||||||
|
) |
||||||
|
|
||||||
|
const { posts: globalPosts, favorites } = useSelector( |
||||||
|
(state: RootState) => state.blog |
||||||
|
) |
||||||
|
const navigate = useNavigate() |
||||||
|
const { |
||||||
|
getBlogPosts, |
||||||
|
getBlogPostsFavorites, |
||||||
|
getBlogPostsSubscriptions, |
||||||
|
checkNewMessages, |
||||||
|
getNewPosts, |
||||||
|
getBlogFilteredPosts |
||||||
|
} = useFetchPosts() |
||||||
|
const getPosts = React.useCallback(async () => { |
||||||
|
if (isFiltering) { |
||||||
|
getBlogFilteredPosts(filterValue) |
||||||
|
return |
||||||
|
} |
||||||
|
if (mode === 'favorites') { |
||||||
|
getBlogPostsFavorites() |
||||||
|
return |
||||||
|
} |
||||||
|
if (mode === 'subscriptions' && user?.name) { |
||||||
|
getBlogPostsSubscriptions(user.name) |
||||||
|
return |
||||||
|
} |
||||||
|
await getBlogPosts() |
||||||
|
}, [getBlogPosts, mode, favoritesLocal, user?.name, isFiltering, filterValue]) |
||||||
|
|
||||||
|
let posts = globalPosts |
||||||
|
|
||||||
|
if (mode === 'favorites') { |
||||||
|
posts = favorites |
||||||
|
} |
||||||
|
if (mode === 'subscriptions') { |
||||||
|
posts = subscriptionPosts |
||||||
|
} |
||||||
|
if (isFiltering) { |
||||||
|
posts = filteredPosts |
||||||
|
} |
||||||
|
const interval = useRef<any>(null) |
||||||
|
|
||||||
|
const checkNewMessagesFunc = useCallback(() => { |
||||||
|
let isCalling = false |
||||||
|
interval.current = setInterval(async () => { |
||||||
|
if (isCalling) return |
||||||
|
isCalling = true |
||||||
|
const res = await checkNewMessages() |
||||||
|
isCalling = false |
||||||
|
}, 30000) // 1 second interval
|
||||||
|
}, [checkNewMessages]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!mode) { |
||||||
|
checkNewMessagesFunc() |
||||||
|
} |
||||||
|
return () => { |
||||||
|
if (interval?.current) { |
||||||
|
clearInterval(interval.current) |
||||||
|
} |
||||||
|
} |
||||||
|
}, [mode, checkNewMessagesFunc]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (isFiltering && filterValue !== prevVal?.current) { |
||||||
|
prevVal.current = filterValue |
||||||
|
getPosts() |
||||||
|
} |
||||||
|
}, [filterValue, isFiltering, filteredPosts]) |
||||||
|
// if (!favoritesLocal) return null
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
{/* <List |
||||||
|
sx={{ |
||||||
|
margin: '0px', |
||||||
|
padding: '10px', |
||||||
|
display: 'flex', |
||||||
|
flexWrap: 'wrap', |
||||||
|
justifyContent: 'center' |
||||||
|
}} |
||||||
|
> */} |
||||||
|
{!mode && countNewPosts > 0 && !isFiltering && ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography> |
||||||
|
{countNewPosts === 1 |
||||||
|
? `There is ${countNewPosts} new post` |
||||||
|
: `There are ${countNewPosts} new posts`} |
||||||
|
</Typography> |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial' |
||||||
|
}} |
||||||
|
onClick={getNewPosts} |
||||||
|
> |
||||||
|
Load new Posts |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
<Masonry |
||||||
|
breakpointCols={breakpointColumnsObj} |
||||||
|
className="my-masonry-grid" |
||||||
|
columnClassName="my-masonry-grid_column" |
||||||
|
> |
||||||
|
{posts.map((post, index) => { |
||||||
|
const existingPost = hashMapPosts[post.id] |
||||||
|
let blogPost = post |
||||||
|
if (existingPost) { |
||||||
|
blogPost = existingPost |
||||||
|
} |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
gap: 1, |
||||||
|
alignItems: 'center', |
||||||
|
width: 'auto', |
||||||
|
position: 'relative', |
||||||
|
' @media (max-width: 450px)': { |
||||||
|
width: '100%' |
||||||
|
} |
||||||
|
}} |
||||||
|
key={blogPost.id} |
||||||
|
> |
||||||
|
<BlogPostPreview |
||||||
|
onClick={() => { |
||||||
|
const str = blogPost.id |
||||||
|
const arr = str.split('-post-') |
||||||
|
const str1 = arr[0] |
||||||
|
const str2 = arr[1] |
||||||
|
const blogId = removePrefix(str1) |
||||||
|
navigate(`/${blogPost.user}/${blogId}/${str2}`) |
||||||
|
}} |
||||||
|
description={blogPost?.description} |
||||||
|
title={blogPost?.title} |
||||||
|
createdAt={blogPost?.createdAt} |
||||||
|
author={blogPost.user} |
||||||
|
postImage={blogPost?.postImage} |
||||||
|
blogPost={blogPost} |
||||||
|
isValid={blogPost?.isValid} |
||||||
|
/> |
||||||
|
|
||||||
|
{blogPost.user === user?.name && ( |
||||||
|
<EditIcon |
||||||
|
className="edit-btn" |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
zIndex: 10, |
||||||
|
bottom: '25px', |
||||||
|
right: '25px', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
onClick={() => { |
||||||
|
const str = blogPost.id |
||||||
|
const arr = str.split('-post-') |
||||||
|
const str1 = arr[0] |
||||||
|
const str2 = arr[1] |
||||||
|
const blogId = removePrefix(str1) |
||||||
|
navigate(`/${blogPost.user}/${blogId}/${str2}/edit`) |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
})} |
||||||
|
</Masonry> |
||||||
|
{/* </List> */} |
||||||
|
<LazyLoad onLoadMore={getPosts}></LazyLoad> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,134 @@ |
|||||||
|
import { styled } from "@mui/system"; |
||||||
|
import { Card, Box, Typography } from "@mui/material"; |
||||||
|
|
||||||
|
export const StyledCard = styled(Card)(({ theme }) => ({
|
||||||
|
backgroundColor: theme.palette.mode === "light" ? theme.palette.primary.main : theme.palette.primary.dark, |
||||||
|
maxWidth: "600px", |
||||||
|
width: "100%", |
||||||
|
margin: "10px 0px", |
||||||
|
cursor: "pointer", |
||||||
|
"@media (max-width: 450px)": { |
||||||
|
width: "100%;" |
||||||
|
} |
||||||
|
})); |
||||||
|
|
||||||
|
export const CardContentContainer = styled(Box)(({ theme }) => ({
|
||||||
|
backgroundColor: theme.palette.mode === "light" ? theme.palette.primary.dark : theme.palette.primary.light, |
||||||
|
margin: "5px 10px", |
||||||
|
borderRadius: "15px", |
||||||
|
})); |
||||||
|
export const CardContentContainerComment = styled(Box)(({ theme }) => ({ |
||||||
|
backgroundColor: |
||||||
|
theme.palette.mode === 'light' |
||||||
|
? theme.palette.primary.dark |
||||||
|
: theme.palette.primary.light, |
||||||
|
margin: '0px', |
||||||
|
borderRadius: '15px', |
||||||
|
width: '100%', |
||||||
|
|
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column' |
||||||
|
})) |
||||||
|
|
||||||
|
export const StyledCardHeader = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'flex-start', |
||||||
|
gap: '5px', |
||||||
|
padding: '7px' |
||||||
|
}) |
||||||
|
export const StyledCardHeaderComment = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'flex-start', |
||||||
|
gap: '5px', |
||||||
|
padding: '7px' |
||||||
|
}) |
||||||
|
export const StyledCardCol = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
overflow: 'hidden', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: '2px', |
||||||
|
alignItems: 'flex-start', |
||||||
|
width: '100%' |
||||||
|
}) |
||||||
|
export const StyledCardColComment = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
overflow: 'hidden', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: '2px', |
||||||
|
alignItems: 'flex-start', |
||||||
|
width: '100%' |
||||||
|
}) |
||||||
|
export const StyledCardContent = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'flex-start', |
||||||
|
padding: '5px 10px', |
||||||
|
gap: '10px' |
||||||
|
}) |
||||||
|
export const StyledCardContentComment = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
alignItems: 'flex-start', |
||||||
|
justifyContent: 'flex-start', |
||||||
|
padding: '5px 10px', |
||||||
|
gap: '10px' |
||||||
|
}) |
||||||
|
export const TitleText = styled(Typography)({ |
||||||
|
whiteSpace: 'nowrap', |
||||||
|
overflow: 'hidden', |
||||||
|
textOverflow: 'ellipsis', |
||||||
|
width: '100%', |
||||||
|
fontFamily: 'Cairo, sans-serif', |
||||||
|
fontSize: '22px', |
||||||
|
lineHeight: '1.2' |
||||||
|
}) |
||||||
|
|
||||||
|
export const AuthorText = styled(Typography)({ |
||||||
|
fontFamily: 'Raleway, sans-serif', |
||||||
|
fontSize: '16px', |
||||||
|
lineHeight: '1.2' |
||||||
|
}) |
||||||
|
export const AuthorTextComment = styled(Typography)({ |
||||||
|
fontFamily: 'Raleway, sans-serif', |
||||||
|
fontSize: '16px', |
||||||
|
lineHeight: '1.2' |
||||||
|
}) |
||||||
|
export const IconsBox = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
gap: "3px", |
||||||
|
position: 'absolute', |
||||||
|
top: '12px', |
||||||
|
right: '5px', |
||||||
|
transition: 'all 0.3s ease-in-out', |
||||||
|
}); |
||||||
|
|
||||||
|
export const BookmarkIconContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
boxShadow: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;", |
||||||
|
backgroundColor: '#fbfbfb', |
||||||
|
color: "#50e3c2", |
||||||
|
padding: '5px', |
||||||
|
borderRadius: '3px', |
||||||
|
transition: 'all 0.3s ease-in-out', |
||||||
|
"&:hover": { |
||||||
|
cursor: 'pointer', |
||||||
|
transform: "scale(1.1)", |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
export const BlockIconContainer = styled(Box)({ |
||||||
|
display: 'flex', |
||||||
|
boxShadow: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;", |
||||||
|
backgroundColor: '#fbfbfb', |
||||||
|
color: "#c25252", |
||||||
|
padding: '5px', |
||||||
|
borderRadius: '3px', |
||||||
|
transition: 'all 0.3s ease-in-out', |
||||||
|
"&:hover": { |
||||||
|
cursor: 'pointer', |
||||||
|
transform: "scale(1.1)", |
||||||
|
} |
||||||
|
}) |
@ -0,0 +1,304 @@ |
|||||||
|
import React, { useMemo, useState } from 'react' |
||||||
|
import { |
||||||
|
Avatar, |
||||||
|
Card, |
||||||
|
CardContent, |
||||||
|
CardHeader, |
||||||
|
CardMedia, |
||||||
|
Typography, |
||||||
|
Box, |
||||||
|
Button, |
||||||
|
Tooltip, |
||||||
|
useTheme |
||||||
|
} from '@mui/material' |
||||||
|
import Dialog from '@mui/material/Dialog' |
||||||
|
import DialogActions from '@mui/material/DialogActions' |
||||||
|
import DialogContent from '@mui/material/DialogContent' |
||||||
|
import DialogContentText from '@mui/material/DialogContentText' |
||||||
|
import DialogTitle from '@mui/material/DialogTitle' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
|
||||||
|
import { |
||||||
|
CardContentContainer, |
||||||
|
StyledCard, |
||||||
|
StyledCardContent, |
||||||
|
TitleText, |
||||||
|
AuthorText, |
||||||
|
StyledCardHeader, |
||||||
|
StyledCardCol, |
||||||
|
IconsBox, |
||||||
|
BlockIconContainer, |
||||||
|
BookmarkIconContainer |
||||||
|
} from './PostPreview-styles' |
||||||
|
import moment from 'moment' |
||||||
|
import { |
||||||
|
blockUser, |
||||||
|
BlogPost, |
||||||
|
removeFavorites, |
||||||
|
removeSubscription, |
||||||
|
upsertFavorites |
||||||
|
} from '../../state/features/blogSlice' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder' |
||||||
|
import BookmarkIcon from '@mui/icons-material/Bookmark' |
||||||
|
import { AppDispatch, RootState } from '../../state/store' |
||||||
|
import BlockIcon from '@mui/icons-material/Block' |
||||||
|
import { CustomIcon } from '../../components/common/CustomIcon' |
||||||
|
interface BlogPostPreviewProps { |
||||||
|
title: string |
||||||
|
createdAt: number | string |
||||||
|
author: string |
||||||
|
postImage?: string |
||||||
|
description: any |
||||||
|
blogPost: BlogPost |
||||||
|
onClick?: () => void |
||||||
|
isValid?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({ |
||||||
|
title, |
||||||
|
createdAt, |
||||||
|
author, |
||||||
|
postImage, |
||||||
|
description, |
||||||
|
onClick, |
||||||
|
blogPost, |
||||||
|
isValid |
||||||
|
}) => { |
||||||
|
const [avatarUrl, setAvatarUrl] = React.useState<string>('') |
||||||
|
const [showIcons, setShowIcons] = React.useState<boolean>(false) |
||||||
|
|
||||||
|
const dispatch = useDispatch<AppDispatch>() |
||||||
|
const theme = useTheme() |
||||||
|
const favoritesLocal = useSelector( |
||||||
|
(state: RootState) => state.blog.favoritesLocal |
||||||
|
) |
||||||
|
const [isOpenAlert, setIsOpenAlert] = useState<boolean>(false) |
||||||
|
const subscriptions = useSelector( |
||||||
|
(state: RootState) => state.blog.subscriptions |
||||||
|
) |
||||||
|
const username = useSelector((state: RootState) => state.auth?.user?.name) |
||||||
|
const formatDate = (unixTimestamp: number): string => { |
||||||
|
const date = moment(unixTimestamp, 'x').fromNow() |
||||||
|
|
||||||
|
return date |
||||||
|
} |
||||||
|
|
||||||
|
function extractTextFromSlate(nodes: any) { |
||||||
|
if (!Array.isArray(nodes)) return '' |
||||||
|
let text = '' |
||||||
|
|
||||||
|
for (const node of nodes) { |
||||||
|
if (node.text) { |
||||||
|
text += node.text |
||||||
|
} else if (node.children) { |
||||||
|
text += extractTextFromSlate(node.children) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return text |
||||||
|
} |
||||||
|
const getAvatar = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
let url = await qortalRequest({ |
||||||
|
action: 'GET_QDN_RESOURCE_URL', |
||||||
|
name: author, |
||||||
|
service: 'THUMBNAIL', |
||||||
|
identifier: 'qortal_avatar' |
||||||
|
}) |
||||||
|
|
||||||
|
setAvatarUrl(url) |
||||||
|
} catch (error) {} |
||||||
|
}, [author]) |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
getAvatar() |
||||||
|
}, []) |
||||||
|
|
||||||
|
const isFavorite = useMemo(() => { |
||||||
|
if (!favoritesLocal) return false |
||||||
|
return favoritesLocal.find((fav) => fav?.id === blogPost?.id) |
||||||
|
}, [favoritesLocal, blogPost?.id]) |
||||||
|
|
||||||
|
const blockUserFunc = async (user: string) => { |
||||||
|
if (user === 'Q-Blog') return |
||||||
|
if (subscriptions.includes(user) && username) { |
||||||
|
try { |
||||||
|
const listName = `q-blog-subscriptions-${username}` |
||||||
|
|
||||||
|
const response = await qortalRequest({ |
||||||
|
action: 'DELETE_LIST_ITEM', |
||||||
|
list_name: listName, |
||||||
|
item: user |
||||||
|
}) |
||||||
|
if (response === true) { |
||||||
|
dispatch(removeSubscription(user)) |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await qortalRequest({ |
||||||
|
action: 'ADD_LIST_ITEMS', |
||||||
|
list_name: 'blockedNames_q-blog', |
||||||
|
items: [user] |
||||||
|
}) |
||||||
|
|
||||||
|
if (response === true) { |
||||||
|
dispatch(blockUser(user)) |
||||||
|
dispatch(removeFavorites(blogPost.id)) |
||||||
|
} |
||||||
|
} catch (error) {} |
||||||
|
} |
||||||
|
|
||||||
|
const continueToPost = () => { |
||||||
|
if (isValid === false) { |
||||||
|
setIsOpenAlert(true) |
||||||
|
return |
||||||
|
} |
||||||
|
if (!onClick) return |
||||||
|
onClick() |
||||||
|
} |
||||||
|
|
||||||
|
const handleClose = () => { |
||||||
|
setIsOpenAlert(false) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<StyledCard |
||||||
|
onClick={continueToPost} |
||||||
|
onMouseEnter={() => setShowIcons(true)} |
||||||
|
onMouseLeave={() => setShowIcons(false)} |
||||||
|
> |
||||||
|
{postImage && ( |
||||||
|
<Box sx={{ padding: '2px' }}> |
||||||
|
<img |
||||||
|
src={postImage} |
||||||
|
style={{ |
||||||
|
width: '100%', |
||||||
|
height: 'auto', |
||||||
|
borderRadius: '8px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
<CardContentContainer> |
||||||
|
<StyledCardHeader |
||||||
|
sx={{ |
||||||
|
'& .MuiCardHeader-content': { |
||||||
|
overflow: 'hidden' |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box> |
||||||
|
<Avatar src={avatarUrl} alt={`${author}'s avatar`} /> |
||||||
|
</Box> |
||||||
|
<StyledCardCol> |
||||||
|
<TitleText |
||||||
|
color={theme.palette.text.primary} |
||||||
|
noWrap |
||||||
|
variant="body1" |
||||||
|
> |
||||||
|
{title} |
||||||
|
</TitleText> |
||||||
|
<AuthorText |
||||||
|
color={ |
||||||
|
theme.palette.mode === 'light' |
||||||
|
? theme.palette.text.secondary |
||||||
|
: '#d6e8ff' |
||||||
|
} |
||||||
|
> |
||||||
|
{author} |
||||||
|
</AuthorText> |
||||||
|
</StyledCardCol> |
||||||
|
</StyledCardHeader> |
||||||
|
<StyledCardContent> |
||||||
|
<Typography variant="body2" color={theme.palette.text.primary}> |
||||||
|
{description} |
||||||
|
</Typography> |
||||||
|
<Box sx={{ textAlign: 'flex-start', width: '100%' }}> |
||||||
|
<Typography variant="h6" color={theme.palette.text.primary}> |
||||||
|
{formatDate(+createdAt)} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</StyledCardContent> |
||||||
|
</CardContentContainer> |
||||||
|
</StyledCard> |
||||||
|
<IconsBox |
||||||
|
sx={{ opacity: showIcons ? 1 : 0 }} |
||||||
|
onMouseEnter={() => setShowIcons(true)} |
||||||
|
onMouseLeave={() => setShowIcons(false)} |
||||||
|
> |
||||||
|
{username && isFavorite && ( |
||||||
|
<Tooltip title="Remove from favorites" placement="top"> |
||||||
|
<BookmarkIconContainer |
||||||
|
onMouseEnter={() => setShowIcons(true)} |
||||||
|
onMouseLeave={() => setShowIcons(false)} |
||||||
|
> |
||||||
|
<BookmarkIcon |
||||||
|
sx={{ |
||||||
|
color: 'red' |
||||||
|
}} |
||||||
|
onClick={() => { |
||||||
|
dispatch(removeFavorites(blogPost.id)) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</BookmarkIconContainer> |
||||||
|
</Tooltip> |
||||||
|
)} |
||||||
|
{username && !isFavorite && ( |
||||||
|
<Tooltip title="Save to favorites" placement="top"> |
||||||
|
<BookmarkIconContainer |
||||||
|
onMouseEnter={() => setShowIcons(true)} |
||||||
|
onMouseLeave={() => setShowIcons(false)} |
||||||
|
> |
||||||
|
<BookmarkBorderIcon |
||||||
|
onClick={() => { |
||||||
|
dispatch(upsertFavorites([blogPost])) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</BookmarkIconContainer> |
||||||
|
</Tooltip> |
||||||
|
)} |
||||||
|
<Tooltip title="Block user content" placement="top"> |
||||||
|
<BlockIconContainer |
||||||
|
onMouseEnter={() => setShowIcons(true)} |
||||||
|
onMouseLeave={() => setShowIcons(false)} |
||||||
|
> |
||||||
|
<BlockIcon |
||||||
|
onClick={() => { |
||||||
|
blockUserFunc(blogPost.user) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</BlockIconContainer> |
||||||
|
</Tooltip> |
||||||
|
</IconsBox> |
||||||
|
|
||||||
|
<Dialog |
||||||
|
open={isOpenAlert} |
||||||
|
onClose={handleClose} |
||||||
|
aria-labelledby="alert-dialog-title" |
||||||
|
aria-describedby="alert-dialog-description" |
||||||
|
> |
||||||
|
<DialogTitle id="alert-dialog-title"> |
||||||
|
Invalid Content Structure |
||||||
|
</DialogTitle> |
||||||
|
<DialogContent> |
||||||
|
<DialogContentText id="alert-dialog-description"> |
||||||
|
This post seems to contain an invalid content structure. Click |
||||||
|
continue to proceed |
||||||
|
</DialogContentText> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<Button onClick={handleClose}>Close</Button> |
||||||
|
<Button onClick={onClick} autoFocus> |
||||||
|
Continue |
||||||
|
</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default BlogPostPreview |
@ -0,0 +1,7 @@ |
|||||||
|
import React from 'react' |
||||||
|
|
||||||
|
export const CreatEditProfile = () => { |
||||||
|
return ( |
||||||
|
<div>CreatEditProfile</div> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,14 @@ |
|||||||
|
import { styled } from '@mui/system' |
||||||
|
|
||||||
|
import { Button } from '@mui/material' |
||||||
|
|
||||||
|
export const BuilderButton = styled(Button)(({ theme }) => ({ |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial', |
||||||
|
transition: "all 0.3s ease-in-out", |
||||||
|
"&:hover": { |
||||||
|
cursor: "pointer", |
||||||
|
filter: "brightness(0.9)" |
||||||
|
} |
||||||
|
})); |
@ -0,0 +1,194 @@ |
|||||||
|
import { Box, Button, Typography } from '@mui/material' |
||||||
|
import React, { useMemo, useState } from 'react' |
||||||
|
import { ReusableModal } from '../../components/modals/ReusableModal' |
||||||
|
import { CreatePostBuilder } from './CreatePostBuilder' |
||||||
|
import { CreatePostMinimal } from './CreatePostMinimal' |
||||||
|
import HandymanRoundedIcon from '@mui/icons-material/HandymanRounded' |
||||||
|
import HourglassFullRoundedIcon from '@mui/icons-material/HourglassFullRounded' |
||||||
|
import { display } from '@mui/system' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { setIsLoadingGlobal } from '../../state/features/globalSlice' |
||||||
|
import { useParams } from 'react-router-dom' |
||||||
|
import { checkStructure } from '../../utils/checkStructure' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import { |
||||||
|
addPrefix, |
||||||
|
buildIdentifierFromCreateTitleIdAndId |
||||||
|
} from '../../utils/blogIdformats' |
||||||
|
import { Tipping } from '../../components/common/Tipping/Tipping' |
||||||
|
type EditorType = 'minimal' | 'builder' |
||||||
|
interface CreatePostProps { |
||||||
|
mode?: string |
||||||
|
} |
||||||
|
export const CreatePost = ({ mode }: CreatePostProps) => { |
||||||
|
const { user: username, postId, blog } = useParams() |
||||||
|
const fullPostId = useMemo(() => { |
||||||
|
if (!blog || !postId || mode !== 'edit') return '' |
||||||
|
const formBlogId = addPrefix(blog) |
||||||
|
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId) |
||||||
|
return formPostId |
||||||
|
}, [blog, postId, mode]) |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
|
||||||
|
const [toggleEditorType, setToggleEditorType] = useState<EditorType | null>( |
||||||
|
null |
||||||
|
) |
||||||
|
const [blogContentForEdit, setBlogContentForEdit] = useState<any>(null) |
||||||
|
const [blogMetadataForEdit, setBlogMetadataForEdit] = useState<any>(null) |
||||||
|
const [editType, setEditType] = useState<EditorType | null>(null) |
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false) |
||||||
|
const dispatch = useDispatch() |
||||||
|
React.useEffect(() => { |
||||||
|
if (!toggleEditorType && mode !== 'edit') { |
||||||
|
setIsOpen(true) |
||||||
|
} |
||||||
|
}, [setIsOpen, toggleEditorType]) |
||||||
|
|
||||||
|
const switchType = () => { |
||||||
|
setIsOpen(true) |
||||||
|
} |
||||||
|
|
||||||
|
const getBlogPost = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const url = `/arbitrary/BLOG_POST/${username}/${fullPostId}` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const responseData = await response.json() |
||||||
|
if (checkStructure(responseData)) { |
||||||
|
// setNewPostContent(responseData.postContent)
|
||||||
|
// setTitle(responseData?.title || '')
|
||||||
|
// setBlogInfo(responseData)
|
||||||
|
const blogType = responseData?.layoutGeneralSettings?.blogPostType |
||||||
|
|
||||||
|
if (blogType) { |
||||||
|
setEditType(blogType) |
||||||
|
setBlogContentForEdit(responseData) |
||||||
|
} |
||||||
|
//TODO - NAME SHOULD BE EXACT
|
||||||
|
// const url2 = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${fullPostId}&exactMatchNames=${username}&limit=1&includemetadata=true`
|
||||||
|
const url2 = `/arbitrary/resources?service=BLOG_POST&identifier=${fullPostId}&name=${username}&limit=1&includemetadata=true` |
||||||
|
|
||||||
|
const responseBlogs = await fetch(url2, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const dataMetadata = await responseBlogs.json() |
||||||
|
if (dataMetadata && dataMetadata.length > 0) { |
||||||
|
setBlogMetadataForEdit(dataMetadata[0]) |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, [username, fullPostId]) |
||||||
|
React.useEffect(() => { |
||||||
|
if (mode === 'edit') { |
||||||
|
getBlogPost() |
||||||
|
} |
||||||
|
}, [mode]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
{/* {toggleEditorType === 'minimal' && ( |
||||||
|
<Button onClick={() => switchType()}>Switch to Builder</Button> |
||||||
|
)} |
||||||
|
{toggleEditorType === 'builder' && ( |
||||||
|
<Button onClick={() => switchType()}>Switch to Minimal</Button> |
||||||
|
)} */} |
||||||
|
{isOpen && ( |
||||||
|
<ReusableModal |
||||||
|
open={isOpen} |
||||||
|
customStyles={{ |
||||||
|
maxWidth: '500px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{toggleEditorType && ( |
||||||
|
<Typography> |
||||||
|
Switching editor type will delete your current progress |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
|
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center', |
||||||
|
alignItems: 'center', |
||||||
|
gap: 2 |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
onClick={() => { |
||||||
|
setToggleEditorType('minimal') |
||||||
|
setIsOpen(false) |
||||||
|
}} |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
justifyContent: 'center', |
||||||
|
alignItems: 'center', |
||||||
|
padding: '20px', |
||||||
|
borderRadius: '6px', |
||||||
|
border: '1px solid', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography>Minimal Editor</Typography> |
||||||
|
<HourglassFullRoundedIcon /> |
||||||
|
</Box> |
||||||
|
<Box |
||||||
|
onClick={() => { |
||||||
|
setToggleEditorType('builder') |
||||||
|
setIsOpen(false) |
||||||
|
}} |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
justifyContent: 'center', |
||||||
|
alignItems: 'center', |
||||||
|
padding: '20px', |
||||||
|
borderRadius: '6px', |
||||||
|
border: '1px solid', |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography>Builder Editor</Typography> |
||||||
|
<HandymanRoundedIcon /> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<Button onClick={() => setIsOpen(false)}>Close</Button> |
||||||
|
</ReusableModal> |
||||||
|
)} |
||||||
|
|
||||||
|
{toggleEditorType === 'minimal' && ( |
||||||
|
<CreatePostMinimal switchType={switchType} /> |
||||||
|
)} |
||||||
|
{toggleEditorType === 'builder' && ( |
||||||
|
<CreatePostBuilder switchType={switchType} /> |
||||||
|
)} |
||||||
|
{mode === 'edit' && editType === 'minimal' && ( |
||||||
|
<CreatePostMinimal |
||||||
|
blogContentForEdit={blogContentForEdit} |
||||||
|
postIdForEdit={fullPostId} |
||||||
|
blogMetadataForEdit={blogMetadataForEdit} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{mode === 'edit' && editType === 'builder' && ( |
||||||
|
<CreatePostBuilder |
||||||
|
blogContentForEdit={blogContentForEdit} |
||||||
|
postIdForEdit={fullPostId} |
||||||
|
blogMetadataForEdit={blogMetadataForEdit} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,261 @@ |
|||||||
|
import React, { useCallback, useEffect } from 'react' |
||||||
|
|
||||||
|
import { |
||||||
|
Button, |
||||||
|
Box, |
||||||
|
Typography, |
||||||
|
Toolbar, |
||||||
|
AppBar, |
||||||
|
Select, |
||||||
|
InputLabel, |
||||||
|
FormControl, |
||||||
|
MenuItem, |
||||||
|
TextField, |
||||||
|
SelectChangeEvent, |
||||||
|
OutlinedInput, |
||||||
|
List, |
||||||
|
ListItem, |
||||||
|
useTheme |
||||||
|
} from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../../../state/store' |
||||||
|
import ShortUniqueId from 'short-unique-id' |
||||||
|
import DeleteIcon from '@mui/icons-material/Delete' |
||||||
|
import { CustomIcon } from '../../../../components/common/CustomIcon' |
||||||
|
|
||||||
|
const uid = new ShortUniqueId() |
||||||
|
interface INavbar { |
||||||
|
saveNav: (navMenu: any, navbarConfig: any) => void |
||||||
|
removeNav: () => void |
||||||
|
close: () => void |
||||||
|
} |
||||||
|
|
||||||
|
export const Navbar = ({ saveNav, removeNav, close }: INavbar) => { |
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
const { currentBlog } = useSelector((state: RootState) => state.global) |
||||||
|
const theme = useTheme() |
||||||
|
const [navTitle, setNavTitle] = React.useState<string>('') |
||||||
|
const [blogPostOption, setBlogPostOption] = React.useState<any | null>(null) |
||||||
|
const [options, setOptions] = React.useState<any>([]) |
||||||
|
const [navItems, setNavItems] = React.useState<any>([]) |
||||||
|
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||||
|
const optionId = event.target.value |
||||||
|
const selectedOption = options.find((option: any) => option.id === optionId) |
||||||
|
setBlogPostOption(selectedOption || null) |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (currentBlog && currentBlog?.navbarConfig) { |
||||||
|
const { navItems } = currentBlog.navbarConfig |
||||||
|
if (!navItems || !Array.isArray(navItems)) return |
||||||
|
|
||||||
|
setNavItems(navItems) |
||||||
|
} |
||||||
|
}, [currentBlog]) |
||||||
|
|
||||||
|
const getOptions = useCallback(async () => { |
||||||
|
if (!user || !currentBlog) return |
||||||
|
const name = user?.name |
||||||
|
const blog = currentBlog?.blogId |
||||||
|
|
||||||
|
try { |
||||||
|
//TODO - NAME SHOULD BE EXACT
|
||||||
|
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=${blog}-post-&exactmatchnames=true&name=${name}&includemetadata=true&reverse=true&limit=0` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
const responseData = await response.json() |
||||||
|
const formatOptions = responseData.map((option: any) => { |
||||||
|
return { |
||||||
|
id: option.identifier, |
||||||
|
name: option?.metadata.title |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
setOptions(formatOptions) |
||||||
|
} catch (error) {} |
||||||
|
}, []) |
||||||
|
useEffect(() => { |
||||||
|
getOptions() |
||||||
|
}, [getOptions]) |
||||||
|
const addToNav = () => { |
||||||
|
if (!navTitle || !blogPostOption) return |
||||||
|
setNavItems((prev: any) => [ |
||||||
|
...prev, |
||||||
|
{ |
||||||
|
id: uid(), |
||||||
|
name: navTitle, |
||||||
|
postId: blogPostOption.id, |
||||||
|
postName: blogPostOption.name |
||||||
|
} |
||||||
|
]) |
||||||
|
} |
||||||
|
|
||||||
|
const handleSaveNav = () => { |
||||||
|
if (!currentBlog) return |
||||||
|
saveNav(navItems, currentBlog?.navbarConfig || {}) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: 1 |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: 1, |
||||||
|
flexWrap: 'wrap' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box> |
||||||
|
<TextField |
||||||
|
label="Nav Item name" |
||||||
|
variant="outlined" |
||||||
|
fullWidth |
||||||
|
value={navTitle} |
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => |
||||||
|
setNavTitle(e.target.value) |
||||||
|
} |
||||||
|
inputProps={{ maxLength: 40 }} |
||||||
|
sx={{ |
||||||
|
marginBottom: 2, |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
border: `1px solid ${theme.palette.text.primary}` |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
<Box> |
||||||
|
<FormControl |
||||||
|
fullWidth |
||||||
|
sx={{ |
||||||
|
marginBottom: 2, |
||||||
|
width: '150px', |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
border: `1px solid ${theme.palette.text.primary}` |
||||||
|
}} |
||||||
|
> |
||||||
|
<InputLabel sx={{ color: theme.palette.text.primary }} id="Post"> |
||||||
|
Select a Post |
||||||
|
</InputLabel> |
||||||
|
<Select |
||||||
|
labelId="Post" |
||||||
|
input={<OutlinedInput label="Select a Post" />} |
||||||
|
value={blogPostOption?.id || ''} |
||||||
|
onChange={handleOptionChange} |
||||||
|
MenuProps={{ |
||||||
|
sx: { |
||||||
|
maxHeight: '300px' // Adjust this value to set the max height,
|
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
{options.map((option: any) => ( |
||||||
|
<MenuItem |
||||||
|
sx={{ color: theme.palette.text.primary }} |
||||||
|
key={option.id} |
||||||
|
value={option.id} |
||||||
|
> |
||||||
|
{option.name} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<Box> |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.light, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
border: `1px solid ${theme.palette.text.primary}` |
||||||
|
}} |
||||||
|
onClick={addToNav} |
||||||
|
> |
||||||
|
Add |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
|
||||||
|
<Box> |
||||||
|
<List |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flex: '1', |
||||||
|
overflow: 'auto' |
||||||
|
}} |
||||||
|
> |
||||||
|
{navItems.map((navItem: any) => ( |
||||||
|
<ListItem |
||||||
|
key={navItem.id} |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: '10px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Typography |
||||||
|
sx={{ |
||||||
|
fontWeight: 'bold' |
||||||
|
}} |
||||||
|
> |
||||||
|
{navItem.name} |
||||||
|
</Typography>{' '} |
||||||
|
<Typography>{navItem.postName}</Typography>{' '} |
||||||
|
<CustomIcon |
||||||
|
component={DeleteIcon} |
||||||
|
onClick={() => |
||||||
|
setNavItems((prev: any) => |
||||||
|
prev.filter((item: any) => item.id !== navItem.id) |
||||||
|
) |
||||||
|
} |
||||||
|
/> |
||||||
|
</ListItem> |
||||||
|
))} |
||||||
|
</List> |
||||||
|
</Box> |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.dark, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial' |
||||||
|
}} |
||||||
|
onClick={handleSaveNav} |
||||||
|
> |
||||||
|
Save Navbar |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.dark, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial' |
||||||
|
}} |
||||||
|
onClick={removeNav} |
||||||
|
> |
||||||
|
Remove Navbar |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
sx={{ |
||||||
|
backgroundColor: theme.palette.primary.dark, |
||||||
|
color: theme.palette.text.primary, |
||||||
|
fontFamily: 'Arial' |
||||||
|
}} |
||||||
|
onClick={close} |
||||||
|
> |
||||||
|
Close |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,157 @@ |
|||||||
|
import React from 'react' |
||||||
|
import TextFieldsIcon from '@mui/icons-material/TextFields' |
||||||
|
import Slider from '@mui/material/Slider' |
||||||
|
import { AudioPanel } from '../../../../components/common/AudioPanel' |
||||||
|
import { Box, Toolbar, AppBar, useTheme } from '@mui/material' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import ImageUploader from '../../../../components/common/ImageUploader' |
||||||
|
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate' |
||||||
|
import { VideoPanel } from '../../../../components/common/VideoPanel' |
||||||
|
import MenuOpenIcon from '@mui/icons-material/MenuOpen' |
||||||
|
import HandymanRoundedIcon from '@mui/icons-material/HandymanRounded' |
||||||
|
import Tooltip from '@mui/material/Tooltip' |
||||||
|
import { FilePanel } from '../../../../components/common/FilePanel' |
||||||
|
|
||||||
|
const CustomToolbar = styled(Toolbar)({ |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'space-between', |
||||||
|
alignItems: 'center' |
||||||
|
}) |
||||||
|
|
||||||
|
const CustomAppBar = styled(AppBar)(({ theme }) => ({ |
||||||
|
backgroundColor: |
||||||
|
theme.palette.mode === 'light' |
||||||
|
? theme.palette.background.default |
||||||
|
: '#19191b' |
||||||
|
})) |
||||||
|
|
||||||
|
interface IEditorToolbar { |
||||||
|
setIsOpenAddTextModal: (val: boolean) => void |
||||||
|
addImage: (base64: string) => void |
||||||
|
onSelectVideo: (video: any) => void |
||||||
|
onSelectAudio: (audio: any) => void |
||||||
|
onSelectFile: (file: any) => void |
||||||
|
paddingValue: number |
||||||
|
onChangePadding: (padding: number) => void |
||||||
|
isMinimal?: boolean |
||||||
|
addNav?: () => void |
||||||
|
switchType?: () => void |
||||||
|
} |
||||||
|
|
||||||
|
export const EditorToolbar = ({ |
||||||
|
setIsOpenAddTextModal, |
||||||
|
addImage, |
||||||
|
onSelectVideo, |
||||||
|
onSelectAudio, |
||||||
|
onSelectFile, |
||||||
|
paddingValue, |
||||||
|
onChangePadding, |
||||||
|
isMinimal = false, |
||||||
|
addNav, |
||||||
|
switchType |
||||||
|
}: IEditorToolbar) => { |
||||||
|
const theme = useTheme() |
||||||
|
return ( |
||||||
|
<CustomAppBar position="sticky"> |
||||||
|
<CustomToolbar variant="dense"> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'space-between', |
||||||
|
width: '100%', |
||||||
|
flexWrap: 'wrap', |
||||||
|
alignItems: 'center' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
gap: '10px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Tooltip title="Add Text" arrow> |
||||||
|
<TextFieldsIcon |
||||||
|
onClick={() => setIsOpenAddTextModal(true)} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
width: 'auto', |
||||||
|
height: '30px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Tooltip> |
||||||
|
|
||||||
|
<ImageUploader onPick={addImage}> |
||||||
|
<Tooltip title="Add an image" arrow> |
||||||
|
<AddPhotoAlternateIcon |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
width: 'auto', |
||||||
|
height: '30px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Tooltip> |
||||||
|
</ImageUploader> |
||||||
|
|
||||||
|
<VideoPanel onSelect={onSelectVideo} /> |
||||||
|
|
||||||
|
<AudioPanel onSelect={onSelectAudio} /> |
||||||
|
<FilePanel onSelect={onSelectFile} /> |
||||||
|
</Box> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
gap: '10px' |
||||||
|
}} |
||||||
|
> |
||||||
|
{!isMinimal && ( |
||||||
|
<Tooltip title="Adjust padding between elements" arrow> |
||||||
|
<Box> |
||||||
|
<Slider |
||||||
|
size="small" |
||||||
|
value={paddingValue} |
||||||
|
onChange={(event: any) => |
||||||
|
onChangePadding(event.target.value) |
||||||
|
} |
||||||
|
defaultValue={5} |
||||||
|
aria-label="Default" |
||||||
|
valueLabelDisplay="auto" |
||||||
|
min={0} |
||||||
|
max={40} |
||||||
|
sx={{ |
||||||
|
color: theme.palette.text.primary, |
||||||
|
width: '100px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</Tooltip> |
||||||
|
)} |
||||||
|
{!isMinimal && ( |
||||||
|
<Tooltip title="Manage your custom navbar links" arrow> |
||||||
|
<MenuOpenIcon |
||||||
|
onClick={addNav} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
width: 'auto', |
||||||
|
height: '30px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Tooltip> |
||||||
|
)} |
||||||
|
{switchType && ( |
||||||
|
<Tooltip title="Switch editor type" arrow> |
||||||
|
<HandymanRoundedIcon |
||||||
|
onClick={switchType} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
width: 'auto', |
||||||
|
height: '30px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Tooltip> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</CustomToolbar> |
||||||
|
</CustomAppBar> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,562 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { useParams } from 'react-router-dom' |
||||||
|
import BlogEditor from '../../components/editor/BlogEditor' |
||||||
|
import ShortUniqueId from 'short-unique-id' |
||||||
|
import { Button, TextField } from '@mui/material' |
||||||
|
import ReadOnlySlate from '../../components/editor/ReadOnlySlate' |
||||||
|
import { useDispatch, useSelector } from 'react-redux' |
||||||
|
import { RootState } from '../../state/store' |
||||||
|
import { Box } from '@mui/material' |
||||||
|
import ImageUploader from '../../components/common/ImageUploader' |
||||||
|
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate' |
||||||
|
import { checkStructure } from '../../utils/checkStructure' |
||||||
|
import { BlogContent } from '../../interfaces/interfaces' |
||||||
|
import PostAddIcon from '@mui/icons-material/PostAdd' |
||||||
|
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle' |
||||||
|
import EditIcon from '@mui/icons-material/Edit' |
||||||
|
import { createEditor, Descendant, Editor, Transforms } from 'slate' |
||||||
|
import { styled } from '@mui/system' |
||||||
|
import { setIsLoadingGlobal } from '../../state/features/globalSlice' |
||||||
|
import { extractTextFromSlate } from '../../utils/extractTextFromSlate' |
||||||
|
import { VideoContent } from '../../components/common/VideoContent' |
||||||
|
import { VideoPanel } from '../../components/common/VideoPanel' |
||||||
|
|
||||||
|
const initialValue: Descendant[] = [ |
||||||
|
{ |
||||||
|
type: 'paragraph', |
||||||
|
children: [ |
||||||
|
{ text: "Start writing your blog post... Don't forget to add a title :)" } |
||||||
|
] |
||||||
|
} |
||||||
|
] |
||||||
|
|
||||||
|
const BlogTitleInput = styled(TextField)(({ theme }) => ({ |
||||||
|
'& .MuiInputBase-input': { |
||||||
|
fontSize: '28px', |
||||||
|
height: '28px', |
||||||
|
'&::placeholder': { |
||||||
|
fontSize: '28px', |
||||||
|
color: theme.palette.text.secondary |
||||||
|
} |
||||||
|
}, |
||||||
|
'& .MuiInputLabel-root': { |
||||||
|
fontSize: '28px' |
||||||
|
} |
||||||
|
})) |
||||||
|
|
||||||
|
interface IaddVideo { |
||||||
|
name: string |
||||||
|
identifier: string |
||||||
|
service: string |
||||||
|
title: string |
||||||
|
description: string |
||||||
|
} |
||||||
|
|
||||||
|
const uid = new ShortUniqueId() |
||||||
|
export const EditPost = () => { |
||||||
|
const { user: username, postId } = useParams() |
||||||
|
|
||||||
|
const { user } = useSelector((state: RootState) => state.auth) |
||||||
|
|
||||||
|
const [newPostContent, setNewPostContent] = React.useState<any[]>([]) |
||||||
|
const [blogInfo, setBlogInfo] = React.useState<BlogContent | null>(null) |
||||||
|
const [editingSection, setEditingSection] = React.useState<any>(null) |
||||||
|
const [value, setValue] = React.useState(initialValue) |
||||||
|
const [value2, setValue2] = React.useState(initialValue) |
||||||
|
const [title, setTitle] = React.useState('') |
||||||
|
const dispatch = useDispatch() |
||||||
|
const addPostSection = React.useCallback((content: any) => { |
||||||
|
const section = { |
||||||
|
type: 'editor', |
||||||
|
version: 1, |
||||||
|
content, |
||||||
|
id: uid() |
||||||
|
} |
||||||
|
|
||||||
|
setNewPostContent((prev) => [...prev, section]) |
||||||
|
}, []) |
||||||
|
const editPostSection = React.useCallback( |
||||||
|
(content: any, section: any) => { |
||||||
|
const findSectionIndex = newPostContent.findIndex( |
||||||
|
(s) => s.id === section.id |
||||||
|
) |
||||||
|
|
||||||
|
if (findSectionIndex !== -1) { |
||||||
|
const copyNewPostContent = [...newPostContent] |
||||||
|
copyNewPostContent[findSectionIndex] = { |
||||||
|
...section, |
||||||
|
content |
||||||
|
} |
||||||
|
|
||||||
|
setNewPostContent(copyNewPostContent) |
||||||
|
} |
||||||
|
|
||||||
|
setEditingSection(null) |
||||||
|
}, |
||||||
|
[newPostContent] |
||||||
|
) |
||||||
|
|
||||||
|
function objectToBase64(obj: any) { |
||||||
|
// Step 1: Convert the object to a JSON string
|
||||||
|
const jsonString = JSON.stringify(obj) |
||||||
|
|
||||||
|
// Step 2: Create a Blob from the JSON string
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json' }) |
||||||
|
|
||||||
|
// Step 3: Create a FileReader to read the Blob as a base64-encoded string
|
||||||
|
return new Promise<string>((resolve, reject) => { |
||||||
|
const reader = new FileReader() |
||||||
|
reader.onloadend = () => { |
||||||
|
if (typeof reader.result === 'string') { |
||||||
|
// Remove 'data:application/json;base64,' prefix
|
||||||
|
const base64 = reader.result.replace( |
||||||
|
'data:application/json;base64,', |
||||||
|
'' |
||||||
|
) |
||||||
|
resolve(base64) |
||||||
|
} else { |
||||||
|
reject( |
||||||
|
new Error('Failed to read the Blob as a base64-encoded string') |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
reader.onerror = () => { |
||||||
|
reject(reader.error) |
||||||
|
} |
||||||
|
reader.readAsDataURL(blob) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const addImage = (base64: string) => { |
||||||
|
const section = { |
||||||
|
type: 'image', |
||||||
|
version: 1, |
||||||
|
content: { |
||||||
|
image: base64, |
||||||
|
caption: '' |
||||||
|
}, |
||||||
|
id: uid() |
||||||
|
} |
||||||
|
|
||||||
|
setNewPostContent((prev) => [...prev, section]) |
||||||
|
} |
||||||
|
|
||||||
|
async function getNameInfo(address: string) { |
||||||
|
const response = await fetch('/names/address/' + address) |
||||||
|
const nameData = await response.json() |
||||||
|
|
||||||
|
if (nameData?.length > 0) { |
||||||
|
return nameData[0].name |
||||||
|
} else { |
||||||
|
return '' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function publishQDNResource() { |
||||||
|
let address |
||||||
|
let name |
||||||
|
|
||||||
|
try { |
||||||
|
if (!user || !user.address) return |
||||||
|
address = user.address |
||||||
|
} catch (error) {} |
||||||
|
if (!address) return |
||||||
|
try { |
||||||
|
name = await getNameInfo(address) |
||||||
|
} catch (error) {} |
||||||
|
if (!name) return |
||||||
|
if (!blogInfo) return |
||||||
|
try { |
||||||
|
const postObject = { |
||||||
|
...blogInfo, |
||||||
|
title, |
||||||
|
postContent: newPostContent |
||||||
|
} |
||||||
|
const blogPostToBase64 = await objectToBase64(postObject) |
||||||
|
let description = '' |
||||||
|
const findText = newPostContent.find((data) => data?.type === 'editor') |
||||||
|
if (findText && findText.content) { |
||||||
|
description = extractTextFromSlate(findText?.content) |
||||||
|
description = description.slice(0, 180) |
||||||
|
} |
||||||
|
const resourceResponse = await qortalRequest({ |
||||||
|
action: 'PUBLISH_QDN_RESOURCE', |
||||||
|
name: name, |
||||||
|
service: 'BLOG_POST', |
||||||
|
data64: blogPostToBase64, |
||||||
|
title: title, |
||||||
|
description: description, |
||||||
|
category: 'TECHNOLOGY', |
||||||
|
tags: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'], |
||||||
|
metaData: 'description=destriptontest&category=catTest', |
||||||
|
identifier: postId |
||||||
|
}) |
||||||
|
} catch (error) { |
||||||
|
console.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const addSection = () => { |
||||||
|
addPostSection(value2) |
||||||
|
} |
||||||
|
|
||||||
|
const getBlogPost = React.useCallback(async () => { |
||||||
|
try { |
||||||
|
dispatch(setIsLoadingGlobal(true)) |
||||||
|
const url = `/arbitrary/BLOG_POST/${username}/${postId}` |
||||||
|
const response = await fetch(url, { |
||||||
|
method: 'GET', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const responseData = await response.json() |
||||||
|
if (checkStructure(responseData)) { |
||||||
|
setNewPostContent(responseData.postContent) |
||||||
|
setTitle(responseData?.title || '') |
||||||
|
setBlogInfo(responseData) |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
} finally { |
||||||
|
dispatch(setIsLoadingGlobal(false)) |
||||||
|
} |
||||||
|
}, [user, postId]) |
||||||
|
React.useEffect(() => { |
||||||
|
getBlogPost() |
||||||
|
}, []) |
||||||
|
|
||||||
|
const editSection = (section: any) => { |
||||||
|
setEditingSection(section) |
||||||
|
setValue(section.content) |
||||||
|
} |
||||||
|
|
||||||
|
const removeSection = (section: any) => { |
||||||
|
const newContent = newPostContent.filter((s) => s.id !== section.id) |
||||||
|
setNewPostContent(newContent) |
||||||
|
} |
||||||
|
const editImage = (base64: string, section: any) => { |
||||||
|
const newSection = { |
||||||
|
...section, |
||||||
|
content: { |
||||||
|
image: base64, |
||||||
|
caption: section.content.caption |
||||||
|
} |
||||||
|
} |
||||||
|
const findSectionIndex = newPostContent.findIndex( |
||||||
|
(s) => s.id === section.id |
||||||
|
) |
||||||
|
if (findSectionIndex !== -1) { |
||||||
|
const copyNewPostContent = [...newPostContent] |
||||||
|
copyNewPostContent[findSectionIndex] = newSection |
||||||
|
|
||||||
|
setNewPostContent(copyNewPostContent) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const editVideo = ( |
||||||
|
{ name, identifier, service, description, title }: IaddVideo, |
||||||
|
section: any |
||||||
|
) => { |
||||||
|
const newSection = { |
||||||
|
...section, |
||||||
|
content: { |
||||||
|
name: name, |
||||||
|
identifier: identifier, |
||||||
|
service: service, |
||||||
|
description, |
||||||
|
title |
||||||
|
} |
||||||
|
} |
||||||
|
const findSectionIndex = newPostContent.findIndex( |
||||||
|
(s) => s.id === section.id |
||||||
|
) |
||||||
|
if (findSectionIndex !== -1) { |
||||||
|
const copyNewPostContent = [...newPostContent] |
||||||
|
copyNewPostContent[findSectionIndex] = newSection |
||||||
|
|
||||||
|
setNewPostContent(copyNewPostContent) |
||||||
|
} |
||||||
|
} |
||||||
|
return ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
flexDirection: 'column' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
maxWidth: '700px', |
||||||
|
margin: '15px', |
||||||
|
width: '100%' |
||||||
|
}} |
||||||
|
> |
||||||
|
<BlogTitleInput |
||||||
|
id="modal-title-input" |
||||||
|
value={title} |
||||||
|
onChange={(e) => setTitle(e.target.value)} |
||||||
|
fullWidth |
||||||
|
placeholder="Title" |
||||||
|
variant="filled" |
||||||
|
multiline |
||||||
|
maxRows={2} |
||||||
|
InputLabelProps={{ shrink: false }} |
||||||
|
/> |
||||||
|
{newPostContent.map((section: any) => { |
||||||
|
if (section.type === 'editor') { |
||||||
|
return ( |
||||||
|
<Box key={section.id}> |
||||||
|
{editingSection && editingSection.id === section.id ? ( |
||||||
|
<BlogEditor |
||||||
|
editPostSection={editPostSection} |
||||||
|
defaultValue={section.content} |
||||||
|
section={section} |
||||||
|
value={value} |
||||||
|
setValue={setValue} |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'relative' |
||||||
|
}} |
||||||
|
> |
||||||
|
<ReadOnlySlate key={section.id} content={section.content} /> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
right: '5px', |
||||||
|
zIndex: 5, |
||||||
|
top: '50%', |
||||||
|
transform: 'translateY(-50%)', |
||||||
|
display: 'flex', |
||||||
|
// flexDirection: 'column',
|
||||||
|
gap: 2, |
||||||
|
background: 'white', |
||||||
|
padding: '5px', |
||||||
|
borderRadius: '5px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<RemoveCircleIcon |
||||||
|
onClick={() => removeSection(section)} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
/> |
||||||
|
<EditIcon |
||||||
|
onClick={() => editSection(section)} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
{editingSection && editingSection.id === section.id ? ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex', |
||||||
|
width: '100%', |
||||||
|
justifyContent: 'flex-end' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Button onClick={() => setEditingSection(null)}> |
||||||
|
Close |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
) : ( |
||||||
|
<></> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section.type === 'image') { |
||||||
|
return ( |
||||||
|
<Box key={section.id}> |
||||||
|
{editingSection && editingSection.id === section.id ? ( |
||||||
|
<ImageUploader |
||||||
|
onPick={(base64) => editImage(base64, section)} |
||||||
|
> |
||||||
|
Add Image |
||||||
|
<AddPhotoAlternateIcon /> |
||||||
|
</ImageUploader> |
||||||
|
) : ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'relative' |
||||||
|
}} |
||||||
|
> |
||||||
|
<img |
||||||
|
src={section.content.image} |
||||||
|
className="post-image" |
||||||
|
style={{ |
||||||
|
marginTop: '20px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
right: '5px', |
||||||
|
zIndex: 5, |
||||||
|
top: '50%', |
||||||
|
transform: 'translateY(-50%)', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: 2, |
||||||
|
background: 'white', |
||||||
|
padding: '5px', |
||||||
|
borderRadius: '5px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<RemoveCircleIcon |
||||||
|
onClick={() => removeSection(section)} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
/> |
||||||
|
<ImageUploader |
||||||
|
onPick={(base64) => editImage(base64, section)} |
||||||
|
> |
||||||
|
<EditIcon |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</ImageUploader> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
{editingSection && editingSection.id === section.id ? ( |
||||||
|
<Button onClick={() => setEditingSection(null)}>Close</Button> |
||||||
|
) : ( |
||||||
|
<></> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
if (section.type === 'video') { |
||||||
|
return ( |
||||||
|
<Box key={section.id}> |
||||||
|
{editingSection && editingSection.id === section.id ? ( |
||||||
|
<VideoPanel |
||||||
|
width="24px" |
||||||
|
height="24px" |
||||||
|
onSelect={(video) => |
||||||
|
editVideo( |
||||||
|
{ |
||||||
|
name: video.name, |
||||||
|
identifier: video.identifier, |
||||||
|
service: video.service, |
||||||
|
title: video?.metadata?.title, |
||||||
|
description: video?.metadata?.description |
||||||
|
}, |
||||||
|
section |
||||||
|
) |
||||||
|
} |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'relative' |
||||||
|
}} |
||||||
|
> |
||||||
|
<VideoContent |
||||||
|
title={section.content?.title} |
||||||
|
description={section.content?.description} |
||||||
|
/> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'absolute', |
||||||
|
right: '5px', |
||||||
|
zIndex: 5, |
||||||
|
top: '50%', |
||||||
|
transform: 'translateY(-50%)', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: 2, |
||||||
|
background: 'white', |
||||||
|
padding: '5px', |
||||||
|
borderRadius: '5px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<RemoveCircleIcon |
||||||
|
onClick={() => removeSection(section)} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer' |
||||||
|
}} |
||||||
|
/> |
||||||
|
<VideoPanel |
||||||
|
width="24px" |
||||||
|
height="24px" |
||||||
|
onSelect={(video) => |
||||||
|
editVideo( |
||||||
|
{ |
||||||
|
name: video.name, |
||||||
|
identifier: video.identifier, |
||||||
|
service: video.service, |
||||||
|
title: video?.metadata?.title, |
||||||
|
description: video?.metadata?.description |
||||||
|
}, |
||||||
|
section |
||||||
|
) |
||||||
|
} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
{editingSection && editingSection.id === section.id ? ( |
||||||
|
<Button onClick={() => setEditingSection(null)}>Close</Button> |
||||||
|
) : ( |
||||||
|
<></> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |
||||||
|
})} |
||||||
|
|
||||||
|
<BlogEditor |
||||||
|
addPostSection={addPostSection} |
||||||
|
value={value2} |
||||||
|
setValue={setValue2} |
||||||
|
/> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: 'flex' |
||||||
|
}} |
||||||
|
> |
||||||
|
<PostAddIcon |
||||||
|
onClick={addSection} |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
width: '50px', |
||||||
|
height: '50px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
<ImageUploader onPick={addImage}> |
||||||
|
<AddPhotoAlternateIcon |
||||||
|
sx={{ |
||||||
|
cursor: 'pointer', |
||||||
|
width: '50px', |
||||||
|
height: '50px' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</ImageUploader> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
position: 'fixed', |
||||||
|
bottom: '30px', |
||||||
|
right: '30px', |
||||||
|
zIndex: 15, |
||||||
|
background: 'deepskyblue', |
||||||
|
padding: '10px', |
||||||
|
borderRadius: '5px' |
||||||
|
}} |
||||||
|
> |
||||||
|
<Button onClick={publishQDNResource}>PUBLISH UPDATE</Button> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
) |
||||||
|
} |