@ -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,14 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> |
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>Q-Blog</title> |
||||
</head> |
||||
<body> |
||||
<div id="root"></div> |
||||
<script type="module" src="/src/main.tsx"></script> |
||||
</body> |
||||
</html> |
@ -0,0 +1,54 @@
|
||||
{ |
||||
"name": "q-blog", |
||||
"private": true, |
||||
"version": "0.0.0", |
||||
"type": "module", |
||||
"scripts": { |
||||
"dev": "vite", |
||||
"build": "tsc && vite build", |
||||
"preview": "vite preview" |
||||
}, |
||||
"dependencies": { |
||||
"@emotion/react": "^11.10.6", |
||||
"@emotion/styled": "^11.10.6", |
||||
"@mui/icons-material": "^5.11.11", |
||||
"@mui/material": "^5.11.13", |
||||
"@reduxjs/toolkit": "^1.9.3", |
||||
"@types/react-grid-layout": "^1.3.2", |
||||
"axios": "^1.3.4", |
||||
"compressorjs": "^1.2.1", |
||||
"localforage": "^1.10.0", |
||||
"moment": "^2.29.4", |
||||
"philliplm-react-modern-audio-player": "^1.4.6", |
||||
"react": "^18.2.0", |
||||
"react-copy-to-clipboard": "^5.1.0", |
||||
"react-dnd": "^16.0.1", |
||||
"react-dnd-html5-backend": "^16.0.1", |
||||
"react-dom": "^18.2.0", |
||||
"react-dropzone": "^14.2.3", |
||||
"react-grid-layout": "^1.3.4", |
||||
"react-intersection-observer": "^9.4.3", |
||||
"react-masonry-css": "^1.0.16", |
||||
"react-redux": "^8.0.5", |
||||
"react-resize-detector": "^8.0.4", |
||||
"react-router-dom": "^6.9.0", |
||||
"react-toastify": "^9.1.2", |
||||
"react-virtuoso": "^4.3.3", |
||||
"short-unique-id": "^4.4.4", |
||||
"slate": "^0.91.4", |
||||
"slate-history": "^0.86.0", |
||||
"slate-react": "^0.91.11", |
||||
"ts-key-enum": "^2.0.12" |
||||
}, |
||||
"devDependencies": { |
||||
"@mui/types": "^7.2.3", |
||||
"@types/react": "^18.0.28", |
||||
"@types/react-copy-to-clipboard": "^5.0.4", |
||||
"@types/react-dom": "^18.0.11", |
||||
"@vitejs/plugin-react-swc": "^3.2.0", |
||||
"prettier": "^2.8.6", |
||||
"typescript": "^4.9.3", |
||||
"vite": "^4.2.0", |
||||
"worker-loader": "^3.0.8" |
||||
} |
||||
} |
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,67 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { Routes, Route } from 'react-router-dom' |
||||
import { BlogIndividualPost } from './pages/BlogIndividualPost/BlogIndividualPost' |
||||
import { BlogIndividualProfile } from './pages/BlogIndividualProfile/BlogIndividualProfile' |
||||
import { BlogList } from './pages/BlogList/BlogList' |
||||
import { CreatePost } from './pages/CreatePost/CreatePost' |
||||
import { CreatEditProfile } from './pages/CreateEditProfile/CreatEditProfile' |
||||
import { ThemeProvider } from '@mui/material/styles' |
||||
import { CssBaseline } from '@mui/material' |
||||
import { lightTheme, darkTheme } from './styles/theme' |
||||
import { store } from './state/store' |
||||
import { Provider } from 'react-redux' |
||||
import GlobalWrapper from './wrappers/GlobalWrapper' |
||||
import DownloadWrapper from './wrappers/DownloadWrapper' |
||||
import Notification from './components/common/Notification/Notification' |
||||
import { useState } from 'react' |
||||
import { Mail } from './pages/Mail/Mail' |
||||
|
||||
function App() { |
||||
const themeColor = window._qdnTheme |
||||
|
||||
// const [colorTheme, setColorTheme] = useState('dark')
|
||||
|
||||
// const toggleDarkMode = () => {
|
||||
// setIsDarkMode("dark");
|
||||
// }
|
||||
|
||||
return ( |
||||
<Provider store={store}> |
||||
<ThemeProvider theme={themeColor === 'light' ? lightTheme : darkTheme}> |
||||
<Notification /> |
||||
<DownloadWrapper> |
||||
<GlobalWrapper> |
||||
<CssBaseline /> |
||||
|
||||
<Routes> |
||||
<Route |
||||
path="/:user/:blog/:postId" |
||||
element={<BlogIndividualPost />} |
||||
/> |
||||
<Route |
||||
path="/:user/:blog/:postId/edit" |
||||
element={<CreatePost mode="edit" />} |
||||
/> |
||||
<Route path="/:user/:blog" element={<BlogIndividualProfile />} /> |
||||
<Route path="/post/new" element={<CreatePost />} /> |
||||
<Route path="/profile/new" element={<CreatEditProfile />} /> |
||||
<Route |
||||
path="/favorites" |
||||
element={<BlogList mode="favorites" />} |
||||
/> |
||||
<Route |
||||
path="/subscriptions" |
||||
element={<BlogList mode="subscriptions" />} |
||||
/> |
||||
<Route path="/mail" element={<Mail />} /> |
||||
<Route path="/" element={<BlogList />} /> |
||||
</Routes> |
||||
</GlobalWrapper> |
||||
</DownloadWrapper> |
||||
</ThemeProvider> |
||||
</Provider> |
||||
) |
||||
} |
||||
|
||||
export default App |
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.4 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,230 @@
|
||||
import * as React from 'react' |
||||
import { styled, useTheme } from '@mui/material/styles' |
||||
import Box from '@mui/material/Box' |
||||
import Typography from '@mui/material/Typography' |
||||
|
||||
import AudiotrackIcon from '@mui/icons-material/Audiotrack' |
||||
import { MyContext } from '../wrappers/DownloadWrapper' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../state/store' |
||||
import { CircularProgress } from '@mui/material' |
||||
import { |
||||
setCurrAudio, |
||||
setShowingAudioPlayer |
||||
} from '../state/features/globalSlice' |
||||
|
||||
const Widget = styled('div')(({ theme }) => ({ |
||||
padding: 16, |
||||
borderRadius: 16, |
||||
maxWidth: '100%', |
||||
position: 'relative', |
||||
zIndex: 1, |
||||
// backgroundColor:
|
||||
// theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.4)',
|
||||
backdropFilter: 'blur(40px)', |
||||
background: 'skyblue', |
||||
transition: '0.2s all', |
||||
'&:hover': { |
||||
opacity: 0.75 |
||||
} |
||||
})) |
||||
|
||||
const CoverImage = styled('div')({ |
||||
width: 100, |
||||
height: 100, |
||||
objectFit: 'cover', |
||||
overflow: 'hidden', |
||||
flexShrink: 0, |
||||
borderRadius: 8, |
||||
backgroundColor: 'rgba(0,0,0,0.08)', |
||||
'& > img': { |
||||
width: '100%' |
||||
} |
||||
}) |
||||
|
||||
const TinyText = styled(Typography)({ |
||||
fontSize: '0.75rem', |
||||
opacity: 0.38, |
||||
fontWeight: 500, |
||||
letterSpacing: 0.2 |
||||
}) |
||||
|
||||
interface IAudioElement { |
||||
onClick: () => void |
||||
title: string |
||||
description: string |
||||
author: string |
||||
audioInfo?: any |
||||
postId?: string |
||||
user?: string |
||||
} |
||||
|
||||
export default function AudioElement({ |
||||
onClick, |
||||
title, |
||||
description, |
||||
author, |
||||
audioInfo, |
||||
postId, |
||||
user |
||||
}: IAudioElement) { |
||||
const { downloadVideo } = React.useContext(MyContext) |
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false) |
||||
const { downloads } = useSelector((state: RootState) => state.global) |
||||
const reDownload = React.useRef<boolean>(false) |
||||
|
||||
const dispatch = useDispatch() |
||||
const download = React.useMemo(() => { |
||||
if (!downloads || !audioInfo?.identifier) return {} |
||||
const findDownload = downloads[audioInfo?.identifier] |
||||
|
||||
if (!findDownload) return {} |
||||
return findDownload |
||||
}, [downloads, audioInfo]) |
||||
|
||||
const resourceStatus = React.useMemo(() => { |
||||
return download?.status || {} |
||||
}, [download]) |
||||
const handlePlay = () => { |
||||
if (!postId) return |
||||
const { name, service, identifier } = audioInfo |
||||
|
||||
if (download && resourceStatus?.status === 'READY') { |
||||
dispatch(setShowingAudioPlayer(true)) |
||||
dispatch(setCurrAudio(identifier)) |
||||
return |
||||
} |
||||
setIsLoading(true) |
||||
downloadVideo({ |
||||
name, |
||||
service, |
||||
identifier, |
||||
blogPost: { |
||||
postId, |
||||
user, |
||||
audioTitle: title, |
||||
audioDescription: description, |
||||
audioAuthor: author |
||||
} |
||||
}) |
||||
dispatch(setCurrAudio(identifier)) |
||||
dispatch(setShowingAudioPlayer(true)) |
||||
} |
||||
|
||||
React.useEffect(() => { |
||||
if (resourceStatus?.status === 'READY') { |
||||
setIsLoading(false) |
||||
} |
||||
}, [resourceStatus]) |
||||
|
||||
React.useEffect(() => { |
||||
if ( |
||||
resourceStatus?.status === 'DOWNLOADED' && |
||||
reDownload?.current === false |
||||
) { |
||||
handlePlay() |
||||
reDownload.current = true |
||||
} |
||||
}, [handlePlay, resourceStatus]) |
||||
return ( |
||||
<Box |
||||
onClick={handlePlay} |
||||
sx={{ |
||||
width: '100%', |
||||
overflow: 'hidden', |
||||
position: 'relative', |
||||
cursor: 'pointer' |
||||
}} |
||||
> |
||||
<Widget> |
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}> |
||||
<CoverImage> |
||||
<AudiotrackIcon |
||||
sx={{ |
||||
width: '90%', |
||||
height: 'auto' |
||||
}} |
||||
/> |
||||
</CoverImage> |
||||
<Box sx={{ ml: 1.5, minWidth: 0 }}> |
||||
<Typography |
||||
variant="caption" |
||||
color="text.secondary" |
||||
fontWeight={500} |
||||
> |
||||
{author} |
||||
</Typography> |
||||
<Typography noWrap> |
||||
<b>{title}</b> |
||||
</Typography> |
||||
<Typography noWrap letterSpacing={-0.25}> |
||||
{description} |
||||
</Typography> |
||||
</Box> |
||||
</Box> |
||||
{((resourceStatus.status && resourceStatus?.status !== 'READY') || |
||||
isLoading) && ( |
||||
<Box |
||||
position="absolute" |
||||
top={0} |
||||
left={0} |
||||
right={0} |
||||
bottom={0} |
||||
display="flex" |
||||
justifyContent="center" |
||||
alignItems="center" |
||||
zIndex={4999} |
||||
bgcolor="rgba(0, 0, 0, 0.6)" |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
gap: '10px', |
||||
padding: '16px', |
||||
borderRadius: '16px' |
||||
}} |
||||
> |
||||
<CircularProgress color="secondary" /> |
||||
{resourceStatus && ( |
||||
<Typography |
||||
variant="subtitle2" |
||||
component="div" |
||||
sx={{ |
||||
color: 'white', |
||||
fontSize: '14px' |
||||
}} |
||||
> |
||||
{resourceStatus?.status === 'REFETCHING' ? ( |
||||
<> |
||||
<> |
||||
{( |
||||
(resourceStatus?.localChunkCount / |
||||
resourceStatus?.totalChunkCount) * |
||||
100 |
||||
)?.toFixed(0)} |
||||
% |
||||
</> |
||||
|
||||
<> Refetching in 25 seconds</> |
||||
</> |
||||
) : resourceStatus?.status === 'DOWNLOADED' ? ( |
||||
<>Download Completed: building audio...</> |
||||
) : resourceStatus?.status !== 'READY' ? ( |
||||
<> |
||||
{( |
||||
(resourceStatus?.localChunkCount / |
||||
resourceStatus?.totalChunkCount) * |
||||
100 |
||||
)?.toFixed(0)} |
||||
% |
||||
</> |
||||
) : ( |
||||
<>Download Completed: fetching audio...</> |
||||
)} |
||||
</Typography> |
||||
)} |
||||
</Box> |
||||
)} |
||||
</Widget> |
||||
</Box> |
||||
) |
||||
} |
@ -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,445 @@
|
||||
import * as React from 'react' |
||||
import { styled, useTheme } from '@mui/material/styles' |
||||
import Box from '@mui/material/Box' |
||||
import Typography from '@mui/material/Typography' |
||||
import AudiotrackIcon from '@mui/icons-material/Audiotrack' |
||||
import { MyContext } from '../wrappers/DownloadWrapper' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../state/store' |
||||
import { CircularProgress } from '@mui/material' |
||||
import AttachFileIcon from '@mui/icons-material/AttachFile' |
||||
import { |
||||
setCurrAudio, |
||||
setShowingAudioPlayer |
||||
} from '../state/features/globalSlice' |
||||
import { |
||||
base64ToUint8Array, |
||||
objectToUint8ArrayFromResponse |
||||
} from '../utils/toBase64' |
||||
import { setNotification } from '../state/features/notificationsSlice' |
||||
|
||||
const Widget = styled('div')(({ theme }) => ({ |
||||
padding: 8, |
||||
borderRadius: 10, |
||||
maxWidth: 350, |
||||
position: 'relative', |
||||
zIndex: 1, |
||||
// backgroundColor:
|
||||
// theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.4)',
|
||||
backdropFilter: 'blur(40px)', |
||||
background: 'skyblue', |
||||
transition: '0.2s all', |
||||
'&:hover': { |
||||
opacity: 0.75 |
||||
} |
||||
})) |
||||
|
||||
const CoverImage = styled('div')({ |
||||
width: 40, |
||||
height: 40, |
||||
objectFit: 'cover', |
||||
overflow: 'hidden', |
||||
flexShrink: 0, |
||||
borderRadius: 8, |
||||
backgroundColor: 'rgba(0,0,0,0.08)', |
||||
'& > img': { |
||||
width: '100%' |
||||
} |
||||
}) |
||||
|
||||
const TinyText = styled(Typography)({ |
||||
fontSize: '0.75rem', |
||||
opacity: 0.38, |
||||
fontWeight: 500, |
||||
letterSpacing: 0.2 |
||||
}) |
||||
|
||||
interface IAudioElement { |
||||
title: string |
||||
description?: string |
||||
author?: string |
||||
fileInfo?: any |
||||
postId?: string |
||||
user?: string |
||||
children?: React.ReactNode |
||||
mimeType?: string |
||||
disable?: boolean |
||||
mode?: string |
||||
otherUser?: string |
||||
} |
||||
|
||||
interface CustomWindow extends Window { |
||||
showSaveFilePicker: any // Replace 'any' with the appropriate type if you know it
|
||||
} |
||||
|
||||
const customWindow = window as unknown as CustomWindow |
||||
|
||||
export default function FileElement({ |
||||
title, |
||||
description, |
||||
author, |
||||
fileInfo, |
||||
postId = '', |
||||
user, |
||||
children, |
||||
mimeType, |
||||
disable, |
||||
mode, |
||||
otherUser |
||||
}: IAudioElement) { |
||||
const { downloadVideo } = React.useContext(MyContext) |
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false) |
||||
const [fileProperties, setFileProperties] = React.useState<any>(null) |
||||
const [downloadLoader, setDownloadLoader] = React.useState<any>(false) |
||||
|
||||
const [pdfSrc, setPdfSrc] = React.useState('') |
||||
const { downloads } = useSelector((state: RootState) => state.global) |
||||
const { user: username } = useSelector((state: RootState) => state.auth) |
||||
|
||||
const dispatch = useDispatch() |
||||
const download = React.useMemo(() => { |
||||
if (!downloads || !fileInfo?.identifier) return {} |
||||
const findDownload = downloads[fileInfo?.identifier] |
||||
|
||||
if (!findDownload) return {} |
||||
return findDownload |
||||
}, [downloads, fileInfo]) |
||||
|
||||
const resourceStatus = React.useMemo(() => { |
||||
return download?.status || {} |
||||
}, [download]) |
||||
const saveFileToDisk = async (blob: any, fileName: any) => { |
||||
try { |
||||
const fileHandle = await customWindow.showSaveFilePicker({ |
||||
suggestedName: fileName, |
||||
types: [ |
||||
{ |
||||
description: 'File' |
||||
} |
||||
] |
||||
}) |
||||
const writeFile = async (fileHandle: any, contents: any) => { |
||||
const writable = await fileHandle.createWritable() |
||||
await writable.write(contents) |
||||
await writable.close() |
||||
} |
||||
writeFile(fileHandle, blob).then(() => console.log('FILE SAVED')) |
||||
} catch (error) { |
||||
console.log(error) |
||||
} |
||||
} |
||||
const handlePlay = async () => { |
||||
if (disable) return |
||||
if ( |
||||
resourceStatus?.status === 'READY' && |
||||
download?.url && |
||||
download?.blogPost?.filename |
||||
) { |
||||
if (downloadLoader) return |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Saving file... please wait', |
||||
alertType: 'info' |
||||
}) |
||||
) |
||||
try { |
||||
const { name, service, identifier } = fileInfo |
||||
|
||||
setDownloadLoader(true) |
||||
const url = `/arbitrary/${service}/${name}/${identifier}` |
||||
fetch(url) |
||||
.then((response) => response.blob()) |
||||
.then(async (blob) => { |
||||
await qortalRequest({ |
||||
action: 'SAVE_FILE', |
||||
blob, |
||||
filename: download?.blogPost?.filename, |
||||
mimeType: download?.blogPost?.mimeType || '' |
||||
}) |
||||
// saveAs(blob, download?.blogPost?.filename)
|
||||
}) |
||||
.catch((error) => { |
||||
console.error('Error fetching the video:', error) |
||||
// clearInterval(intervalId)
|
||||
}) |
||||
.finally(() => { |
||||
setDownloadLoader(false) |
||||
}) |
||||
} catch (error: any) { |
||||
let notificationObj = null |
||||
if (typeof error === 'string') { |
||||
notificationObj = { |
||||
msg: error || 'Failed to send message', |
||||
alertType: 'error' |
||||
} |
||||
} else if (typeof error?.error === 'string') { |
||||
notificationObj = { |
||||
msg: error?.error || 'Failed to send message', |
||||
alertType: 'error' |
||||
} |
||||
} else { |
||||
notificationObj = { |
||||
msg: error?.message || 'Failed to send message', |
||||
alertType: 'error' |
||||
} |
||||
} |
||||
if (!notificationObj) return |
||||
dispatch(setNotification(notificationObj)) |
||||
} finally { |
||||
if (mode === 'mail') { |
||||
setDownloadLoader(false) |
||||
} |
||||
} |
||||
return |
||||
} |
||||
if (!postId) return |
||||
const { name, service, identifier } = fileInfo |
||||
let filename = fileProperties?.filename |
||||
let mimeType = fileProperties?.mimeType |
||||
if (!fileProperties) { |
||||
try { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Downloading file... please wait', |
||||
alertType: 'info' |
||||
}) |
||||
) |
||||
let res = await qortalRequest({ |
||||
action: 'GET_QDN_RESOURCE_PROPERTIES', |
||||
name: name, |
||||
service: service, |
||||
identifier: identifier |
||||
}) |
||||
setFileProperties(res) |
||||
filename = res?.filename |
||||
mimeType = res?.mimeType |
||||
} catch (error: any) { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: error?.message || 'Error with download. Please try again', |
||||
alertType: 'error' |
||||
}) |
||||
) |
||||
} |
||||
} |
||||
if (!filename) return |
||||
|
||||
setIsLoading(true) |
||||
downloadVideo({ |
||||
name, |
||||
service, |
||||
identifier, |
||||
blogPost: { |
||||
postId, |
||||
user, |
||||
audioTitle: title, |
||||
audioDescription: description, |
||||
audioAuthor: author, |
||||
filename, |
||||
mimeType |
||||
} |
||||
}) |
||||
} |
||||
|
||||
React.useEffect(() => { |
||||
if ( |
||||
resourceStatus?.status === 'READY' && |
||||
download?.url && |
||||
download?.blogPost?.filename |
||||
) { |
||||
setIsLoading(false) |
||||
} |
||||
}, [resourceStatus, download]) |
||||
|
||||
return ( |
||||
<Box |
||||
onClick={handlePlay} |
||||
sx={{ |
||||
width: '100%', |
||||
overflow: 'hidden', |
||||
position: 'relative', |
||||
cursor: 'pointer' |
||||
}} |
||||
> |
||||
{children && ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
position: 'relative', |
||||
gap: '7px' |
||||
}} |
||||
> |
||||
{children}{' '} |
||||
{(resourceStatus.status && resourceStatus?.status !== 'READY') || |
||||
isLoading ? ( |
||||
<CircularProgress color="secondary" size={14} /> |
||||
) : resourceStatus?.status === 'READY' ? ( |
||||
<> |
||||
<Typography |
||||
sx={{ |
||||
fontSize: '14px' |
||||
}} |
||||
> |
||||
Ready to save: click here |
||||
</Typography> |
||||
{downloadLoader && ( |
||||
<CircularProgress color="secondary" size={14} /> |
||||
)} |
||||
</> |
||||
) : null} |
||||
</Box> |
||||
)} |
||||
{!children && ( |
||||
<Widget> |
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}> |
||||
<CoverImage> |
||||
<AttachFileIcon |
||||
sx={{ |
||||
width: '90%', |
||||
height: 'auto' |
||||
}} |
||||
/> |
||||
</CoverImage> |
||||
<Box sx={{ ml: 1.5, minWidth: 0 }}> |
||||
<Typography |
||||
variant="caption" |
||||
color="text.secondary" |
||||
fontWeight={500} |
||||
> |
||||
{author} |
||||
</Typography> |
||||
<Typography |
||||
noWrap |
||||
sx={{ |
||||
fontSize: '16px' |
||||
}} |
||||
> |
||||
<b>{title}</b> |
||||
</Typography> |
||||
<Typography |
||||
noWrap |
||||
letterSpacing={-0.25} |
||||
sx={{ |
||||
fontSize: '14px' |
||||
}} |
||||
> |
||||
{description} |
||||
</Typography> |
||||
{mimeType && ( |
||||
<Typography |
||||
noWrap |
||||
letterSpacing={-0.25} |
||||
sx={{ |
||||
fontSize: '12px' |
||||
}} |
||||
> |
||||
{mimeType} |
||||
</Typography> |
||||
)} |
||||
</Box> |
||||
</Box> |
||||
{((resourceStatus.status && resourceStatus?.status !== 'READY') || |
||||
isLoading) && ( |
||||
<Box |
||||
position="absolute" |
||||
top={0} |
||||
left={0} |
||||
right={0} |
||||
bottom={0} |
||||
display="flex" |
||||
justifyContent="center" |
||||
alignItems="center" |
||||
zIndex={4999} |
||||
bgcolor="rgba(0, 0, 0, 0.6)" |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
gap: '10px', |
||||
padding: '8px', |
||||
borderRadius: '10px' |
||||
}} |
||||
> |
||||
<CircularProgress color="secondary" /> |
||||
{resourceStatus && ( |
||||
<Typography |
||||
variant="subtitle2" |
||||
component="div" |
||||
sx={{ |
||||
color: 'white', |
||||
fontSize: '14px' |
||||
}} |
||||
> |
||||
{resourceStatus?.status === 'REFETCHING' ? ( |
||||
<> |
||||
<> |
||||
{( |
||||
(resourceStatus?.localChunkCount / |
||||
resourceStatus?.totalChunkCount) * |
||||
100 |
||||
)?.toFixed(0)} |
||||
% |
||||
</> |
||||
|
||||
<> Refetching in 2 minutes</> |
||||
</> |
||||
) : resourceStatus?.status === 'DOWNLOADED' ? ( |
||||
<>Download Completed: building file...</> |
||||
) : resourceStatus?.status !== 'READY' ? ( |
||||
<> |
||||
{( |
||||
(resourceStatus?.localChunkCount / |
||||
resourceStatus?.totalChunkCount) * |
||||
100 |
||||
)?.toFixed(0)} |
||||
% |
||||
</> |
||||
) : ( |
||||
<>Download Completed: fetching file...</> |
||||
)} |
||||
</Typography> |
||||
)} |
||||
</Box> |
||||
)} |
||||
{resourceStatus?.status === 'READY' && |
||||
download?.url && |
||||
download?.blogPost?.filename && ( |
||||
<Box |
||||
position="absolute" |
||||
top={0} |
||||
left={0} |
||||
right={0} |
||||
bottom={0} |
||||
display="flex" |
||||
justifyContent="center" |
||||
alignItems="center" |
||||
zIndex={4999} |
||||
bgcolor="rgba(0, 0, 0, 0.6)" |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
gap: '10px', |
||||
padding: '8px', |
||||
borderRadius: '10px' |
||||
}} |
||||
> |
||||
<Typography |
||||
variant="subtitle2" |
||||
component="div" |
||||
sx={{ |
||||
color: 'white', |
||||
fontSize: '14px' |
||||
}} |
||||
> |
||||
Ready to save: click here |
||||
</Typography> |
||||
{downloadLoader && ( |
||||
<CircularProgress color="secondary" size={14} /> |
||||
)} |
||||
</Box> |
||||
)} |
||||
</Widget> |
||||
)} |
||||
</Box> |
||||
) |
||||
} |
@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect } from 'react' |
||||
import { styled, Box } from '@mui/system' |
||||
import { |
||||
Drawer, |
||||
List, |
||||
ListItem, |
||||
ListItemText, |
||||
Typography, |
||||
ButtonBase, |
||||
Button, |
||||
Tooltip |
||||
} from '@mui/material' |
||||
import VideoCallIcon from '@mui/icons-material/VideoCall' |
||||
import VideoModal from './VideoPublishModal' |
||||
import { useSelector } from 'react-redux' |
||||
import { RootState } from '../../state/store' |
||||
import { AudioModal } from './AudioPublishModal' |
||||
import AudioFileIcon from '@mui/icons-material/AudioFile' |
||||
interface VideoPanelProps { |
||||
onSelect: (video: Video) => void |
||||
height?: string |
||||
width?: string |
||||
} |
||||
|
||||
interface VideoApiResponse { |
||||
videos: Video[] |
||||
} |
||||
|
||||
const Panel = styled('div')` |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: center; |
||||
align-items: center; |
||||
width: 100%; |
||||
padding-bottom: 10px; |
||||
height: 100%; |
||||
overflow: hidden; |
||||
|
||||
&::-webkit-scrollbar { |
||||
width: 8px; |
||||
height: 8px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar-thumb { |
||||
background-color: #888; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar-thumb:hover { |
||||
background-color: #555; |
||||
} |
||||
` |
||||
|
||||
const PublishButton = styled(Button)` |
||||
/* position: absolute; |
||||
bottom: 20px; |
||||
left: 0; |
||||
right: 0; |
||||
margin: auto; */ |
||||
max-width: 80%; |
||||
` |
||||
|
||||
export const AudioPanel: React.FC<VideoPanelProps> = ({ |
||||
onSelect, |
||||
height, |
||||
width |
||||
}) => { |
||||
const [isOpen, setIsOpen] = useState(false) |
||||
const [videos, setVideos] = useState<Video[]>([]) |
||||
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false) |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const [editVideoIdentifier, setEditVideoIdentifier] = useState< |
||||
string | null | undefined |
||||
>() |
||||
|
||||
const fetchVideos = React.useCallback(async (): Promise<Video[]> => { |
||||
if (!user?.name) return [] |
||||
|
||||
let res = [] |
||||
try { |
||||
// res = await qortalRequest({
|
||||
// action: 'LIST_QDN_RESOURCES',
|
||||
// service: 'AUDIO',
|
||||
// name: user.name,
|
||||
// includeMetadata: true,
|
||||
// limit: 100,
|
||||
// offset: 0,
|
||||
// reverse: true
|
||||
// })
|
||||
const res2 = await fetch( |
||||
`/arbitrary/resources?&service=AUDIO&name=${user.name}&includemetadata=true&limit=100&offset=0&reverse=true` |
||||
) |
||||
const resData = await res2.json() |
||||
if (Array.isArray(resData)) { |
||||
res = resData |
||||
} |
||||
} catch (error) {} |
||||
|
||||
// Replace this URL with the actual API endpoint
|
||||
|
||||
return res |
||||
}, [user]) |
||||
useEffect(() => { |
||||
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||
}, []) |
||||
|
||||
const handleToggle = () => { |
||||
setIsOpen(!isOpen) |
||||
} |
||||
|
||||
const handleClick = (video: Video) => { |
||||
onSelect(video) |
||||
} |
||||
|
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex' |
||||
}} |
||||
> |
||||
<Tooltip title="Add an audio file" arrow> |
||||
<AudioFileIcon |
||||
onClick={handleToggle} |
||||
sx={{ |
||||
height: height || '30px', |
||||
width: width || 'auto', |
||||
cursor: 'pointer' |
||||
}} |
||||
></AudioFileIcon> |
||||
</Tooltip> |
||||
<Drawer |
||||
anchor="right" |
||||
open={isOpen} |
||||
onClose={handleToggle} |
||||
ModalProps={{ |
||||
keepMounted: true // Better performance on mobile
|
||||
}} |
||||
sx={{ |
||||
'& .MuiPaper-root': { |
||||
width: '400px' |
||||
} |
||||
}} |
||||
> |
||||
<Panel> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
alignItems: 'center', |
||||
flex: '0 0' |
||||
}} |
||||
> |
||||
<Typography |
||||
variant="h5" |
||||
component="div" |
||||
sx={{ flexGrow: 1, mt: 2, mb: 1 }} |
||||
> |
||||
Select Audio |
||||
</Typography> |
||||
<Typography |
||||
variant="subtitle2" |
||||
component="div" |
||||
sx={{ flexGrow: 1, mb: 2 }} |
||||
> |
||||
List of audios in QDN under your name |
||||
</Typography> |
||||
</Box> |
||||
|
||||
<List |
||||
sx={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
flex: '1', |
||||
overflow: 'auto' |
||||
}} |
||||
> |
||||
{videos.map((video) => ( |
||||
<ListItem key={video.identifier}> |
||||
<ButtonBase |
||||
onClick={() => handleClick(video)} |
||||
sx={{ width: '100%' }} |
||||
> |
||||
<ListItemText |
||||
primary={video?.metadata?.title || ''} |
||||
secondary={video?.metadata?.description || ''} |
||||
/> |
||||
</ButtonBase> |
||||
<Button |
||||
size="small" |
||||
variant="contained" |
||||
onClick={() => { |
||||
setEditVideoIdentifier(video.identifier) |
||||
setIsOpenVideoModal(true) |
||||
}} |
||||
> |
||||
Edit |
||||
</Button> |
||||
</ListItem> |
||||
))} |
||||
</List> |
||||
<Box |
||||
sx={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
justifyContent: 'center', |
||||
flex: '0 0 50px' |
||||
}} |
||||
> |
||||
<PublishButton |
||||
variant="contained" |
||||
onClick={() => { |
||||
setEditVideoIdentifier(null) |
||||
setIsOpenVideoModal(true) |
||||
}} |
||||
> |
||||
Publish new audio file |
||||
</PublishButton> |
||||
</Box> |
||||
</Panel> |
||||
</Drawer> |
||||
<AudioModal |
||||
onClose={() => { |
||||
setIsOpenVideoModal(false) |
||||
setEditVideoIdentifier(null) |
||||
}} |
||||
open={isOpenVideoModal} |
||||
onPublish={(value) => { |
||||
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||
setIsOpenVideoModal(false) |
||||
}} |
||||
editVideoIdentifier={editVideoIdentifier} |
||||
/> |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
// Add this to your 'types.ts' file
|
||||
export interface Video { |
||||
name: string |
||||
service: string |
||||
identifier: string |
||||
metadata: { |
||||
title: string |
||||
description: string |
||||
tags: string[] |
||||
category: string |
||||
categoryName: string |
||||
} |
||||
size: number |
||||
created: number |
||||
updated: number |
||||
} |
@ -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,366 @@
|
||||
import React, { useState } from 'react' |
||||
import { |
||||
Box, |
||||
Button, |
||||
Modal, |
||||
TextField, |
||||
Typography, |
||||
Select, |
||||
MenuItem, |
||||
FormControl, |
||||
InputLabel, |
||||
SelectChangeEvent, |
||||
OutlinedInput, |
||||
Chip, |
||||
IconButton |
||||
} from '@mui/material' |
||||
import { styled } from '@mui/system' |
||||
import { useDropzone } from 'react-dropzone' |
||||
import { toBase64 } from '../../utils/toBase64' |
||||
import AddIcon from '@mui/icons-material/Add' |
||||
import CloseIcon from '@mui/icons-material/Close' |
||||
import { usePublishAudio } from './PublishAudio' |
||||
|
||||
const StyledModal = styled(Modal)(({ theme }) => ({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center' |
||||
})) |
||||
|
||||
const ChipContainer = styled(Box)({ |
||||
display: 'flex', |
||||
flexWrap: 'wrap', |
||||
'& > *': { |
||||
margin: '4px' |
||||
} |
||||
}) |
||||
|
||||
const ModalContent = styled(Box)(({ theme }) => ({ |
||||
backgroundColor: theme.palette.background.paper, |
||||
padding: theme.spacing(4), |
||||
borderRadius: theme.spacing(1), |
||||
width: '40%', |
||||
'&:focus': { |
||||
outline: 'none' |
||||
} |
||||
})) |
||||
|
||||
interface VideoModalProps { |
||||
open: boolean |
||||
onClose: () => void |
||||
onPublish: (value: any) => void |
||||
editVideoIdentifier?: string | null | undefined |
||||
} |
||||
|
||||
interface SelectOption { |
||||
id: string |
||||
name: string |
||||
} |
||||
|
||||
async function addAudioCoverImage( |
||||
base64Audio: string, |
||||
coverImageBase64: string |
||||
): Promise<string> { |
||||
// Decode the base64 audio data
|
||||
const audioData: Uint8Array = new Uint8Array( |
||||
atob(base64Audio) |
||||
.split('') |
||||
.map((char) => char.charCodeAt(0)) |
||||
) |
||||
|
||||
const decoder: TextDecoder = new TextDecoder('utf-8') |
||||
const decodedAudioData: string = decoder.decode(audioData) |
||||
|
||||
// Create a Blob object from the decoded audio data
|
||||
const blob: Blob = new Blob([decodedAudioData], { type: 'audio/mpeg' }) |
||||
|
||||
// Create a new file name for the audio with cover image
|
||||
const fileName: string = 'audio-with-cover.mp3' |
||||
|
||||
// Create a new FormData object to hold the file and metadata
|
||||
const formData: FormData = new FormData() |
||||
formData.append('file', blob, fileName) |
||||
|
||||
// Create a new image object from the base64 data
|
||||
const image: HTMLImageElement = new Image() |
||||
image.src = `data:image/png;base64,${coverImageBase64}` |
||||
|
||||
// Wait for the image to load before getting its dimensions
|
||||
await new Promise((resolve) => { |
||||
image.onload = () => resolve(null) |
||||
}) |
||||
|
||||
// Get the image dimensions
|
||||
const width: number = image.width |
||||
const height: number = image.height |
||||
|
||||
// Create a new metadata object with the image dimensions
|
||||
const metadata: any = { |
||||
title: 'Audio with Cover', |
||||
artist: 'Artist Name', |
||||
album: 'Album Name', |
||||
trackNumber: 1, |
||||
image: { |
||||
mime: 'image/png', |
||||
type: 3, |
||||
description: 'Cover Image', |
||||
data: coverImageBase64, |
||||
width: width, |
||||
height: height |
||||
} |
||||
} |
||||
|
||||
// Set the metadata on the file
|
||||
formData.set('metadata', JSON.stringify(metadata)) |
||||
|
||||
// Create a new URL object for the file
|
||||
const url: string = URL.createObjectURL(blob) |
||||
|
||||
// Create a download link for the file
|
||||
const link: HTMLAnchorElement = document.createElement('a') |
||||
link.href = url |
||||
link.download = fileName |
||||
link.click() |
||||
|
||||
// Read the downloaded file and return its contents as a base64 string
|
||||
const fileReader: FileReader = new FileReader() |
||||
fileReader.readAsDataURL(blob) |
||||
return await new Promise<string>((resolve, reject) => { |
||||
fileReader.onload = () => { |
||||
const base64: string | undefined = fileReader.result?.toString() |
||||
if (base64 !== undefined) { |
||||
resolve(base64) |
||||
} else { |
||||
reject(new Error('Failed to read downloaded file.')) |
||||
} |
||||
} |
||||
fileReader.onerror = () => reject(fileReader.error) |
||||
}) |
||||
} |
||||
|
||||
export const AudioModal: React.FC<VideoModalProps> = ({ |
||||
open, |
||||
onClose, |
||||
onPublish, |
||||
editVideoIdentifier |
||||
}) => { |
||||
const [file, setFile] = useState<File | null>(null) |
||||
const [title, setTitle] = useState('') |
||||
const [description, setDescription] = useState('') |
||||
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||
null |
||||
) |
||||
const [inputValue, setInputValue] = useState<string>('') |
||||
const [chips, setChips] = useState<string[]>([]) |
||||
|
||||
const [options, setOptions] = useState<SelectOption[]>([]) |
||||
const [tags, setTags] = useState<string[]>([]) |
||||
const { publishAudio } = usePublishAudio() |
||||
const { getRootProps, getInputProps } = useDropzone({ |
||||
accept: { |
||||
'audio/*': [] |
||||
}, |
||||
maxFiles: 1, |
||||
onDrop: (acceptedFiles) => { |
||||
setFile(acceptedFiles[0]) |
||||
} |
||||
}) |
||||
|
||||
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
||||
setTitle(event.target.value) |
||||
} |
||||
|
||||
const handleDescriptionChange = ( |
||||
event: React.ChangeEvent<HTMLInputElement> |
||||
) => { |
||||
setDescription(event.target.value) |
||||
} |
||||
|
||||
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||
const optionId = event.target.value |
||||
const selectedOption = options.find((option) => option.id === optionId) |
||||
setSelectedOption(selectedOption || null) |
||||
} |
||||
|
||||
const handleChipDelete = (index: number) => { |
||||
const newChips = [...chips] |
||||
newChips.splice(index, 1) |
||||
setChips(newChips) |
||||
} |
||||
|
||||
const handleSubmit = async () => { |
||||
const missingFields = [] |
||||
|
||||
if (!title) missingFields.push('title') |
||||
if (!file) missingFields.push('file') |
||||
if (missingFields.length > 0) { |
||||
const missingFieldsString = missingFields.join(', ') |
||||
const errMsg = `Missing: ${missingFieldsString}` |
||||
|
||||
return |
||||
} |
||||
if (!file) return |
||||
|
||||
const formattedTags: { [key: string]: string } = {} |
||||
chips.forEach((tag, i) => { |
||||
formattedTags[`tag${i + 1}`] = tag |
||||
}) |
||||
|
||||
try { |
||||
const base64 = await toBase64(file) |
||||
if (typeof base64 !== 'string') return |
||||
const base64String = base64.split(',')[1] |
||||
|
||||
const res = await publishAudio({ |
||||
editVideoIdentifier, |
||||
title, |
||||
description, |
||||
base64: base64String, |
||||
category: selectedOption?.id || '', |
||||
...formattedTags |
||||
}) |
||||
onPublish(res) |
||||
setFile(null) |
||||
setTitle('') |
||||
setDescription('') |
||||
onClose() |
||||
} catch (error) {} |
||||
} |
||||
|
||||
const handleInputChange = (event: any) => { |
||||
setInputValue(event.target.value) |
||||
} |
||||
|
||||
const handleInputKeyDown = (event: any) => { |
||||
if (event.key === 'Enter' && inputValue !== '') { |
||||
if (chips.length < 5) { |
||||
setChips([...chips, inputValue]) |
||||
setInputValue('') |
||||
} else { |
||||
event.preventDefault() |
||||
} |
||||
} |
||||
} |
||||
|
||||
const addChip = () => { |
||||
if (chips.length < 5) { |
||||
setChips([...chips, inputValue]) |
||||
setInputValue('') |
||||
} |
||||
} |
||||
|
||||
const getListCategories = React.useCallback(async () => { |
||||
try { |
||||
const url = `/arbitrary/categories` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
setOptions(responseData) |
||||
} catch (error) {} |
||||
}, []) |
||||
|
||||
React.useEffect(() => { |
||||
getListCategories() |
||||
}, [getListCategories]) |
||||
|
||||
return ( |
||||
<StyledModal open={open} onClose={onClose}> |
||||
<ModalContent> |
||||
{editVideoIdentifier && ( |
||||
<Typography variant="h6"> |
||||
You are editing: {editVideoIdentifier} |
||||
</Typography> |
||||
)} |
||||
<Typography variant="h6" component="h2" gutterBottom> |
||||
Upload Audio |
||||
</Typography> |
||||
<Box |
||||
{...getRootProps()} |
||||
sx={{ |
||||
border: '1px dashed gray', |
||||
padding: 2, |
||||
textAlign: 'center', |
||||
marginBottom: 2 |
||||
}} |
||||
> |
||||
<input {...getInputProps()} /> |
||||
<Typography> |
||||
{file |
||||
? file.name |
||||
: 'Drag and drop an audio file here or click to select a file'} |
||||
</Typography> |
||||
</Box> |
||||
<TextField |
||||
label="Audio Title" |
||||
variant="outlined" |
||||
fullWidth |
||||
value={title} |
||||
onChange={handleTitleChange} |
||||
inputProps={{ maxLength: 40 }} |
||||
sx={{ marginBottom: 2 }} |
||||
/> |
||||
<TextField |
||||
label="Audio Description" |
||||
variant="outlined" |
||||
fullWidth |
||||
multiline |
||||
rows={4} |
||||
value={description} |
||||
onChange={handleDescriptionChange} |
||||
inputProps={{ maxLength: 180 }} |
||||
sx={{ marginBottom: 2 }} |
||||
/> |
||||
{options.length > 0 && ( |
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||
<InputLabel id="Category">Select a Category</InputLabel> |
||||
<Select |
||||
labelId="Category" |
||||
input={<OutlinedInput label="Select a Category" />} |
||||
value={selectedOption?.id || ''} |
||||
onChange={handleOptionChange} |
||||
> |
||||
{options.map((option) => ( |
||||
<MenuItem key={option.id} value={option.id}> |
||||
{option.name} |
||||
</MenuItem> |
||||
))} |
||||
</Select> |
||||
</FormControl> |
||||
)} |
||||
|
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||
<TextField |
||||
label="Add a tag" |
||||
value={inputValue} |
||||
onChange={handleInputChange} |
||||
onKeyDown={handleInputKeyDown} |
||||
disabled={chips.length === 3} |
||||
/> |
||||
|
||||
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||
<AddIcon /> |
||||
</IconButton> |
||||
</Box> |
||||
<ChipContainer> |
||||
{chips.map((chip, index) => ( |
||||
<Chip |
||||
key={index} |
||||
label={chip} |
||||
onDelete={() => handleChipDelete(index)} |
||||
deleteIcon={<CloseIcon />} |
||||
/> |
||||
))} |
||||
</ChipContainer> |
||||
</FormControl> |
||||
<Button variant="contained" color="primary" onClick={handleSubmit}> |
||||
Submit |
||||
</Button> |
||||
</ModalContent> |
||||
</StyledModal> |
||||
) |
||||
} |
@ -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,336 @@
|
||||
import { |
||||
Avatar, |
||||
Box, |
||||
Button, |
||||
Dialog, |
||||
DialogActions, |
||||
DialogContent, |
||||
DialogTitle, |
||||
Typography, |
||||
useTheme |
||||
} from '@mui/material' |
||||
import React, { useCallback, useState } from 'react' |
||||
import { CommentEditor } from './CommentEditor' |
||||
import { CardContentContainerComment } from '../../../pages/BlogList/PostPreview-styles' |
||||
import { StyledCardHeaderComment } from '../../../pages/BlogList/PostPreview-styles' |
||||
import { StyledCardColComment } from '../../../pages/BlogList/PostPreview-styles' |
||||
import { AuthorTextComment } from '../../../pages/BlogList/PostPreview-styles' |
||||
import { StyledCardContentComment } from '../../../pages/BlogList/PostPreview-styles' |
||||
import { useSelector } from 'react-redux' |
||||
import { RootState } from '../../../state/store' |
||||
import Portal from '../Portal' |
||||
import { Tipping } from '../Tipping/Tipping' |
||||
import { formatDate } from '../../../utils/time' |
||||
interface CommentProps { |
||||
comment: any |
||||
postId: string |
||||
postName: string |
||||
onSubmit: (obj?: any, isEdit?: boolean) => void |
||||
} |
||||
export const Comment = ({ |
||||
comment, |
||||
postId, |
||||
postName, |
||||
onSubmit |
||||
}: CommentProps) => { |
||||
const [isReplying, setIsReplying] = useState<boolean>(false) |
||||
const [isEditing, setIsEditing] = useState<boolean>(false) |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const [currentEdit, setCurrentEdit] = useState<any>(null) |
||||
const theme = useTheme() |
||||
|
||||
const handleSubmit = useCallback((comment: any, isEdit?: boolean) => { |
||||
onSubmit(comment, isEdit) |
||||
setCurrentEdit(null) |
||||
setIsReplying(false) |
||||
}, []) |
||||
|
||||
return ( |
||||
<Box |
||||
id={comment?.identifier} |
||||
sx={{ |
||||
display: 'flex', |
||||
width: '100%', |
||||
flexDirection: 'column' |
||||
}} |
||||
> |
||||
{currentEdit && ( |
||||
<Portal> |
||||
<Dialog |
||||
open={!!currentEdit} |
||||
onClose={() => setCurrentEdit(null)} |
||||
aria-labelledby="alert-dialog-title" |
||||
aria-describedby="alert-dialog-description" |
||||
> |
||||
<DialogTitle id="alert-dialog-title"></DialogTitle> |
||||
<DialogContent> |
||||
<Box |
||||
sx={{ |
||||
width: '300px', |
||||
display: 'flex', |
||||
justifyContent: 'center' |
||||
}} |
||||
> |
||||
<CommentEditor |
||||
onSubmit={(obj) => handleSubmit(obj, true)} |
||||
postId={postId} |
||||
postName={postName} |
||||
isEdit |
||||
commentId={currentEdit?.identifier} |
||||
commentMessage={currentEdit?.message} |
||||
/> |
||||
</Box> |
||||
</DialogContent> |
||||
<DialogActions> |
||||
<Button variant="contained" onClick={() => setCurrentEdit(null)}> |
||||
Close |
||||
</Button> |
||||
</DialogActions> |
||||
</Dialog> |
||||
</Portal> |
||||
)} |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
width: '100%', |
||||
flexDirection: 'column' |
||||
}} |
||||
> |
||||
<CommentCard |
||||
name={comment?.name} |
||||
message={comment?.message} |
||||
replies={comment?.replies || []} |
||||
setCurrentEdit={setCurrentEdit} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
gap: '5px', |
||||
marginTop: '20px', |
||||
justifyContent: 'space-between' |
||||
}} |
||||
> |
||||
{comment?.created && ( |
||||
<Typography |
||||
variant="h6" |
||||
sx={{ |
||||
fontSize: '12px', |
||||
marginLeft: '5px' |
||||
}} |
||||
color={theme.palette.text.primary} |
||||
> |
||||
{formatDate(+comment?.created)} |
||||
</Typography> |
||||
)} |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
gap: '5px' |
||||
}} |
||||
> |
||||
<Button |
||||
size="small" |
||||
variant="contained" |
||||
onClick={() => setIsReplying(true)} |
||||
> |
||||
reply |
||||
</Button> |
||||
{user?.name === comment?.name && ( |
||||
<Button |
||||
size="small" |
||||
variant="contained" |
||||
onClick={() => setCurrentEdit(comment)} |
||||
> |
||||
edit |
||||
</Button> |
||||
)} |
||||
{isReplying && ( |
||||
<Button |
||||
size="small" |
||||
variant="contained" |
||||
onClick={() => { |
||||
setIsReplying(false) |
||||
setIsEditing(false) |
||||
}} |
||||
> |
||||
close |
||||
</Button> |
||||
)} |
||||
</Box> |
||||
</Box> |
||||
</CommentCard> |
||||
{/* <Typography variant="body1"> {comment?.message}</Typography> */} |
||||
</Box> |
||||
|
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
width: '100%', |
||||
flexDirection: 'column', |
||||
alignItems: 'center' |
||||
}} |
||||
> |
||||
{isReplying && ( |
||||
<CommentEditor |
||||
onSubmit={handleSubmit} |
||||
postId={postId} |
||||
postName={postName} |
||||
isReply |
||||
commentId={comment.identifier} |
||||
/> |
||||
)} |
||||
</Box> |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
const CommentCard = ({ |
||||
message, |
||||
created, |
||||
name, |
||||
replies, |
||||
children, |
||||
setCurrentEdit |
||||
}: any) => { |
||||
const [avatarUrl, setAvatarUrl] = React.useState<string>('') |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
|
||||
const theme = useTheme() |
||||
|
||||
const getAvatar = React.useCallback(async (author: string) => { |
||||
try { |
||||
let url = await qortalRequest({ |
||||
action: 'GET_QDN_RESOURCE_URL', |
||||
name: author, |
||||
service: 'THUMBNAIL', |
||||
identifier: 'qortal_avatar' |
||||
}) |
||||
|
||||
setAvatarUrl(url) |
||||
} catch (error) {} |
||||
}, []) |
||||
|
||||
React.useEffect(() => { |
||||
getAvatar(name) |
||||
}, [name]) |
||||
return ( |
||||
<CardContentContainerComment> |
||||
<StyledCardHeaderComment |
||||
sx={{ |
||||
'& .MuiCardHeader-content': { |
||||
overflow: 'hidden' |
||||
} |
||||
}} |
||||
> |
||||
<Box> |
||||
<Avatar src={avatarUrl} alt={`${name}'s avatar`} /> |
||||
</Box> |
||||
<StyledCardColComment> |
||||
<AuthorTextComment |
||||
color={ |
||||
theme.palette.mode === 'light' |
||||
? theme.palette.text.secondary |
||||
: '#d6e8ff' |
||||
} |
||||
> |
||||
{name} |
||||
</AuthorTextComment> |
||||
</StyledCardColComment> |
||||
{name && ( |
||||
<Tipping |
||||
name={name} |
||||
onSubmit={() => { |
||||
// setNameTip('')
|
||||
}} |
||||
onClose={() => { |
||||
// setNameTip('')
|
||||
}} |
||||
onlyIcon={true} |
||||
/> |
||||
)} |
||||
</StyledCardHeaderComment> |
||||
<StyledCardContentComment> |
||||
<Typography |
||||
variant="body2" |
||||
color={theme.palette.text.primary} |
||||
sx={{ |
||||
fontSize: '16px', |
||||
wordBreak: 'break-word' |
||||
}} |
||||
> |
||||
{message} |
||||
</Typography> |
||||
</StyledCardContentComment> |
||||
<Box |
||||
sx={{ |
||||
paddingLeft: '15px', |
||||
display: 'flex', |
||||
flexDirection: 'column' |
||||
}} |
||||
> |
||||
{replies?.map((reply: any) => { |
||||
return ( |
||||
<Box |
||||
key={reply?.identifier} |
||||
id={reply?.identifier} |
||||
sx={{ |
||||
display: 'flex', |
||||
border: '1px solid grey', |
||||
borderRadius: '10px', |
||||
marginTop: '8px' |
||||
}} |
||||
> |
||||
<CommentCard |
||||
name={reply?.name} |
||||
message={reply?.message} |
||||
setCurrentEdit={setCurrentEdit} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
gap: '5px', |
||||
justifyContent: 'space-between' |
||||
}} |
||||
> |
||||
{reply?.created && ( |
||||
<Typography |
||||
variant="h6" |
||||
sx={{ |
||||
fontSize: '12px', |
||||
marginLeft: '5px' |
||||
}} |
||||
color={theme.palette.text.primary} |
||||
> |
||||
{formatDate(+reply?.created)} |
||||
</Typography> |
||||
)} |
||||
{user?.name === reply?.name ? ( |
||||
<Button |
||||
size="small" |
||||
variant="contained" |
||||
onClick={() => setCurrentEdit(reply)} |
||||
sx={{ |
||||
width: '30px', |
||||
alignSelf: 'flex-end', |
||||
background: theme.palette.primary.light |
||||
}} |
||||
> |
||||
edit |
||||
</Button> |
||||
) : ( |
||||
<Box /> |
||||
)} |
||||
</Box> |
||||
</CommentCard> |
||||
{/* <Typography variant="body2"> {reply?.message}</Typography> */} |
||||
</Box> |
||||
) |
||||
})} |
||||
</Box> |
||||
{children} |
||||
</CardContentContainerComment> |
||||
) |
||||
} |
@ -0,0 +1,258 @@
|
||||
import { Box, Button, TextField } from '@mui/material' |
||||
import React, { useEffect, useState } from 'react' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../../../state/store' |
||||
import ShortUniqueId from 'short-unique-id' |
||||
import { setNotification } from '../../../state/features/notificationsSlice' |
||||
import { toBase64 } from '../../../utils/toBase64' |
||||
import localforage from 'localforage' |
||||
const uid = new ShortUniqueId() |
||||
|
||||
const notification = localforage.createInstance({ |
||||
name: 'notification' |
||||
}) |
||||
|
||||
const MAX_ITEMS = 10 |
||||
|
||||
export interface Item { |
||||
id: string |
||||
lastSeen: number |
||||
postId: string |
||||
postName: string |
||||
} |
||||
|
||||
export async function addItem(item: Item): Promise<void> { |
||||
// Get all items
|
||||
let notificationComments: Item[] = |
||||
(await notification.getItem('comments')) || [] |
||||
|
||||
// Find the item with the same id, if it exists
|
||||
let existingItemIndex = notificationComments.findIndex( |
||||
(i) => i.id === item.id |
||||
) |
||||
|
||||
if (existingItemIndex !== -1) { |
||||
// If the item exists, update its date
|
||||
notificationComments[existingItemIndex].lastSeen = item.lastSeen |
||||
} else { |
||||
// If the item doesn't exist, add it
|
||||
notificationComments.push(item) |
||||
|
||||
// If adding the item has caused us to exceed the max number of items, remove the oldest one
|
||||
if (notificationComments.length > MAX_ITEMS) { |
||||
notificationComments.sort((a, b) => b.lastSeen - a.lastSeen) // sort items by date, newest first
|
||||
notificationComments.pop() // remove the oldest item
|
||||
} |
||||
} |
||||
|
||||
// Store the items back into localForage
|
||||
await notification.setItem('comments', notificationComments) |
||||
} |
||||
export async function updateItemDate(item: any): Promise<void> { |
||||
// Get all items
|
||||
let notificationComments: Item[] = |
||||
(await notification.getItem('comments')) || [] |
||||
|
||||
let notificationCreatorComment: any = |
||||
(await notification.getItem('post-comments')) || {} |
||||
const findPostId = notificationCreatorComment[item.postId] |
||||
if (findPostId) { |
||||
notificationCreatorComment[item.postId].lastSeen = item.lastSeen |
||||
} |
||||
|
||||
// Find the item with the same id, if it exists
|
||||
notificationComments.forEach((nc, index) => { |
||||
if (nc.postId === item.postId) { |
||||
notificationComments[index].lastSeen = item.lastSeen |
||||
} |
||||
}) |
||||
|
||||
// Store the items back into localForage
|
||||
await notification.setItem('comments', notificationComments) |
||||
await notification.setItem('post-comments', notificationCreatorComment) |
||||
} |
||||
interface CommentEditorProps { |
||||
postId: string |
||||
postName: string |
||||
onSubmit: (obj: any) => void |
||||
isReply?: boolean |
||||
commentId?: string |
||||
isEdit?: boolean |
||||
commentMessage?: string |
||||
} |
||||
|
||||
function utf8ToBase64(inputString: string): string { |
||||
// Encode the string as UTF-8
|
||||
const utf8String = encodeURIComponent(inputString).replace( |
||||
/%([0-9A-F]{2})/g, |
||||
(match, p1) => String.fromCharCode(Number('0x' + p1)) |
||||
) |
||||
|
||||
// Convert the UTF-8 encoded string to base64
|
||||
const base64String = btoa(utf8String) |
||||
return base64String |
||||
} |
||||
|
||||
export const CommentEditor = ({ |
||||
onSubmit, |
||||
postId, |
||||
postName, |
||||
isReply, |
||||
commentId, |
||||
isEdit, |
||||
commentMessage |
||||
}: CommentEditorProps) => { |
||||
const [value, setValue] = useState<string>('') |
||||
const dispatch = useDispatch() |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const notifications = useSelector( |
||||
(state: RootState) => state.global.notifications |
||||
) |
||||
|
||||
useEffect(() => { |
||||
if (isEdit && commentMessage) { |
||||
setValue(commentMessage) |
||||
} |
||||
}, [isEdit, commentMessage]) |
||||
|
||||
const publishComment = async ( |
||||
identifier: string, |
||||
idForNotification?: string |
||||
) => { |
||||
let address |
||||
let name |
||||
let errorMsg = '' |
||||
|
||||
address = user?.address |
||||
name = user?.name || '' |
||||
|
||||
if (!address) { |
||||
errorMsg = "Cannot post: your address isn't available" |
||||
} |
||||
if (!name) { |
||||
errorMsg = 'Cannot post without a name' |
||||
} |
||||
|
||||
if (value.length > 200) { |
||||
errorMsg = 'Comment needs to be under 200 characters' |
||||
} |
||||
|
||||
if (errorMsg) { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: errorMsg, |
||||
alertType: 'error' |
||||
}) |
||||
) |
||||
throw new Error(errorMsg) |
||||
} |
||||
|
||||
try { |
||||
const base64 = utf8ToBase64(value) |
||||
const resourceResponse = await qortalRequest({ |
||||
action: 'PUBLISH_QDN_RESOURCE', |
||||
name: name, |
||||
service: 'BLOG_COMMENT', |
||||
data64: base64, |
||||
identifier: identifier |
||||
}) |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Comment successfully published', |
||||
alertType: 'success' |
||||
}) |
||||
) |
||||
if (idForNotification) { |
||||
addItem({ |
||||
id: idForNotification, |
||||
lastSeen: Date.now(), |
||||
postId, |
||||
postName: postName |
||||
}) |
||||
} |
||||
|
||||
return resourceResponse |
||||
} catch (error: any) { |
||||
let notificationObj = null |
||||
if (typeof error === 'string') { |
||||
notificationObj = { |
||||
msg: error || 'Failed to publish comment', |
||||
alertType: 'error' |
||||
} |
||||
} else if (typeof error?.error === 'string') { |
||||
notificationObj = { |
||||
msg: error?.error || 'Failed to publish comment', |
||||
alertType: 'error' |
||||
} |
||||
} else { |
||||
notificationObj = { |
||||
msg: error?.message || 'Failed to publish comment', |
||||
alertType: 'error' |
||||
} |
||||
} |
||||
if (!notificationObj) throw new Error('Failed to publish comment') |
||||
|
||||
dispatch(setNotification(notificationObj)) |
||||
throw new Error('Failed to publish comment') |
||||
} |
||||
} |
||||
const handleSubmit = async () => { |
||||
try { |
||||
const id = uid() |
||||
|
||||
let identifier = `qcomment_v1_qblog_${postId.slice(-12)}_${id}` |
||||
let idForNotification = identifier |
||||
if (isReply && commentId) { |
||||
identifier = `qcomment_v1_qblog_${postId.slice( |
||||
-12 |
||||
)}_reply_${commentId.slice(-6)}_${id}` |
||||
idForNotification = commentId |
||||
} |
||||
if (isEdit && commentId) { |
||||
identifier = commentId |
||||
} |
||||
|
||||
await publishComment(identifier, idForNotification) |
||||
onSubmit({ |
||||
created: Date.now(), |
||||
identifier, |
||||
message: value, |
||||
service: 'BLOG_COMMENT', |
||||
name: user?.name |
||||
}) |
||||
setValue('') |
||||
} catch (error) {} |
||||
} |
||||
|
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
marginTop: '15px', |
||||
width: '90%' |
||||
}} |
||||
> |
||||
<TextField |
||||
id="standard-multiline-flexible" |
||||
label="Your comment" |
||||
multiline |
||||
maxRows={4} |
||||
variant="filled" |
||||
value={value} |
||||
inputProps={{ |
||||
maxLength: 200, |
||||
style: { |
||||
fontSize: '16px' |
||||
} |
||||
}} |
||||
InputLabelProps={{ style: { fontSize: '18px' } }} |
||||
onChange={(e) => setValue(e.target.value)} |
||||
/> |
||||
|
||||
<Button variant="contained" onClick={handleSubmit}> |
||||
{isReply ? 'Submit reply' : isEdit ? 'Edit' : 'Submit comment'} |
||||
</Button> |
||||
</Box> |
||||
) |
||||
} |
@ -0,0 +1,386 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
||||
import { CommentEditor, addItem, updateItemDate } from './CommentEditor' |
||||
import { Comment } from './Comment' |
||||
import { Box, Button, Drawer, Typography, useTheme } from '@mui/material' |
||||
import { styled } from '@mui/system' |
||||
import CloseIcon from '@mui/icons-material/Close' |
||||
import { useSelector } from 'react-redux' |
||||
import { RootState } from '../../../state/store' |
||||
import CommentIcon from '@mui/icons-material/Comment' |
||||
import { useNavigate, useLocation } from 'react-router-dom' |
||||
|
||||
interface CommentSectionProps { |
||||
postId: string |
||||
postName: string |
||||
} |
||||
|
||||
const Panel = styled('div')` |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: center; |
||||
align-items: center; |
||||
width: 100%; |
||||
padding-bottom: 10px; |
||||
height: 100%; |
||||
overflow: hidden; |
||||
|
||||
&::-webkit-scrollbar { |
||||
width: 8px; |
||||
height: 8px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar-thumb { |
||||
background-color: #888; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar-thumb:hover { |
||||
background-color: #555; |
||||
} |
||||
` |
||||
export const CommentSection = ({ postId, postName }: CommentSectionProps) => { |
||||
const navigate = useNavigate() |
||||
const location = useLocation() |
||||
const [listComments, setListComments] = useState<any[]>([]) |
||||
const [isOpen, setIsOpen] = useState<boolean>(false) |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const [newMessages, setNewMessages] = useState(0) |
||||
const notifications = useSelector( |
||||
(state: RootState) => state.global.notifications |
||||
) |
||||
const notificationCreatorComment = useSelector( |
||||
(state: RootState) => state.global.notificationCreatorComment |
||||
) |
||||
|
||||
const fullNotifications = useMemo(() => { |
||||
return [...notificationCreatorComment, ...notifications].sort( |
||||
(a, b) => b.created - a.created |
||||
) |
||||
}, [notificationCreatorComment, notifications]) |
||||
const theme = useTheme() |
||||
const onSubmit = (obj?: any, isEdit?: boolean) => { |
||||
if (isEdit) { |
||||
setListComments((prev: any[]) => { |
||||
const findCommentIndex = prev.findIndex( |
||||
(item) => item?.identifier === obj?.identifier |
||||
) |
||||
if (findCommentIndex === -1) return prev |
||||
|
||||
const newArray = [...prev] |
||||
newArray[findCommentIndex] = obj |
||||
return newArray |
||||
}) |
||||
|
||||
return |
||||
} |
||||
setListComments((prev) => [ |
||||
...prev, |
||||
{ |
||||
...obj |
||||
} |
||||
]) |
||||
} |
||||
|
||||
useEffect(() => { |
||||
const query = new URLSearchParams(location.search) |
||||
let commentVar = query?.get('comment') |
||||
if (commentVar) { |
||||
if (commentVar && commentVar.endsWith('/')) { |
||||
commentVar = commentVar.slice(0, -1) |
||||
} |
||||
setIsOpen(true) |
||||
if (listComments.length > 0) { |
||||
const el = document.getElementById(commentVar) |
||||
if (el) { |
||||
el.scrollIntoView() |
||||
el.classList.add('glow') |
||||
setTimeout(() => { |
||||
el.classList.remove('glow') |
||||
}, 2000) |
||||
} |
||||
navigate(location.pathname, { replace: true }) |
||||
} |
||||
} |
||||
}, [navigate, location, listComments]) |
||||
|
||||
const getComments = useCallback( |
||||
async (isNewMessages?: boolean, numberOfComments?: number) => { |
||||
let offset: number = 0 |
||||
if (isNewMessages && numberOfComments) { |
||||
offset = numberOfComments |
||||
} |
||||
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=qcomment_v1_qblog_${postId.slice( |
||||
-12 |
||||
)}&limit=20&includemetadata=false&offset=${offset}&reverse=false&excludeblocked=true` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
let comments: any[] = [] |
||||
for (const comment of responseData) { |
||||
if (comment.identifier && comment.name) { |
||||
const url = `/arbitrary/BLOG_COMMENT/${comment.name}/${comment.identifier}` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
|
||||
const responseData2 = await response.text() |
||||
if (responseData) { |
||||
comments.push({ |
||||
message: responseData2, |
||||
...comment |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
if (isNewMessages) { |
||||
setListComments((prev) => [...prev, ...comments]) |
||||
setNewMessages(0) |
||||
} else { |
||||
setListComments(comments) |
||||
} |
||||
|
||||
try { |
||||
} catch (error) {} |
||||
}, |
||||
[postId] |
||||
) |
||||
|
||||
const checkAndUpdateNotification = async () => { |
||||
const filteredNotifications = fullNotifications.filter( |
||||
(notification) => |
||||
postId.includes(notification?.partialPostId) || |
||||
notification?.postId === postId |
||||
) |
||||
filteredNotifications.forEach((notification) => { |
||||
if (postId) { |
||||
updateItemDate({ |
||||
id: notification?.identifier, |
||||
lastSeen: Date.now(), |
||||
postId |
||||
}) |
||||
} |
||||
}) |
||||
} |
||||
useEffect(() => { |
||||
if (fullNotifications && isOpen) { |
||||
checkAndUpdateNotification() |
||||
} |
||||
}, [fullNotifications, isOpen]) |
||||
|
||||
useEffect(() => { |
||||
getComments() |
||||
}, [getComments, postId]) |
||||
|
||||
const structuredCommentList = useMemo(() => { |
||||
return listComments.reduce((acc, curr, index, array) => { |
||||
if (curr?.identifier?.includes('_reply_')) { |
||||
return acc |
||||
} |
||||
acc.push({ |
||||
...curr, |
||||
replies: array.filter((comment) => |
||||
comment.identifier.includes(`_reply_${curr.identifier.slice(-6)}`) |
||||
) |
||||
}) |
||||
return acc |
||||
}, []) |
||||
}, [listComments]) |
||||
|
||||
const interval = useRef<any>(null) |
||||
|
||||
const checkNewComments = useCallback(async () => { |
||||
try { |
||||
const offset = listComments.length |
||||
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=qcomment_v1_qblog_${postId.slice( |
||||
-12 |
||||
)}&limit=20&includemetadata=false&offset=${offset}&reverse=false&excludeblocked=true` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
setNewMessages(responseData.length) |
||||
} catch (error) {} |
||||
}, [listComments, postId]) |
||||
|
||||
const checkNewMessagesFunc = useCallback(() => { |
||||
let isCalling = false |
||||
interval.current = setInterval(async () => { |
||||
if (isCalling) return |
||||
isCalling = true |
||||
const res = await checkNewComments() |
||||
isCalling = false |
||||
}, 15000) |
||||
}, [checkNewComments]) |
||||
|
||||
useEffect(() => { |
||||
checkNewMessagesFunc() |
||||
|
||||
return () => { |
||||
if (interval?.current) { |
||||
clearInterval(interval.current) |
||||
} |
||||
} |
||||
}, [checkNewMessagesFunc]) |
||||
|
||||
return ( |
||||
<> |
||||
<Box |
||||
sx={{ |
||||
position: 'relative' |
||||
}} |
||||
> |
||||
<CommentIcon |
||||
sx={{ |
||||
cursor: 'pointer' |
||||
}} |
||||
onClick={() => setIsOpen((prev) => !prev)} |
||||
> |
||||
Comments |
||||
</CommentIcon> |
||||
{listComments?.length > 0 && ( |
||||
<Box |
||||
sx={{ |
||||
fontSize: '12px', |
||||
background: theme.palette.mode === 'dark' ? 'white' : 'black', |
||||
color: theme.palette.mode === 'dark' ? 'black' : 'white', |
||||
borderRadius: '50%', |
||||
position: 'absolute', |
||||
top: '-15px', |
||||
right: '-15px', |
||||
width: '20px', |
||||
height: '20px', |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center' |
||||
}} |
||||
> |
||||
{listComments.length < 10 ? listComments.length : '9+'} |
||||
</Box> |
||||
)} |
||||
</Box> |
||||
|
||||
<Drawer |
||||
variant="persistent" |
||||
hideBackdrop={true} |
||||
anchor="right" |
||||
open={isOpen} |
||||
onClose={() => {}} |
||||
ModalProps={{ |
||||
keepMounted: true // Better performance on mobile
|
||||
}} |
||||
sx={{ |
||||
'& .MuiPaper-root': { |
||||
width: '400px' |
||||
} |
||||
}} |
||||
> |
||||
<Panel> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
alignItems: 'center', |
||||
justifyContent: 'space-between', |
||||
flex: '0 0', |
||||
padding: '10px', |
||||
width: '100%' |
||||
}} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex' |
||||
}} |
||||
> |
||||
{newMessages > 0 && ( |
||||
<Button |
||||
onClick={() => { |
||||
// addItem({
|
||||
// id: notification.identifier,
|
||||
// lastSeen: Date.now(),
|
||||
// postId
|
||||
// })
|
||||
updateItemDate({ |
||||
id: '', |
||||
lastSeen: Date.now(), |
||||
postId |
||||
}) |
||||
getComments(true, listComments.length) |
||||
}} |
||||
variant="contained" |
||||
size="small" |
||||
> |
||||
Load {newMessages} new{' '} |
||||
{newMessages > 1 ? 'messages' : 'message'} |
||||
</Button> |
||||
)} |
||||
</Box> |
||||
<CloseIcon |
||||
sx={{ |
||||
cursor: 'pointer' |
||||
}} |
||||
onClick={() => setIsOpen(false)} |
||||
/> |
||||
</Box> |
||||
|
||||
<Box |
||||
sx={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
flex: '1', |
||||
overflow: 'auto' |
||||
}} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
margin: '25px 0px 50px 0px', |
||||
maxWidth: '400px', |
||||
width: '100%', |
||||
gap: '10px', |
||||
padding: '0px 5px' |
||||
}} |
||||
> |
||||
{structuredCommentList.map((comment: any) => { |
||||
return ( |
||||
<Comment |
||||
key={comment?.identifier} |
||||
comment={comment} |
||||
onSubmit={onSubmit} |
||||
postId={postId} |
||||
postName={postName} |
||||
/> |
||||
) |
||||
})} |
||||
</Box> |
||||
</Box> |
||||
<Box |
||||
sx={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
justifyContent: 'center', |
||||
flex: '0 0 100px' |
||||
}} |
||||
> |
||||
<CommentEditor |
||||
onSubmit={onSubmit} |
||||
postId={postId} |
||||
postName={postName} |
||||
/> |
||||
</Box> |
||||
</Panel> |
||||
</Drawer> |
||||
</> |
||||
) |
||||
} |
@ -0,0 +1,82 @@
|
||||
import * as React from 'react' |
||||
import Menu from '@mui/material/Menu' |
||||
import MenuItem from '@mui/material/MenuItem' |
||||
import Typography from '@mui/material/Typography' |
||||
import { CopyToClipboard } from 'react-copy-to-clipboard' |
||||
import { useDispatch } from 'react-redux' |
||||
import { setNotification } from '../../../state/features/notificationsSlice' |
||||
import { Box } from '@mui/material' |
||||
|
||||
export default function ContextMenuResource({ |
||||
children, |
||||
name, |
||||
service, |
||||
identifier, |
||||
link |
||||
}: any) { |
||||
const [contextMenu, setContextMenu] = React.useState<{ |
||||
mouseX: number |
||||
mouseY: number |
||||
} | null>(null) |
||||
const dispatch = useDispatch() |
||||
const handleContextMenu = (event: React.MouseEvent) => { |
||||
event.preventDefault() |
||||
setContextMenu( |
||||
contextMenu === null |
||||
? { |
||||
mouseX: event.clientX + 2, |
||||
mouseY: event.clientY - 6 |
||||
} |
||||
: // repeated contextmenu when it is already open closes it with Chrome 84 on Ubuntu
|
||||
// Other native context menus might behave different.
|
||||
// With this behavior we prevent contextmenu from the backdrop to re-locale existing context menus.
|
||||
null |
||||
) |
||||
} |
||||
|
||||
const handleClose = () => { |
||||
setContextMenu(null) |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
onContextMenu={handleContextMenu} |
||||
style={{ cursor: 'context-menu', width: '100%' }} |
||||
> |
||||
{children} |
||||
<Menu |
||||
open={contextMenu !== null} |
||||
onClose={handleClose} |
||||
anchorReference="anchorPosition" |
||||
anchorPosition={ |
||||
contextMenu !== null |
||||
? { top: contextMenu.mouseY, left: contextMenu.mouseX } |
||||
: undefined |
||||
} |
||||
> |
||||
<MenuItem> |
||||
<CopyToClipboard |
||||
text={link} |
||||
onCopy={() => { |
||||
handleClose() |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Copied to clipboard!', |
||||
alertType: 'success' |
||||
}) |
||||
) |
||||
}} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
fontSize: '16px' |
||||
}} |
||||
> |
||||
Copy Link |
||||
</Box> |
||||
</CopyToClipboard> |
||||
</MenuItem> |
||||
</Menu> |
||||
</div> |
||||
) |
||||
} |
@ -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,257 @@
|
||||
import React, { useState, useEffect } from 'react' |
||||
import { styled, Box } from '@mui/system' |
||||
import { |
||||
Drawer, |
||||
List, |
||||
ListItem, |
||||
ListItemText, |
||||
Typography, |
||||
ButtonBase, |
||||
Button, |
||||
Tooltip |
||||
} from '@mui/material' |
||||
import VideoCallIcon from '@mui/icons-material/VideoCall' |
||||
import VideoModal from './VideoPublishModal' |
||||
import { useSelector } from 'react-redux' |
||||
import { RootState } from '../../state/store' |
||||
import AttachFileIcon from '@mui/icons-material/AttachFile' |
||||
import { AudioModal } from './AudioPublishModal' |
||||
import AudioFileIcon from '@mui/icons-material/AudioFile' |
||||
import { GenericModal } from './GenericPublishModal' |
||||
interface VideoPanelProps { |
||||
onSelect: (video: Video) => void |
||||
height?: string |
||||
width?: string |
||||
} |
||||
|
||||
interface VideoApiResponse { |
||||
videos: Video[] |
||||
} |
||||
|
||||
const Panel = styled('div')` |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: center; |
||||
align-items: center; |
||||
width: 100%; |
||||
padding-bottom: 10px; |
||||
height: 100%; |
||||
overflow: hidden; |
||||
|
||||
&::-webkit-scrollbar { |
||||
width: 8px; |
||||
height: 8px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar-thumb { |
||||
background-color: #888; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar-thumb:hover { |
||||
background-color: #555; |
||||
} |
||||
` |
||||
|
||||
const PublishButton = styled(Button)` |
||||
/* position: absolute; |
||||
bottom: 20px; |
||||
left: 0; |
||||
right: 0; |
||||
margin: auto; */ |
||||
max-width: 80%; |
||||
` |
||||
|
||||
export const FilePanel: React.FC<VideoPanelProps> = ({ |
||||
onSelect, |
||||
height, |
||||
width |
||||
}) => { |
||||
const [isOpen, setIsOpen] = useState(false) |
||||
const [videos, setVideos] = useState<Video[]>([]) |
||||
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false) |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const [editVideoIdentifier, setEditVideoIdentifier] = useState< |
||||
string | null | undefined |
||||
>() |
||||
const fetchVideos = React.useCallback(async (): Promise<Video[]> => { |
||||
if (!user?.name) return [] |
||||
|
||||
let res = [] |
||||
try { |
||||
// res = await qortalRequest({
|
||||
// action: 'LIST_QDN_RESOURCES',
|
||||
// service: 'FILE',
|
||||
// name: user.name,
|
||||
// includeMetadata: true,
|
||||
// limit: 100,
|
||||
// offset: 0,
|
||||
// reverse: true
|
||||
// })
|
||||
|
||||
const res2 = await fetch( |
||||
`/arbitrary/resources?&service=FILE&name=${user.name}&includemetadata=true&limit=100&offset=0&reverse=true` |
||||
) |
||||
const resData = await res2.json() |
||||
if (Array.isArray(resData)) { |
||||
res = resData |
||||
} |
||||
} catch (error) {} |
||||
|
||||
// Replace this URL with the actual API endpoint
|
||||
|
||||
return res |
||||
}, [user]) |
||||
useEffect(() => { |
||||
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||
}, []) |
||||
|
||||
const handleToggle = () => { |
||||
setIsOpen(!isOpen) |
||||
} |
||||
|
||||
const handleClick = (video: Video) => { |
||||
onSelect(video) |
||||
} |
||||
|
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex' |
||||
}} |
||||
> |
||||
<Tooltip title="Add any type of file" arrow> |
||||
<AttachFileIcon |
||||
onClick={handleToggle} |
||||
sx={{ |
||||
height: height || '30px', |
||||
width: width || 'auto', |
||||
cursor: 'pointer' |
||||
}} |
||||
></AttachFileIcon> |
||||
</Tooltip> |
||||
<Drawer |
||||
anchor="right" |
||||
open={isOpen} |
||||
onClose={handleToggle} |
||||
ModalProps={{ |
||||
keepMounted: true // Better performance on mobile
|
||||
}} |
||||
sx={{ |
||||
'& .MuiPaper-root': { |
||||
width: '400px' |
||||
} |
||||
}} |
||||
> |
||||
<Panel> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
alignItems: 'center', |
||||
flex: '0 0' |
||||
}} |
||||
> |
||||
<Typography |
||||
variant="h5" |
||||
component="div" |
||||
sx={{ flexGrow: 1, mt: 2, mb: 1 }} |
||||
> |
||||
Select File |
||||
</Typography> |
||||
<Typography |
||||
variant="subtitle2" |
||||
component="div" |
||||
sx={{ flexGrow: 1, mb: 2 }} |
||||
> |
||||
List of Files in QDN under your name (FILE service) |
||||
</Typography> |
||||
</Box> |
||||
|
||||
<List |
||||
sx={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
flex: '1', |
||||
overflow: 'auto' |
||||
}} |
||||
> |
||||
{videos.map((video) => ( |
||||
<ListItem key={video.identifier}> |
||||
<ButtonBase |
||||
onClick={() => handleClick(video)} |
||||
sx={{ width: '100%' }} |
||||
> |
||||
<ListItemText |
||||
primary={video?.metadata?.title || ''} |
||||
secondary={video?.metadata?.description || ''} |
||||
/> |
||||
</ButtonBase> |
||||
<Button |
||||
size="small" |
||||
variant="contained" |
||||
onClick={() => { |
||||
setEditVideoIdentifier(video.identifier) |
||||
setIsOpenVideoModal(true) |
||||
}} |
||||
> |
||||
Edit |
||||
</Button> |
||||
</ListItem> |
||||
))} |
||||
</List> |
||||
<Box |
||||
sx={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
justifyContent: 'center', |
||||
flex: '0 0 50px' |
||||
}} |
||||
> |
||||
<PublishButton |
||||
variant="contained" |
||||
onClick={() => { |
||||
setEditVideoIdentifier(null) |
||||
setIsOpenVideoModal(true) |
||||
}} |
||||
> |
||||
Publish new file |
||||
</PublishButton> |
||||
</Box> |
||||
</Panel> |
||||
</Drawer> |
||||
<GenericModal |
||||
service="FILE" |
||||
identifierPrefix="qfile_qblog" |
||||
onClose={() => { |
||||
setIsOpenVideoModal(false) |
||||
setEditVideoIdentifier(null) |
||||
}} |
||||
open={isOpenVideoModal} |
||||
onPublish={(value) => { |
||||
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||
setIsOpenVideoModal(false) |
||||
}} |
||||
editVideoIdentifier={editVideoIdentifier} |
||||
/> |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
// Add this to your 'types.ts' file
|
||||
export interface Video { |
||||
name: string |
||||
service: string |
||||
identifier: string |
||||
metadata: { |
||||
title: string |
||||
description: string |
||||
tags: string[] |
||||
category: string |
||||
categoryName: string |
||||
} |
||||
size: number |
||||
created: number |
||||
updated: number |
||||
} |
@ -0,0 +1,317 @@
|
||||
import React, { useState } from 'react' |
||||
import { |
||||
Box, |
||||
Button, |
||||
Modal, |
||||
TextField, |
||||
Typography, |
||||
Select, |
||||
MenuItem, |
||||
FormControl, |
||||
InputLabel, |
||||
SelectChangeEvent, |
||||
OutlinedInput, |
||||
Chip, |
||||
IconButton |
||||
} from '@mui/material' |
||||
import { styled } from '@mui/system' |
||||
import { useDropzone } from 'react-dropzone' |
||||
import { toBase64 } from '../../utils/toBase64' |
||||
import AddIcon from '@mui/icons-material/Add' |
||||
import CloseIcon from '@mui/icons-material/Close' |
||||
import { usePublishGeneric } from './PublishGeneric' |
||||
import { useDispatch } from 'react-redux' |
||||
import { setNotification } from '../../state/features/notificationsSlice' |
||||
|
||||
const StyledModal = styled(Modal)(({ theme }) => ({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center' |
||||
})) |
||||
|
||||
const ChipContainer = styled(Box)({ |
||||
display: 'flex', |
||||
flexWrap: 'wrap', |
||||
'& > *': { |
||||
margin: '4px' |
||||
} |
||||
}) |
||||
|
||||
const ModalContent = styled(Box)(({ theme }) => ({ |
||||
backgroundColor: theme.palette.background.paper, |
||||
padding: theme.spacing(4), |
||||
borderRadius: theme.spacing(1), |
||||
width: '40%', |
||||
'&:focus': { |
||||
outline: 'none' |
||||
} |
||||
})) |
||||
|
||||
interface GenericModalProps { |
||||
open: boolean |
||||
onClose: () => void |
||||
onPublish: (value: any) => void |
||||
acceptedFileType?: string |
||||
acceptedFileTypes?: string[] |
||||
service: string |
||||
identifierPrefix: string |
||||
editVideoIdentifier?: string | null | undefined |
||||
} |
||||
|
||||
interface SelectOption { |
||||
id: string |
||||
name: string |
||||
} |
||||
const maxSize = 500 * 1024 * 1024 |
||||
|
||||
export const GenericModal: React.FC<GenericModalProps> = ({ |
||||
open, |
||||
onClose, |
||||
onPublish, |
||||
acceptedFileType, |
||||
acceptedFileTypes, |
||||
service, |
||||
identifierPrefix, |
||||
editVideoIdentifier |
||||
}) => { |
||||
const [file, setFile] = useState<File | null>(null) |
||||
const [title, setTitle] = useState('') |
||||
const [description, setDescription] = useState('') |
||||
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||
null |
||||
) |
||||
const [inputValue, setInputValue] = useState<string>('') |
||||
const [chips, setChips] = useState<string[]>([]) |
||||
|
||||
const [options, setOptions] = useState<SelectOption[]>([]) |
||||
const [tags, setTags] = useState<string[]>([]) |
||||
const { publishGeneric } = usePublishGeneric() |
||||
const dispatch = useDispatch() |
||||
|
||||
let acceptedFile = {} |
||||
if (acceptedFileType) { |
||||
acceptedFile = { |
||||
[acceptedFileType]: [] |
||||
} |
||||
} |
||||
const { getRootProps, getInputProps } = useDropzone({ |
||||
...acceptedFile, |
||||
maxFiles: 1, |
||||
maxSize, |
||||
onDrop: (acceptedFiles) => { |
||||
setFile(acceptedFiles[0]) |
||||
}, |
||||
onDropRejected: (rejectedFiles) => { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Your file is over the 500mb limit.', |
||||
alertType: 'error' |
||||
}) |
||||
) |
||||
} |
||||
}) |
||||
|
||||
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
||||
setTitle(event.target.value) |
||||
} |
||||
|
||||
const handleDescriptionChange = ( |
||||
event: React.ChangeEvent<HTMLInputElement> |
||||
) => { |
||||
setDescription(event.target.value) |
||||
} |
||||
|
||||
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||
const optionId = event.target.value |
||||
const selectedOption = options.find((option) => option.id === optionId) |
||||
setSelectedOption(selectedOption || null) |
||||
} |
||||
|
||||
const handleChipDelete = (index: number) => { |
||||
const newChips = [...chips] |
||||
newChips.splice(index, 1) |
||||
setChips(newChips) |
||||
} |
||||
|
||||
const handleSubmit = async () => { |
||||
const missingFields = [] |
||||
|
||||
if (!title) missingFields.push('title') |
||||
if (!file) missingFields.push('file') |
||||
if (missingFields.length > 0) { |
||||
const missingFieldsString = missingFields.join(', ') |
||||
const errMsg = `Missing: ${missingFieldsString}` |
||||
|
||||
return |
||||
} |
||||
if (!file) return |
||||
|
||||
const formattedTags: { [key: string]: string } = {} |
||||
chips.forEach((tag, i) => { |
||||
formattedTags[`tag${i + 1}`] = tag |
||||
}) |
||||
|
||||
try { |
||||
const base64 = await toBase64(file) |
||||
if (typeof base64 !== 'string') return |
||||
const base64String = base64.split(',')[1] |
||||
const fileExtension = file?.name?.split('.')?.pop() |
||||
const fileTitle = title?.replace(/ /g, '_')?.slice(0, 20) |
||||
const filename = `${fileTitle}.${fileExtension}` |
||||
const res = await publishGeneric({ |
||||
editVideoIdentifier, |
||||
service, |
||||
identifierPrefix, |
||||
title, |
||||
description, |
||||
// base64: base64String,
|
||||
file, |
||||
filename: filename, |
||||
category: selectedOption?.id || '', |
||||
...formattedTags |
||||
}) |
||||
onPublish(res) |
||||
setFile(null) |
||||
setTitle('') |
||||
setDescription('') |
||||
onClose() |
||||
} catch (error) {} |
||||
} |
||||
|
||||
const handleInputChange = (event: any) => { |
||||
setInputValue(event.target.value) |
||||
} |
||||
|
||||
const handleInputKeyDown = (event: any) => { |
||||
if (event.key === 'Enter' && inputValue !== '') { |
||||
if (chips.length < 5) { |
||||
setChips([...chips, inputValue]) |
||||
setInputValue('') |
||||
} else { |
||||
event.preventDefault() |
||||
} |
||||
} |
||||
} |
||||
|
||||
const addChip = () => { |
||||
if (chips.length < 5) { |
||||
setChips([...chips, inputValue]) |
||||
setInputValue('') |
||||
} |
||||
} |
||||
|
||||
const getListCategories = React.useCallback(async () => { |
||||
try { |
||||
const url = `/arbitrary/categories` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
setOptions(responseData) |
||||
} catch (error) {} |
||||
}, []) |
||||
|
||||
React.useEffect(() => { |
||||
getListCategories() |
||||
}, [getListCategories]) |
||||
|
||||
return ( |
||||
<StyledModal open={open} onClose={onClose}> |
||||
<ModalContent> |
||||
{editVideoIdentifier && ( |
||||
<Typography variant="h6"> |
||||
You are editing: {editVideoIdentifier} |
||||
</Typography> |
||||
)} |
||||
<Typography variant="h6" component="h2" gutterBottom> |
||||
Upload {service} |
||||
</Typography> |
||||
<Box |
||||
{...getRootProps()} |
||||
sx={{ |
||||
border: '1px dashed gray', |
||||
padding: 2, |
||||
textAlign: 'center', |
||||
marginBottom: 2 |
||||
}} |
||||
> |
||||
<input {...getInputProps()} /> |
||||
<Typography> |
||||
{file |
||||
? file.name |
||||
: 'Drag and drop a file here or click to select a file'} |
||||
</Typography> |
||||
</Box> |
||||
<TextField |
||||
label="Title" |
||||
variant="outlined" |
||||
fullWidth |
||||
value={title} |
||||
onChange={handleTitleChange} |
||||
inputProps={{ maxLength: 40 }} |
||||
sx={{ marginBottom: 2 }} |
||||
/> |
||||
<TextField |
||||
label="Description" |
||||
variant="outlined" |
||||
fullWidth |
||||
multiline |
||||
rows={4} |
||||
value={description} |
||||
onChange={handleDescriptionChange} |
||||
inputProps={{ maxLength: 180 }} |
||||
sx={{ marginBottom: 2 }} |
||||
/> |
||||
{options.length > 0 && ( |
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||
<InputLabel id="Category">Select a Category</InputLabel> |
||||
<Select |
||||
labelId="Category" |
||||
input={<OutlinedInput label="Select a Category" />} |
||||
value={selectedOption?.id || ''} |
||||
onChange={handleOptionChange} |
||||
> |
||||
{options.map((option) => ( |
||||
<MenuItem key={option.id} value={option.id}> |
||||
{option.name} |
||||
</MenuItem> |
||||
))} |
||||
</Select> |
||||
</FormControl> |
||||
)} |
||||
|
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||
<TextField |
||||
label="Add a tag" |
||||
value={inputValue} |
||||
onChange={handleInputChange} |
||||
onKeyDown={handleInputKeyDown} |
||||
disabled={chips.length === 3} |
||||
/> |
||||
|
||||
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||
<AddIcon /> |
||||
</IconButton> |
||||
</Box> |
||||
<ChipContainer> |
||||
{chips.map((chip, index) => ( |
||||
<Chip |
||||
key={index} |
||||
label={chip} |
||||
onDelete={() => handleChipDelete(index)} |
||||
deleteIcon={<CloseIcon />} |
||||
/> |
||||
))} |
||||
</ChipContainer> |
||||
</FormControl> |
||||
<Button variant="contained" color="primary" onClick={handleSubmit}> |
||||
Submit |
||||
</Button> |
||||
</ModalContent> |
||||
</StyledModal> |
||||
) |
||||
} |
@ -0,0 +1,89 @@
|
||||
import React, { useCallback } from 'react' |
||||
import { Box, Button, TextField, Typography, Modal } from '@mui/material' |
||||
import { |
||||
useDropzone, |
||||
DropzoneRootProps, |
||||
DropzoneInputProps |
||||
} from 'react-dropzone' |
||||
import Compressor from 'compressorjs' |
||||
|
||||
const toBase64 = (file: File): Promise<string | ArrayBuffer | null> => |
||||
new Promise((resolve, reject) => { |
||||
const reader = new FileReader() |
||||
reader.readAsDataURL(file) |
||||
reader.onload = () => resolve(reader.result) |
||||
reader.onerror = (error) => { |
||||
reject(error) |
||||
} |
||||
}) |
||||
|
||||
interface ImageUploaderProps { |
||||
children: React.ReactNode |
||||
onPick: (base64Img: string) => void |
||||
} |
||||
|
||||
const ImageUploader: React.FC<ImageUploaderProps> = ({ children, onPick }) => { |
||||
const onDrop = useCallback( |
||||
async (acceptedFiles: File[]) => { |
||||
if (acceptedFiles.length > 1) { |
||||
return |
||||
} |
||||
let compressedFile: File | undefined |
||||
|
||||
try { |
||||
const image = acceptedFiles[0] |
||||
await new Promise<void>((resolve) => { |
||||
new Compressor(image, { |
||||
quality: 0.6, |
||||
maxWidth: 1200, |
||||
mimeType: 'image/webp', |
||||
success(result) { |
||||
const file = new File([result], 'name', { |
||||
type: 'image/webp' |
||||
}) |
||||
compressedFile = file |
||||
resolve() |
||||
}, |
||||
error(err) {} |
||||
}) |
||||
}) |
||||
if (!compressedFile) return |
||||
const base64Img = await toBase64(compressedFile) |
||||
|
||||
onPick(base64Img as string) |
||||
} catch (error) { |
||||
console.error(error) |
||||
} |
||||
}, |
||||
[onPick] |
||||
) |
||||
|
||||
const { |
||||
getRootProps, |
||||
getInputProps, |
||||
isDragActive |
||||
}: { |
||||
getRootProps: () => DropzoneRootProps |
||||
getInputProps: () => DropzoneInputProps |
||||
isDragActive: boolean |
||||
} = useDropzone({ |
||||
onDrop, |
||||
accept: { |
||||
'image/*': [] |
||||
} |
||||
}) |
||||
|
||||
return ( |
||||
<Box |
||||
{...getRootProps()} |
||||
sx={{ |
||||
display: 'flex' |
||||
}} |
||||
> |
||||
<input {...getInputProps()} /> |
||||
{children} |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
export default ImageUploader |
@ -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,43 @@
|
||||
import React from 'react'; |
||||
import CircularProgress from '@mui/material/CircularProgress'; |
||||
import Box from '@mui/system/Box'; |
||||
import { useTheme } from '@mui/material' |
||||
|
||||
interface PageLoaderProps { |
||||
size?: number |
||||
thickness?: number |
||||
} |
||||
|
||||
const PageLoader: React.FC<PageLoaderProps> = ({ |
||||
size = 40, |
||||
thickness = 5 |
||||
}) => { |
||||
const theme = useTheme() |
||||
|
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
justifyContent: 'center', |
||||
alignItems: 'center', |
||||
height: '100vh', |
||||
width: '100%', |
||||
position: 'fixed', |
||||
top: 0, |
||||
left: 0, |
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)', |
||||
zIndex: 1000 |
||||
}} |
||||
> |
||||
<CircularProgress |
||||
size={size} |
||||
thickness={thickness} |
||||
sx={{ |
||||
color: theme.palette.secondary.main |
||||
}} |
||||
/> |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
export default PageLoader; |
@ -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,111 @@
|
||||
import React from 'react' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { setNotification } from '../../state/features/notificationsSlice' |
||||
import { RootState } from '../../state/store' |
||||
import ShortUniqueId from 'short-unique-id' |
||||
|
||||
const uid = new ShortUniqueId() |
||||
|
||||
interface IPublishVideo { |
||||
title: string |
||||
description: string |
||||
base64: string |
||||
category: string |
||||
editVideoIdentifier?: string | null | undefined |
||||
|
||||
} |
||||
|
||||
export const usePublishAudio = () => { |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const dispatch = useDispatch() |
||||
const publishAudio = async ({ |
||||
editVideoIdentifier, |
||||
title, |
||||
description, |
||||
base64, |
||||
category, |
||||
...rest |
||||
}: IPublishVideo) => { |
||||
let address |
||||
let name |
||||
let errorMsg = '' |
||||
|
||||
address = user?.address |
||||
name = user?.name || '' |
||||
|
||||
const missingFields = [] |
||||
if (!address) { |
||||
errorMsg = "Cannot post: your address isn't available" |
||||
} |
||||
if (!name) { |
||||
errorMsg = 'Cannot post without a name' |
||||
} |
||||
if (!title) missingFields.push('title') |
||||
if (missingFields.length > 0) { |
||||
const missingFieldsString = missingFields.join(', ') |
||||
const errMsg = `Missing: ${missingFieldsString}` |
||||
errorMsg = errMsg |
||||
} |
||||
|
||||
if (errorMsg) { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: errorMsg, |
||||
alertType: 'error' |
||||
}) |
||||
) |
||||
throw new Error(errorMsg) |
||||
} |
||||
|
||||
try { |
||||
const id = uid() |
||||
|
||||
let identifier = `qaudio_qblog_${id}` |
||||
if(editVideoIdentifier){ |
||||
identifier = editVideoIdentifier |
||||
} |
||||
const resourceResponse = await qortalRequest({ |
||||
action: 'PUBLISH_QDN_RESOURCE', |
||||
name: name, |
||||
service: 'AUDIO', |
||||
data64: base64, |
||||
title: title, |
||||
description: description, |
||||
category: category, |
||||
...rest, |
||||
identifier: identifier |
||||
}) |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Audio successfully published', |
||||
alertType: 'success' |
||||
}) |
||||
) |
||||
return resourceResponse |
||||
} catch (error: any) { |
||||
let notificationObj = null |
||||
if (typeof error === 'string') { |
||||
notificationObj = { |
||||
msg: error || 'Failed to publish audio', |
||||
alertType: 'error' |
||||
} |
||||
} else if (typeof error?.error === 'string') { |
||||
notificationObj = { |
||||
msg: error?.error || 'Failed to publish audio', |
||||
alertType: 'error' |
||||
} |
||||
} else { |
||||
notificationObj = { |
||||
msg: error?.message || error?.message || 'Failed to publish audio', |
||||
alertType: 'error' |
||||
} |
||||
} |
||||
if (!notificationObj) return |
||||
dispatch(setNotification(notificationObj)) |
||||
|
||||
} |
||||
} |
||||
return { |
||||
publishAudio |
||||
} |
||||
} |
@ -0,0 +1,120 @@
|
||||
import React from 'react' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { setNotification } from '../../state/features/notificationsSlice' |
||||
import { RootState } from '../../state/store' |
||||
import ShortUniqueId from 'short-unique-id' |
||||
|
||||
const uid = new ShortUniqueId() |
||||
|
||||
interface IPublishGeneric { |
||||
title: string |
||||
description: string |
||||
base64?: string |
||||
file?: File |
||||
category: string |
||||
service: string |
||||
identifierPrefix: string |
||||
filename: string |
||||
editVideoIdentifier?: string | null | undefined |
||||
} |
||||
|
||||
export const usePublishGeneric = () => { |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const dispatch = useDispatch() |
||||
const publishGeneric = async ({ |
||||
editVideoIdentifier, |
||||
service, |
||||
identifierPrefix, |
||||
filename, |
||||
title, |
||||
description, |
||||
base64, |
||||
file, |
||||
category, |
||||
...rest |
||||
}: IPublishGeneric) => { |
||||
let address |
||||
let name |
||||
let errorMsg = '' |
||||
|
||||
address = user?.address |
||||
name = user?.name || '' |
||||
|
||||
const missingFields = [] |
||||
if (!address) { |
||||
errorMsg = "Cannot post: your address isn't available" |
||||
} |
||||
if (!name) { |
||||
errorMsg = 'Cannot post without a name' |
||||
} |
||||
if (!title) missingFields.push('title') |
||||
if (missingFields.length > 0) { |
||||
const missingFieldsString = missingFields.join(', ') |
||||
const errMsg = `Missing: ${missingFieldsString}` |
||||
errorMsg = errMsg |
||||
} |
||||
|
||||
if (errorMsg) { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: errorMsg, |
||||
alertType: 'error' |
||||
}) |
||||
) |
||||
throw new Error(errorMsg) |
||||
} |
||||
|
||||
try { |
||||
const id = uid() |
||||
|
||||
let identifier = `${identifierPrefix}_${id}` |
||||
if (editVideoIdentifier) { |
||||
identifier = editVideoIdentifier |
||||
} |
||||
|
||||
const resourceResponse = await qortalRequest({ |
||||
action: 'PUBLISH_QDN_RESOURCE', |
||||
name: name, |
||||
service: service, |
||||
file, |
||||
title: title, |
||||
description: description, |
||||
category: category, |
||||
filename, |
||||
...rest, |
||||
identifier: identifier |
||||
}) |
||||
dispatch( |
||||
setNotification({ |
||||
msg: `${service} successfully published`, |
||||
alertType: 'success' |
||||
}) |
||||
) |
||||
return resourceResponse |
||||
} catch (error: any) { |
||||
let notificationObj = null |
||||
if (typeof error === 'string') { |
||||
notificationObj = { |
||||
msg: error || `Failed to publish ${service}`, |
||||
alertType: 'error' |
||||
} |
||||
} else if (typeof error?.error === 'string') { |
||||
notificationObj = { |
||||
msg: error?.error || `Failed to publish ${service}`, |
||||
alertType: 'error' |
||||
} |
||||
} else { |
||||
notificationObj = { |
||||
msg: |
||||
error?.message || error?.message || `Failed to publish ${service}`, |
||||
alertType: 'error' |
||||
} |
||||
} |
||||
if (!notificationObj) return |
||||
dispatch(setNotification(notificationObj)) |
||||
} |
||||
} |
||||
return { |
||||
publishGeneric |
||||
} |
||||
} |
@ -0,0 +1,112 @@
|
||||
import React from 'react' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { setNotification } from '../../state/features/notificationsSlice' |
||||
import { RootState } from '../../state/store' |
||||
import ShortUniqueId from 'short-unique-id' |
||||
|
||||
const uid = new ShortUniqueId() |
||||
|
||||
interface IPublishVideo { |
||||
title: string |
||||
description: string |
||||
base64?: string |
||||
category: string |
||||
editVideoIdentifier?: string | null | undefined |
||||
file?: File |
||||
} |
||||
|
||||
export const usePublishVideo = () => { |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const dispatch = useDispatch() |
||||
const publishVideo = async ({ |
||||
file, |
||||
editVideoIdentifier, |
||||
title, |
||||
description, |
||||
base64, |
||||
category, |
||||
...rest |
||||
}: IPublishVideo) => { |
||||
let address |
||||
let name |
||||
let errorMsg = '' |
||||
|
||||
address = user?.address |
||||
name = user?.name || '' |
||||
|
||||
const missingFields = [] |
||||
if (!address) { |
||||
errorMsg = "Cannot post: your address isn't available" |
||||
} |
||||
if (!name) { |
||||
errorMsg = 'Cannot post without a name' |
||||
} |
||||
if (!title) missingFields.push('title') |
||||
if (missingFields.length > 0) { |
||||
const missingFieldsString = missingFields.join(', ') |
||||
const errMsg = `Missing: ${missingFieldsString}` |
||||
errorMsg = errMsg |
||||
} |
||||
|
||||
if (errorMsg) { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: errorMsg, |
||||
alertType: 'error' |
||||
}) |
||||
) |
||||
throw new Error(errorMsg) |
||||
} |
||||
|
||||
try { |
||||
const id = uid() |
||||
|
||||
let identifier = `qvideo_qblog_${id}` |
||||
if (editVideoIdentifier) { |
||||
identifier = editVideoIdentifier |
||||
} |
||||
const resourceResponse = await qortalRequest({ |
||||
action: 'PUBLISH_QDN_RESOURCE', |
||||
name: name, |
||||
service: 'VIDEO', |
||||
// data64: base64,
|
||||
file: file, |
||||
title: title, |
||||
description: description, |
||||
category: category, |
||||
...rest, |
||||
identifier: identifier |
||||
}) |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Video successfully published', |
||||
alertType: 'success' |
||||
}) |
||||
) |
||||
return resourceResponse |
||||
} catch (error: any) { |
||||
let notificationObj = null |
||||
if (typeof error === 'string') { |
||||
notificationObj = { |
||||
msg: error || 'Failed to publish video', |
||||
alertType: 'error' |
||||
} |
||||
} else if (typeof error?.error === 'string') { |
||||
notificationObj = { |
||||
msg: error?.error || 'Failed to publish video', |
||||
alertType: 'error' |
||||
} |
||||
} else { |
||||
notificationObj = { |
||||
msg: error?.message || 'Failed to publish video', |
||||
alertType: 'error' |
||||
} |
||||
} |
||||
if (!notificationObj) return |
||||
dispatch(setNotification(notificationObj)) |
||||
} |
||||
} |
||||
return { |
||||
publishVideo |
||||
} |
||||
} |
@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect, CSSProperties } from 'react' |
||||
import Skeleton from '@mui/material/Skeleton' |
||||
import { Box } from '@mui/material' |
||||
|
||||
interface ResponsiveImageProps { |
||||
src: string |
||||
dimensions: string |
||||
alt?: string |
||||
className?: string |
||||
style?: CSSProperties |
||||
} |
||||
|
||||
const ResponsiveImage: React.FC<ResponsiveImageProps> = ({ |
||||
src, |
||||
dimensions, |
||||
alt, |
||||
className, |
||||
style |
||||
}) => { |
||||
const [loading, setLoading] = useState(true) |
||||
const matchResult = dimensions?.match(/v1\.(\d+(\.\d+)?)x(\d+)/) |
||||
|
||||
const width = matchResult ? parseFloat(matchResult[1]) : 1 // Default width value
|
||||
const height = matchResult ? parseInt(matchResult[3], 10) : 1 // Default height value
|
||||
|
||||
const aspectRatio = (height / width) * 100 |
||||
|
||||
useEffect(() => { |
||||
if (dimensions === 'v1.0x0') { |
||||
setLoading(false) |
||||
return |
||||
} |
||||
}, [dimensions]) |
||||
|
||||
if (dimensions === 'v1.0x0' || !dimensions) { |
||||
return null |
||||
} |
||||
|
||||
const imageStyle: CSSProperties = { |
||||
width: '100%', |
||||
height: '100%', |
||||
objectFit: 'cover' |
||||
} |
||||
|
||||
const wrapperStyle: CSSProperties = { |
||||
position: 'relative', |
||||
paddingBottom: `${aspectRatio}%`, |
||||
overflow: 'hidden', |
||||
...style |
||||
} |
||||
|
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
padding: '2px' |
||||
}} |
||||
> |
||||
{/* <img |
||||
onLoad={() => setLoading(false)} |
||||
src={src} |
||||
style={{ |
||||
width: '100%', |
||||
height: 'auto', |
||||
borderRadius: '8px' |
||||
}} |
||||
/> */} |
||||
{loading && ( |
||||
<Skeleton |
||||
variant="rectangular" |
||||
style={{ |
||||
width: '100%', |
||||
height: 0, |
||||
paddingBottom: `${(height / width) * 100}%`, |
||||
objectFit: 'contain', |
||||
visibility: loading ? 'visible' : 'hidden', |
||||
borderRadius: '8px' |
||||
}} |
||||
/> |
||||
)} |
||||
|
||||
<img |
||||
onLoad={() => setLoading(false)} |
||||
src={src} |
||||
style={{ |
||||
width: '100%', |
||||
height: 'auto', |
||||
borderRadius: '8px', |
||||
visibility: loading ? 'hidden' : 'visible', |
||||
position: loading ? 'absolute' : 'unset' |
||||
}} |
||||
/> |
||||
</Box> |
||||
) |
||||
|
||||
return ( |
||||
<div style={wrapperStyle} className={className}> |
||||
{loading ? ( |
||||
<Skeleton |
||||
variant="rectangular" |
||||
sx={{ |
||||
position: 'absolute', |
||||
top: 0, |
||||
left: 0, |
||||
right: 0, |
||||
bottom: 0 |
||||
}} |
||||
/> |
||||
) : ( |
||||
<img |
||||
src={src} |
||||
alt={alt} |
||||
style={{ |
||||
...imageStyle, |
||||
position: 'absolute', |
||||
top: 0, |
||||
left: 0 |
||||
}} |
||||
/> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
export default ResponsiveImage |
@ -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,284 @@
|
||||
import React, { useState, useEffect } from 'react' |
||||
import { styled, Box } from '@mui/system' |
||||
import { |
||||
Drawer, |
||||
List, |
||||
ListItem, |
||||
ListItemText, |
||||
Typography, |
||||
ButtonBase, |
||||
Button, |
||||
Tooltip |
||||
} from '@mui/material' |
||||
import VideoCallIcon from '@mui/icons-material/VideoCall' |
||||
import VideoModal from './VideoPublishModal' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { CopyToClipboard } from 'react-copy-to-clipboard' |
||||
|
||||
import { RootState } from '../../state/store' |
||||
import LinkIcon from '@mui/icons-material/Link' |
||||
import { setNotification } from '../../state/features/notificationsSlice' |
||||
interface VideoPanelProps { |
||||
onSelect: (video: Video) => void |
||||
height?: string |
||||
width?: string |
||||
} |
||||
|
||||
interface VideoApiResponse { |
||||
videos: Video[] |
||||
} |
||||
|
||||
const Panel = styled('div')` |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: center; |
||||
align-items: center; |
||||
width: 100%; |
||||
padding-bottom: 10px; |
||||
height: 100%; |
||||
overflow: hidden; |
||||
|
||||
&::-webkit-scrollbar { |
||||
width: 8px; |
||||
height: 8px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar-thumb { |
||||
background-color: #888; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar-thumb:hover { |
||||
background-color: #555; |
||||
} |
||||
` |
||||
|
||||
const PublishButton = styled(Button)` |
||||
/* position: absolute; |
||||
bottom: 20px; |
||||
left: 0; |
||||
right: 0; |
||||
margin: auto; */ |
||||
max-width: 80%; |
||||
` |
||||
|
||||
export const VideoPanel: React.FC<VideoPanelProps> = ({ |
||||
onSelect, |
||||
height, |
||||
width |
||||
}) => { |
||||
const [isOpen, setIsOpen] = useState(false) |
||||
const [videos, setVideos] = useState<Video[]>([]) |
||||
const [isOpenVideoModal, setIsOpenVideoModal] = useState<boolean>(false) |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const [editVideoIdentifier, setEditVideoIdentifier] = useState< |
||||
string | null | undefined |
||||
>() |
||||
const dispatch = useDispatch() |
||||
const fetchVideos = React.useCallback(async (): Promise<Video[]> => { |
||||
if (!user?.name) return [] |
||||
// Replace this URL with the actual API endpoint
|
||||
let res = [] |
||||
try { |
||||
// res = await qortalRequest({
|
||||
// action: 'LIST_QDN_RESOURCES',
|
||||
// service: 'VIDEO',
|
||||
// name: user.name,
|
||||
// includeMetadata: true,
|
||||
// limit: 100,
|
||||
// offset: 0,
|
||||
// reverse: true
|
||||
// })
|
||||
|
||||
const res2 = await fetch( |
||||
`/arbitrary/resources?&service=VIDEO&name=${user.name}&includemetadata=true&limit=100&offset=0&reverse=true` |
||||
) |
||||
const resData = await res2.json() |
||||
if (Array.isArray(resData)) { |
||||
res = resData |
||||
} |
||||
} catch (error) { |
||||
// const res2 = await fetch(
|
||||
// '/arbitrary/resources?&service=VIDEO&name=Phil&includemetadata=true&limit=100&offset=0&reverse=true'
|
||||
// )
|
||||
// res = await res2.json()
|
||||
} |
||||
|
||||
return res |
||||
}, [user]) |
||||
useEffect(() => { |
||||
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||
}, []) |
||||
|
||||
const handleToggle = () => { |
||||
setIsOpen(!isOpen) |
||||
} |
||||
|
||||
const handleClick = (video: Video) => { |
||||
onSelect(video) |
||||
} |
||||
|
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex' |
||||
}} |
||||
> |
||||
<Tooltip title="Add a video" arrow> |
||||
<VideoCallIcon |
||||
onClick={handleToggle} |
||||
sx={{ |
||||
height: height || '30px', |
||||
width: width || 'auto', |
||||
cursor: 'pointer' |
||||
}} |
||||
></VideoCallIcon> |
||||
</Tooltip> |
||||
<Drawer |
||||
anchor="right" |
||||
open={isOpen} |
||||
onClose={handleToggle} |
||||
ModalProps={{ |
||||
keepMounted: true // Better performance on mobile
|
||||
}} |
||||
sx={{ |
||||
'& .MuiPaper-root': { |
||||
width: '400px' |
||||
} |
||||
}} |
||||
> |
||||
<Panel> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
alignItems: 'center', |
||||
flex: '0 0' |
||||
}} |
||||
> |
||||
<Typography |
||||
variant="h5" |
||||
component="div" |
||||
sx={{ flexGrow: 1, mt: 2, mb: 1 }} |
||||
> |
||||
Select Video |
||||
</Typography> |
||||
<Typography |
||||
variant="subtitle2" |
||||
component="div" |
||||
sx={{ flexGrow: 1, mb: 2 }} |
||||
> |
||||
List of videos in QDN under your name |
||||
</Typography> |
||||
</Box> |
||||
|
||||
<List |
||||
sx={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
flex: '1', |
||||
overflow: 'auto' |
||||
}} |
||||
> |
||||
{videos.map((video) => ( |
||||
<ListItem key={video.identifier}> |
||||
<ButtonBase |
||||
onClick={() => handleClick(video)} |
||||
sx={{ width: '100%' }} |
||||
> |
||||
<ListItemText |
||||
primary={video?.metadata?.title || ''} |
||||
secondary={video?.metadata?.description || ''} |
||||
/> |
||||
</ButtonBase> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
gap: '5px' |
||||
}} |
||||
> |
||||
<Button |
||||
size="small" |
||||
variant="contained" |
||||
onClick={() => { |
||||
setEditVideoIdentifier(video.identifier) |
||||
setIsOpenVideoModal(true) |
||||
}} |
||||
> |
||||
Edit |
||||
</Button> |
||||
<CopyToClipboard |
||||
text={`qortal://${video.service}/${video.name}/${video.identifier}`} |
||||
onCopy={() => { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Copied to clipboard!', |
||||
alertType: 'success' |
||||
}) |
||||
) |
||||
}} |
||||
> |
||||
<LinkIcon |
||||
sx={{ |
||||
fontSize: '14px', |
||||
cursor: 'pointer' |
||||
}} |
||||
/> |
||||
</CopyToClipboard> |
||||
</Box> |
||||
</ListItem> |
||||
))} |
||||
</List> |
||||
<Box |
||||
sx={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
justifyContent: 'center', |
||||
flex: '0 0 50px' |
||||
}} |
||||
> |
||||
<PublishButton |
||||
variant="contained" |
||||
onClick={() => { |
||||
setEditVideoIdentifier(null) |
||||
setIsOpenVideoModal(true) |
||||
}} |
||||
> |
||||
Publish new video |
||||
</PublishButton> |
||||
</Box> |
||||
</Panel> |
||||
</Drawer> |
||||
<VideoModal |
||||
onClose={() => { |
||||
setIsOpenVideoModal(false) |
||||
setEditVideoIdentifier(null) |
||||
}} |
||||
open={isOpenVideoModal} |
||||
onPublish={(value) => { |
||||
fetchVideos().then((fetchedVideos) => setVideos(fetchedVideos)) |
||||
setIsOpenVideoModal(false) |
||||
}} |
||||
editVideoIdentifier={editVideoIdentifier} |
||||
/> |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
// Add this to your 'types.ts' file
|
||||
export interface Video { |
||||
name: string |
||||
service: string |
||||
identifier: string |
||||
metadata: { |
||||
title: string |
||||
description: string |
||||
tags: string[] |
||||
category: string |
||||
categoryName: string |
||||
} |
||||
size: number |
||||
created: number |
||||
updated: number |
||||
} |
@ -0,0 +1,832 @@
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react' |
||||
import ReactDOM from 'react-dom' |
||||
import { Box, IconButton, Slider } from '@mui/material' |
||||
import { CircularProgress, Typography } from '@mui/material' |
||||
import { Key } from 'ts-key-enum' |
||||
import { |
||||
PlayArrow, |
||||
Pause, |
||||
VolumeUp, |
||||
Fullscreen, |
||||
PictureInPicture, VolumeOff |
||||
} from '@mui/icons-material' |
||||
import { styled } from '@mui/system' |
||||
import { MyContext } from '../../wrappers/DownloadWrapper' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../../state/store' |
||||
import { Refresh } from '@mui/icons-material' |
||||
|
||||
import { Menu, MenuItem } from '@mui/material' |
||||
import { MoreVert as MoreIcon } from '@mui/icons-material' |
||||
const VideoContainer = styled(Box)` |
||||
position: relative; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
justify-content: center; |
||||
width: 100%; |
||||
height: 100%; |
||||
margin: 0px; |
||||
padding: 0px; |
||||
` |
||||
|
||||
const VideoElement = styled('video')` |
||||
width: 100%; |
||||
height: auto; |
||||
background: rgb(33, 33, 33); |
||||
` |
||||
|
||||
const ControlsContainer = styled(Box)` |
||||
position: absolute; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
bottom: 0; |
||||
left: 0; |
||||
right: 0; |
||||
padding: 8px; |
||||
background-color: rgba(0, 0, 0, 0.6); |
||||
` |
||||
|
||||
interface VideoPlayerProps { |
||||
src?: string |
||||
poster?: string |
||||
name?: string |
||||
identifier?: string |
||||
service?: string |
||||
autoplay?: boolean |
||||
from?: string | null |
||||
setCount?: () => void |
||||
customStyle?: any |
||||
user?: string |
||||
postId?: string |
||||
} |
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ |
||||
poster, |
||||
name, |
||||
identifier, |
||||
service, |
||||
autoplay = true, |
||||
from = null, |
||||
setCount, |
||||
customStyle = {}, |
||||
user = '', |
||||
postId = '' |
||||
}) => { |
||||
const videoRef = useRef<HTMLVideoElement | null>(null) |
||||
const [playing, setPlaying] = useState(false) |
||||
const [volume, setVolume] = useState(1) |
||||
const [mutedVolume, setMutedVolume] = useState(1) |
||||
const [isMuted, setIsMuted] = useState(false) |
||||
const [progress, setProgress] = useState(0) |
||||
const [isLoading, setIsLoading] = useState(false) |
||||
const [canPlay, setCanPlay] = useState(false) |
||||
const [startPlay, setStartPlay] = useState(false) |
||||
const [isMobileView, setIsMobileView] = useState(false) |
||||
const [playbackRate, setPlaybackRate] = useState(1) |
||||
const [anchorEl, setAnchorEl] = useState(null) |
||||
const [consoleLog, setConsoleLog] = useState('Console Log Here') |
||||
const [debug, setDebug] = useState(false) |
||||
|
||||
const reDownload = useRef<boolean>(false) |
||||
const { downloads } = useSelector((state: RootState) => state.global) |
||||
const download = useMemo(() => { |
||||
if (!downloads || !identifier) return {} |
||||
const findDownload = downloads[identifier] |
||||
|
||||
if (!findDownload) return {} |
||||
return findDownload |
||||
}, [downloads, identifier]) |
||||
|
||||
const src = useMemo(() => { |
||||
return download?.url || '' |
||||
}, [download?.url]) |
||||
const resourceStatus = useMemo(() => { |
||||
return download?.status || {} |
||||
}, [download]) |
||||
|
||||
const minSpeed = 0.25; |
||||
const maxSpeed = 4.0; |
||||
const speedChange = 0.25; |
||||
|
||||
const updatePlaybackRate = (newSpeed: number) => { |
||||
if(videoRef.current) { |
||||
if(newSpeed > maxSpeed || newSpeed < minSpeed) |
||||
newSpeed = minSpeed |
||||
videoRef.current.playbackRate = newSpeed |
||||
setPlaybackRate(newSpeed) |
||||
} |
||||
} |
||||
|
||||
const increaseSpeed = (wrapOverflow = true) => { |
||||
const changedSpeed = playbackRate + speedChange |
||||
let newSpeed = wrapOverflow ? changedSpeed: Math.min(changedSpeed, maxSpeed) |
||||
|
||||
|
||||
if (videoRef.current) { |
||||
updatePlaybackRate(newSpeed); |
||||
} |
||||
} |
||||
|
||||
const decreaseSpeed = () => { |
||||
if (videoRef.current) { |
||||
updatePlaybackRate(playbackRate - speedChange); |
||||
} |
||||
} |
||||
|
||||
|
||||
const toggleRef = useRef<any>(null) |
||||
const { downloadVideo } = useContext(MyContext) |
||||
const togglePlay = async () => { |
||||
if (!videoRef.current) return |
||||
setStartPlay(true) |
||||
if (!src) { |
||||
const el = document.getElementById('videoWrapper') |
||||
if (el) { |
||||
el?.parentElement?.removeChild(el) |
||||
} |
||||
ReactDOM.flushSync(() => { |
||||
setIsLoading(true) |
||||
}) |
||||
getSrc() |
||||
} |
||||
if (playing) { |
||||
videoRef.current.pause() |
||||
} else { |
||||
videoRef.current.play() |
||||
} |
||||
setPlaying(!playing) |
||||
} |
||||
|
||||
const onVolumeChange = (_: any, value: number | number[]) => { |
||||
if (!videoRef.current) return |
||||
videoRef.current.volume = value as number |
||||
setVolume(value as number) |
||||
setIsMuted(false) |
||||
} |
||||
|
||||
const onProgressChange = (_: any, value: number | number[]) => { |
||||
if (!videoRef.current) return |
||||
videoRef.current.currentTime = value as number |
||||
setProgress(value as number) |
||||
if (!playing) { |
||||
videoRef.current.play() |
||||
setPlaying(true) |
||||
} |
||||
} |
||||
|
||||
const handleEnded = () => { |
||||
setPlaying(false) |
||||
} |
||||
|
||||
const updateProgress = () => { |
||||
if (!videoRef.current) return |
||||
setProgress(videoRef.current.currentTime) |
||||
} |
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false) |
||||
|
||||
const enterFullscreen = () => { |
||||
if (!videoRef.current) return |
||||
if (videoRef.current.requestFullscreen) { |
||||
videoRef.current.requestFullscreen() |
||||
} |
||||
} |
||||
|
||||
const exitFullscreen = () => { |
||||
if (document.exitFullscreen) { |
||||
document.exitFullscreen() |
||||
} |
||||
} |
||||
|
||||
const toggleFullscreen = () => { |
||||
isFullscreen ? exitFullscreen(): enterFullscreen() |
||||
} |
||||
const togglePictureInPicture = async () => { |
||||
if (!videoRef.current) return |
||||
if (document.pictureInPictureElement === videoRef.current) { |
||||
await document.exitPictureInPicture() |
||||
} else { |
||||
await videoRef.current.requestPictureInPicture() |
||||
} |
||||
} |
||||
|
||||
useEffect(() => { |
||||
const handleFullscreenChange = () => { |
||||
setIsFullscreen(!!document.fullscreenElement) |
||||
} |
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange) |
||||
return () => { |
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange) |
||||
} |
||||
}, []) |
||||
|
||||
const handleLoadedMetadata = () => { |
||||
setIsLoading(false) |
||||
} |
||||
|
||||
const handleCanPlay = () => { |
||||
if (setCount) { |
||||
setCount() |
||||
} |
||||
setIsLoading(false) |
||||
setCanPlay(true) |
||||
} |
||||
|
||||
const getSrc = React.useCallback(async () => { |
||||
if (!name || !identifier || !service || !postId || !user) return |
||||
try { |
||||
downloadVideo({ |
||||
name, |
||||
service, |
||||
identifier, |
||||
blogPost: { |
||||
postId, |
||||
user |
||||
} |
||||
}) |
||||
} catch (error) {} |
||||
}, [identifier, name, service]) |
||||
|
||||
useEffect(() => { |
||||
const videoElement = videoRef.current |
||||
|
||||
const handleLeavePictureInPicture = async (event: any) => { |
||||
const target = event?.target |
||||
if (target) { |
||||
target.pause() |
||||
if (setPlaying) { |
||||
setPlaying(false) |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (videoElement) { |
||||
videoElement.addEventListener( |
||||
'leavepictureinpicture', |
||||
handleLeavePictureInPicture |
||||
) |
||||
} |
||||
|
||||
return () => { |
||||
if (videoElement) { |
||||
videoElement.removeEventListener( |
||||
'leavepictureinpicture', |
||||
handleLeavePictureInPicture |
||||
) |
||||
} |
||||
} |
||||
}, []) |
||||
|
||||
useEffect(() => { |
||||
const videoElement = videoRef.current |
||||
|
||||
const minimizeVideo = async () => { |
||||
if (!videoElement) return |
||||
const handleClose = () => { |
||||
if (videoElement && videoElement.parentElement) { |
||||
const el = document.getElementById('videoWrapper') |
||||
if (el) { |
||||
el?.parentElement?.removeChild(el) |
||||
} |
||||
} |
||||
} |
||||
const createCloseButton = (): HTMLButtonElement => { |
||||
const closeButton = document.createElement('button') |
||||
closeButton.textContent = 'X' |
||||
closeButton.style.position = 'absolute' |
||||
closeButton.style.top = '0' |
||||
closeButton.style.right = '0' |
||||
closeButton.style.backgroundColor = 'rgba(255, 255, 255, 0.7)' |
||||
closeButton.style.border = 'none' |
||||
closeButton.style.fontWeight = 'bold' |
||||
closeButton.style.fontSize = '1.2rem' |
||||
closeButton.style.cursor = 'pointer' |
||||
closeButton.style.padding = '2px 8px' |
||||
closeButton.style.borderRadius = '0 0 0 4px' |
||||
|
||||
closeButton.addEventListener('click', handleClose) |
||||
|
||||
return closeButton |
||||
} |
||||
const buttonClose = createCloseButton() |
||||
const videoWrapper = document.createElement('div') |
||||
videoWrapper.id = 'videoWrapper' |
||||
videoWrapper.style.position = 'fixed' |
||||
videoWrapper.style.zIndex = '900000009' |
||||
videoWrapper.style.bottom = '0px' |
||||
videoWrapper.style.right = '0px' |
||||
|
||||
videoElement.parentElement?.insertBefore(videoWrapper, videoElement) |
||||
videoWrapper.appendChild(videoElement) |
||||
|
||||
videoWrapper.appendChild(buttonClose) |
||||
videoElement.controls = true |
||||
videoElement.style.height = 'auto' |
||||
videoElement.style.width = '300px' |
||||
|
||||
document.body.appendChild(videoWrapper) |
||||
} |
||||
|
||||
return () => { |
||||
if (videoElement) { |
||||
if (videoElement && !videoElement.paused && !videoElement.ended) { |
||||
minimizeVideo() |
||||
} |
||||
} |
||||
} |
||||
}, []) |
||||
|
||||
function formatTime(seconds: number): string { |
||||
seconds = Math.floor(seconds) |
||||
let minutes: number | string = Math.floor(seconds / 60) |
||||
let hours: number | string = Math.floor(minutes / 60) |
||||
|
||||
let remainingSeconds: number | string = seconds % 60 |
||||
let remainingMinutes: number | string = minutes % 60 |
||||
|
||||
if (remainingSeconds < 10) { |
||||
remainingSeconds = '0' + remainingSeconds |
||||
} |
||||
|
||||
if (remainingMinutes < 10) { |
||||
remainingMinutes = '0' + remainingMinutes |
||||
} |
||||
|
||||
if(hours === 0){ |
||||
hours = '' |
||||
} |
||||
else |
||||
{ |
||||
hours = hours + ':' |
||||
} |
||||
|
||||
return hours + remainingMinutes + ':' + remainingSeconds |
||||
} |
||||
|
||||
const reloadVideo = () => { |
||||
if (!videoRef.current) return |
||||
const currentTime = videoRef.current.currentTime |
||||
videoRef.current.src = src |
||||
videoRef.current.load() |
||||
videoRef.current.currentTime = currentTime |
||||
if (playing) { |
||||
videoRef.current.play() |
||||
} |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if ( |
||||
resourceStatus?.status === 'DOWNLOADED' && |
||||
reDownload?.current === false |
||||
) { |
||||
getSrc() |
||||
reDownload.current = true |
||||
} |
||||
}, [getSrc, resourceStatus]) |
||||
|
||||
const handleMenuOpen = (event: any) => { |
||||
setAnchorEl(event.currentTarget) |
||||
} |
||||
|
||||
const handleMenuClose = () => { |
||||
setAnchorEl(null) |
||||
} |
||||
|
||||
useEffect(() => { |
||||
const videoWidth = videoRef?.current?.offsetWidth |
||||
if (videoWidth && videoWidth <= 600) { |
||||
setIsMobileView(true) |
||||
} |
||||
}, [canPlay]) |
||||
|
||||
const getDownloadProgress = (current: number, total: number) => { |
||||
const progress = current /total * 100; |
||||
return Number.isNaN(progress) ? '': progress.toFixed(0)+'%' |
||||
} |
||||
const mute = () => { |
||||
setIsMuted(true) |
||||
setMutedVolume(volume) |
||||
setVolume(0) |
||||
if(videoRef.current) videoRef.current.volume = 0 |
||||
} |
||||
const unMute = () => { |
||||
setIsMuted(false) |
||||
setVolume(mutedVolume) |
||||
if(videoRef.current) videoRef.current.volume = mutedVolume |
||||
} |
||||
|
||||
const toggleMute = () => { |
||||
isMuted ? unMute() : mute(); |
||||
} |
||||
|
||||
const changeVolume = (volumeChange: number) => |
||||
{ |
||||
if(videoRef.current){ |
||||
const minVolume = 0; |
||||
const maxVolume = 1; |
||||
|
||||
|
||||
let newVolume = volumeChange + volume |
||||
|
||||
newVolume = Math.max(newVolume, minVolume) |
||||
newVolume = Math.min(newVolume, maxVolume) |
||||
|
||||
setIsMuted(false) |
||||
setMutedVolume(newVolume) |
||||
videoRef.current.volume = newVolume |
||||
setVolume(newVolume); |
||||
} |
||||
|
||||
} |
||||
const setProgressRelative = (secondsChange: number) => { |
||||
if(videoRef.current){ |
||||
const currentTime = videoRef.current?.currentTime |
||||
const minTime = 0 |
||||
const maxTime = videoRef.current?.duration || 100 |
||||
|
||||
let newTime = currentTime + secondsChange; |
||||
newTime = Math.max(newTime, minTime) |
||||
newTime = Math.min(newTime, maxTime) |
||||
videoRef.current.currentTime = newTime; |
||||
setProgress(newTime); |
||||
} |
||||
} |
||||
|
||||
const setProgressAbsolute = (videoPercent: number) => { |
||||
if(videoRef.current){ |
||||
videoPercent = Math.min(videoPercent, 100) |
||||
videoPercent = Math.max(videoPercent, 0) |
||||
const finalTime = videoRef.current?.duration*videoPercent / 100 |
||||
videoRef.current.currentTime = finalTime |
||||
setProgress(finalTime); |
||||
} |
||||
} |
||||
|
||||
|
||||
const keyboardShortcutsDown = (e: React.KeyboardEvent<HTMLDivElement>) => |
||||
{ |
||||
e.preventDefault() |
||||
//setConsoleLog(`Alt: ${e.altKey} Shift: ${e.shiftKey} Control: ${e.ctrlKey} Key: ${e.key}`)
|
||||
|
||||
switch(e.key) { |
||||
case Key.Add: increaseSpeed(false); break; |
||||
case '+': increaseSpeed(false); break; |
||||
case '>': increaseSpeed(false); break; |
||||
|
||||
case Key.Subtract: decreaseSpeed(); break; |
||||
case '-': decreaseSpeed(); break; |
||||
case '<': decreaseSpeed(); break; |
||||
|
||||
case Key.ArrowLeft: { |
||||
if(e.shiftKey) setProgressRelative(-300); |
||||
else if(e.ctrlKey) setProgressRelative(-60); |
||||
else if(e.altKey) setProgressRelative(-10); |
||||
else setProgressRelative(-5); |
||||
} break; |
||||
|
||||
case Key.ArrowRight: { |
||||
if(e.shiftKey) setProgressRelative(300); |
||||
else if(e.ctrlKey) setProgressRelative(60); |
||||
else if(e.altKey) setProgressRelative(10); |
||||
else setProgressRelative(5); |
||||
} break; |
||||
|
||||
case Key.ArrowDown: changeVolume(-0.05) ; break; |
||||
case Key.ArrowUp: changeVolume(0.05) ; break; |
||||
} |
||||
} |
||||
|
||||
const keyboardShortcutsUp = (e: React.KeyboardEvent<HTMLDivElement>) => |
||||
{ |
||||
e.preventDefault() |
||||
//setConsoleLog(`Alt: ${e.altKey} Shift: ${e.shiftKey} Control: ${e.ctrlKey} Key: ${e.key}`)
|
||||
|
||||
switch(e.key) { |
||||
case ' ': togglePlay(); break; |
||||
case 'm': toggleMute(); break; |
||||
|
||||
case 'f': enterFullscreen(); break; |
||||
case Key.Escape: exitFullscreen(); break; |
||||
|
||||
case '0': setProgressAbsolute(0); break; |
||||
case '1': setProgressAbsolute(10); break; |
||||
case '2': setProgressAbsolute(20); break; |
||||
case '3': setProgressAbsolute(30); break; |
||||
case '4': setProgressAbsolute(40); break; |
||||
case '5': setProgressAbsolute(50); break; |
||||
case '6': setProgressAbsolute(60); break; |
||||
case '7': setProgressAbsolute(70); break; |
||||
case '8': setProgressAbsolute(80); break; |
||||
case '9': setProgressAbsolute(90); break; |
||||
} |
||||
} |
||||
|
||||
|
||||
return ( |
||||
<VideoContainer |
||||
tabIndex={0} |
||||
onKeyUp={keyboardShortcutsUp} |
||||
onKeyDown={keyboardShortcutsDown} |
||||
style={{ |
||||
padding: from === 'create' ? '8px' : 0 |
||||
}} |
||||
> |
||||
{/* <Box |
||||
sx={{ |
||||
position: 'absolute', |
||||
top: '-30px', |
||||
right: '-15px' |
||||
}} |
||||
> |
||||
<CopyToClipboard |
||||
text={`qortal://${service}/${name}/${identifier}`} |
||||
onCopy={() => { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Copied to clipboard!', |
||||
alertType: 'success' |
||||
}) |
||||
) |
||||
}} |
||||
> |
||||
<LinkIcon |
||||
sx={{ |
||||
fontSize: '14px', |
||||
cursor: 'pointer' |
||||
}} |
||||
/> |
||||
</CopyToClipboard> |
||||
</Box> */} |
||||
{isLoading && ( |
||||
<Box |
||||
position="absolute" |
||||
top={0} |
||||
left={0} |
||||
right={0} |
||||
bottom={resourceStatus?.status === 'READY' ? '55px ' : 0} |
||||
display="flex" |
||||
justifyContent="center" |
||||
alignItems="center" |
||||
zIndex={4999} |
||||
bgcolor="rgba(0, 0, 0, 0.6)" |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
gap: '10px' |
||||
}} |
||||
> |
||||
<CircularProgress color="secondary" /> |
||||
{resourceStatus && ( |
||||
<Typography |
||||
variant="subtitle2" |
||||
component="div" |
||||
sx={{ |
||||
color: 'white', |
||||
fontSize: '15px', |
||||
textAlign: 'center' |
||||
}} |
||||
> |
||||
{resourceStatus?.status === 'REFETCHING' ? ( |
||||
<> |
||||
<> |
||||
{getDownloadProgress(resourceStatus?.localChunkCount,resourceStatus?.totalChunkCount)} |
||||
</> |
||||
|
||||
<> Refetching in 25 seconds</> |
||||
</> |
||||
) : resourceStatus?.status === 'DOWNLOADED' ? ( |
||||
<>Download Completed: building video...</> |
||||
) : resourceStatus?.status !== 'READY' ? ( |
||||
<> |
||||
{getDownloadProgress(resourceStatus?.localChunkCount,resourceStatus?.totalChunkCount)} |
||||
|
||||
</> |
||||
) : ( |
||||
<>Download Completed: fetching video...</> |
||||
)} |
||||
</Typography> |
||||
)} |
||||
</Box> |
||||
)} |
||||
{((!src && !isLoading) || !startPlay) && ( |
||||
<Box |
||||
position="absolute" |
||||
top={0} |
||||
left={0} |
||||
right={0} |
||||
bottom={0} |
||||
display="flex" |
||||
justifyContent="center" |
||||
alignItems="center" |
||||
zIndex={500} |
||||
bgcolor="rgba(0, 0, 0, 0.6)" |
||||
onClick={() => { |
||||
if (from === 'create') return |
||||
|
||||
togglePlay() |
||||
}} |
||||
sx={{ |
||||
cursor: 'pointer' |
||||
}} |
||||
> |
||||
<PlayArrow |
||||
sx={{ |
||||
width: '50px', |
||||
height: '50px', |
||||
color: 'white' |
||||
}} |
||||
/> |
||||
</Box> |
||||
)} |
||||
|
||||
<VideoElement |
||||
ref={videoRef} |
||||
src={!startPlay ? '' : resourceStatus?.status === 'READY' ? src : ''} |
||||
poster={poster} |
||||
onTimeUpdate={updateProgress} |
||||
autoPlay={autoplay} |
||||
onClick={togglePlay} |
||||
onEnded={handleEnded} |
||||
// onLoadedMetadata={handleLoadedMetadata}
|
||||
onCanPlay={handleCanPlay} |
||||
preload="metadata" |
||||
style={{ |
||||
...customStyle |
||||
}} |
||||
/> |
||||
|
||||
<ControlsContainer |
||||
style={{ |
||||
bottom: from === 'create' ? '15px' : 0 |
||||
}} |
||||
> |
||||
{isMobileView && canPlay ? ( |
||||
<> |
||||
<IconButton |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)' |
||||
}} |
||||
onClick={togglePlay} |
||||
> |
||||
{playing ? <Pause /> : <PlayArrow />} |
||||
</IconButton> |
||||
<IconButton |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)', |
||||
marginLeft: '15px' |
||||
}} |
||||
onClick={reloadVideo} |
||||
> |
||||
<Refresh /> |
||||
</IconButton> |
||||
<Slider |
||||
value={progress} |
||||
onChange={onProgressChange} |
||||
min={0} |
||||
max={videoRef.current?.duration || 100} |
||||
sx={{ flexGrow: 1, mx: 2 }} |
||||
/> |
||||
<IconButton |
||||
edge="end" |
||||
color="inherit" |
||||
aria-label="menu" |
||||
onClick={handleMenuOpen} |
||||
> |
||||
<MoreIcon /> |
||||
</IconButton> |
||||
<Menu |
||||
id="simple-menu" |
||||
anchorEl={anchorEl} |
||||
keepMounted |
||||
open={Boolean(anchorEl)} |
||||
onClose={handleMenuClose} |
||||
PaperProps={{ |
||||
style: { |
||||
width: '250px' |
||||
} |
||||
}} |
||||
> |
||||
<MenuItem> |
||||
<VolumeUp /> |
||||
<Slider |
||||
value={volume} |
||||
onChange={onVolumeChange} |
||||
min={0} |
||||
max={1} |
||||
step={0.01}/> |
||||
</MenuItem> |
||||
<MenuItem onClick={() => increaseSpeed()}> |
||||
<Typography |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)', |
||||
fontSize: '14px' |
||||
}} |
||||
> |
||||
Speed: {playbackRate}x |
||||
</Typography> |
||||
</MenuItem> |
||||
<MenuItem onClick={togglePictureInPicture}> |
||||
<PictureInPicture /> |
||||
</MenuItem> |
||||
<MenuItem onClick={toggleFullscreen}> |
||||
<Fullscreen /> |
||||
</MenuItem> |
||||
</Menu> |
||||
</> |
||||
) : canPlay ? ( |
||||
<> |
||||
<IconButton |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)' |
||||
}} |
||||
onClick={togglePlay} |
||||
> |
||||
{playing ? <Pause /> : <PlayArrow />} |
||||
</IconButton> |
||||
<IconButton |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)', |
||||
marginLeft: '15px' |
||||
}} |
||||
onClick={reloadVideo} |
||||
> |
||||
<Refresh /> |
||||
</IconButton> |
||||
<Slider |
||||
value={progress} |
||||
onChange={onProgressChange} |
||||
min={0} |
||||
max={videoRef.current?.duration || 100} |
||||
sx={{ flexGrow: 1, mx: 2 }} |
||||
/> |
||||
<Typography |
||||
sx={{ |
||||
fontSize: '14px', |
||||
marginRight: '5px', |
||||
color: 'rgba(255, 255, 255, 0.7)', |
||||
visibility: |
||||
!videoRef.current?.duration || !progress |
||||
? 'hidden' |
||||
: 'visible' |
||||
}} |
||||
> |
||||
{progress && videoRef.current?.duration && formatTime(progress)}/ |
||||
{progress && |
||||
videoRef.current?.duration && |
||||
formatTime(videoRef.current?.duration)} |
||||
</Typography> |
||||
<IconButton |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)', |
||||
marginRight: '10px' |
||||
}} |
||||
onClick={toggleMute} |
||||
> |
||||
{isMuted ? <VolumeOff/>:<VolumeUp/>} |
||||
</IconButton> |
||||
<Slider |
||||
value={volume} |
||||
onChange={onVolumeChange} |
||||
min={0} |
||||
max={1} |
||||
step={0.01} |
||||
/> |
||||
<IconButton |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)', |
||||
fontSize: '14px', |
||||
marginLeft: '5px' |
||||
}} |
||||
onClick={(e) => increaseSpeed()} |
||||
> |
||||
Speed: {playbackRate}x |
||||
</IconButton> |
||||
|
||||
<IconButton |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)', |
||||
marginLeft: '15px' |
||||
}} |
||||
ref={toggleRef} |
||||
onClick={togglePictureInPicture} |
||||
> |
||||
<PictureInPicture /> |
||||
</IconButton> |
||||
<IconButton |
||||
sx={{ |
||||
color: 'rgba(255, 255, 255, 0.7)' |
||||
}} |
||||
onClick={toggleFullscreen} |
||||
> |
||||
<Fullscreen /> |
||||
</IconButton> |
||||
</> |
||||
) : null} |
||||
</ControlsContainer> |
||||
{debug ? <span>{consoleLog}</span>: <></>} |
||||
</VideoContainer> |
||||
) |
||||
} |
@ -0,0 +1,287 @@
|
||||
import React, { useState } from 'react' |
||||
import { |
||||
Box, |
||||
Button, |
||||
Modal, |
||||
TextField, |
||||
Typography, |
||||
Select, |
||||
MenuItem, |
||||
FormControl, |
||||
InputLabel, |
||||
SelectChangeEvent, |
||||
OutlinedInput, |
||||
Chip, |
||||
IconButton |
||||
} from '@mui/material' |
||||
import { styled } from '@mui/system' |
||||
import { useDropzone } from 'react-dropzone' |
||||
import { usePublishVideo } from './PublishVideo' |
||||
import { toBase64 } from '../../utils/toBase64' |
||||
import AddIcon from '@mui/icons-material/Add' |
||||
import CloseIcon from '@mui/icons-material/Close' |
||||
const StyledModal = styled(Modal)(({ theme }) => ({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center' |
||||
})) |
||||
|
||||
const ChipContainer = styled(Box)({ |
||||
display: 'flex', |
||||
flexWrap: 'wrap', |
||||
'& > *': { |
||||
margin: '4px' |
||||
} |
||||
}) |
||||
|
||||
const ModalContent = styled(Box)(({ theme }) => ({ |
||||
backgroundColor: theme.palette.background.paper, |
||||
padding: theme.spacing(4), |
||||
borderRadius: theme.spacing(1), |
||||
width: '40%', |
||||
'&:focus': { |
||||
outline: 'none' |
||||
} |
||||
})) |
||||
|
||||
interface VideoModalProps { |
||||
open: boolean |
||||
onClose: () => void |
||||
onPublish: (value: any) => void |
||||
editVideoIdentifier?: string | null | undefined |
||||
} |
||||
|
||||
interface SelectOption { |
||||
id: string |
||||
name: string |
||||
} |
||||
|
||||
const VideoModal: React.FC<VideoModalProps> = ({ |
||||
open, |
||||
onClose, |
||||
onPublish, |
||||
editVideoIdentifier |
||||
}) => { |
||||
const [file, setFile] = useState<File | null>(null) |
||||
const [title, setTitle] = useState('') |
||||
const [description, setDescription] = useState('') |
||||
const [selectedOption, setSelectedOption] = useState<SelectOption | null>( |
||||
null |
||||
) |
||||
const [inputValue, setInputValue] = useState<string>('') |
||||
const [chips, setChips] = useState<string[]>([]) |
||||
|
||||
const [options, setOptions] = useState<SelectOption[]>([]) |
||||
const [tags, setTags] = useState<string[]>([]) |
||||
const { publishVideo } = usePublishVideo() |
||||
const { getRootProps, getInputProps } = useDropzone({ |
||||
accept: { |
||||
'video/*': [] |
||||
}, |
||||
maxFiles: 1, |
||||
onDrop: (acceptedFiles) => { |
||||
setFile(acceptedFiles[0]) |
||||
} |
||||
}) |
||||
|
||||
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
||||
setTitle(event.target.value) |
||||
} |
||||
|
||||
const handleDescriptionChange = ( |
||||
event: React.ChangeEvent<HTMLInputElement> |
||||
) => { |
||||
setDescription(event.target.value) |
||||
} |
||||
|
||||
const handleOptionChange = (event: SelectChangeEvent<string>) => { |
||||
const optionId = event.target.value |
||||
const selectedOption = options.find((option) => option.id === optionId) |
||||
setSelectedOption(selectedOption || null) |
||||
} |
||||
|
||||
const handleChipDelete = (index: number) => { |
||||
const newChips = [...chips] |
||||
newChips.splice(index, 1) |
||||
setChips(newChips) |
||||
} |
||||
|
||||
const handleSubmit = async () => { |
||||
const missingFields = [] |
||||
|
||||
if (!title) missingFields.push('title') |
||||
if (!file) missingFields.push('file') |
||||
if (missingFields.length > 0) { |
||||
const missingFieldsString = missingFields.join(', ') |
||||
const errMsg = `Missing: ${missingFieldsString}` |
||||
|
||||
return |
||||
} |
||||
if (!file) return |
||||
|
||||
const formattedTags: { [key: string]: string } = {} |
||||
chips.forEach((tag, i) => { |
||||
formattedTags[`tag${i + 1}`] = tag |
||||
}) |
||||
|
||||
try { |
||||
// const base64 = await toBase64(file)
|
||||
// if (typeof base64 !== 'string') return
|
||||
// const base64String = base64.split(',')[1]
|
||||
// if (!file) return
|
||||
|
||||
const res = await publishVideo({ |
||||
file: file, |
||||
editVideoIdentifier, |
||||
title, |
||||
description, |
||||
category: selectedOption?.id || '', |
||||
...formattedTags |
||||
}) |
||||
onPublish(res) |
||||
setFile(null) |
||||
setTitle('') |
||||
setDescription('') |
||||
onClose() |
||||
} catch (error) {} |
||||
} |
||||
|
||||
const handleInputChange = (event: any) => { |
||||
setInputValue(event.target.value) |
||||
} |
||||
|
||||
const handleInputKeyDown = (event: any) => { |
||||
if (event.key === 'Enter' && inputValue !== '') { |
||||
if (chips.length < 5) { |
||||
setChips([...chips, inputValue]) |
||||
setInputValue('') |
||||
} else { |
||||
event.preventDefault() |
||||
} |
||||
} |
||||
} |
||||
|
||||
const addChip = () => { |
||||
if (chips.length < 5) { |
||||
setChips([...chips, inputValue]) |
||||
setInputValue('') |
||||
} |
||||
} |
||||
|
||||
const getListCategories = React.useCallback(async () => { |
||||
try { |
||||
const url = `/arbitrary/categories` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
setOptions(responseData) |
||||
} catch (error) {} |
||||
}, []) |
||||
|
||||
React.useEffect(() => { |
||||
getListCategories() |
||||
}, [getListCategories]) |
||||
|
||||
return ( |
||||
<StyledModal open={open} onClose={onClose}> |
||||
<ModalContent> |
||||
{editVideoIdentifier && ( |
||||
<Typography variant="h6"> |
||||
You are editing: {editVideoIdentifier} |
||||
</Typography> |
||||
)} |
||||
<Typography variant="h6" component="h2" gutterBottom> |
||||
Upload Video |
||||
</Typography> |
||||
<Box |
||||
{...getRootProps()} |
||||
sx={{ |
||||
border: '1px dashed gray', |
||||
padding: 2, |
||||
textAlign: 'center', |
||||
marginBottom: 2 |
||||
}} |
||||
> |
||||
<input {...getInputProps()} /> |
||||
<Typography> |
||||
{file |
||||
? file.name |
||||
: 'Drag and drop a video file here or click to select a file'} |
||||
</Typography> |
||||
</Box> |
||||
<TextField |
||||
label="Video Title" |
||||
variant="outlined" |
||||
fullWidth |
||||
value={title} |
||||
onChange={handleTitleChange} |
||||
inputProps={{ maxLength: 40 }} |
||||
sx={{ marginBottom: 2 }} |
||||
/> |
||||
<TextField |
||||
label="Video Description" |
||||
variant="outlined" |
||||
fullWidth |
||||
multiline |
||||
rows={4} |
||||
value={description} |
||||
onChange={handleDescriptionChange} |
||||
inputProps={{ maxLength: 180 }} |
||||
sx={{ marginBottom: 2 }} |
||||
/> |
||||
{options.length > 0 && ( |
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||
<InputLabel id="Category">Select a Category</InputLabel> |
||||
<Select |
||||
labelId="Category" |
||||
input={<OutlinedInput label="Select a Category" />} |
||||
value={selectedOption?.id || ''} |
||||
onChange={handleOptionChange} |
||||
> |
||||
{options.map((option) => ( |
||||
<MenuItem key={option.id} value={option.id}> |
||||
{option.name} |
||||
</MenuItem> |
||||
))} |
||||
</Select> |
||||
</FormControl> |
||||
)} |
||||
|
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||
<TextField |
||||
label="Add a tag" |
||||
value={inputValue} |
||||
onChange={handleInputChange} |
||||
onKeyDown={handleInputKeyDown} |
||||
disabled={chips.length === 3} |
||||
/> |
||||
|
||||
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||
<AddIcon /> |
||||
</IconButton> |
||||
</Box> |
||||
<ChipContainer> |
||||
{chips.map((chip, index) => ( |
||||
<Chip |
||||
key={index} |
||||
label={chip} |
||||
onDelete={() => handleChipDelete(index)} |
||||
deleteIcon={<CloseIcon />} |
||||
/> |
||||
))} |
||||
</ChipContainer> |
||||
</FormControl> |
||||
<Button variant="contained" color="primary" onClick={handleSubmit}> |
||||
Submit |
||||
</Button> |
||||
</ModalContent> |
||||
</StyledModal> |
||||
) |
||||
} |
||||
|
||||
export default VideoModal |
@ -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,574 @@
|
||||
// src/components/BlogEditor.tsx
|
||||
// @ts-nocheck
|
||||
|
||||
import React, { useMemo, useState, useCallback } from 'react'; |
||||
import { createEditor, Descendant, Editor, Transforms, Range } from 'slate' |
||||
import SvgIcon from '@material-ui/core/SvgIcon' |
||||
import { |
||||
Slate, |
||||
Editable, |
||||
withReact, |
||||
RenderElementProps, |
||||
RenderLeafProps, |
||||
useSlate |
||||
} from 'slate-react' |
||||
import { styled } from '@mui/system' |
||||
import { CustomElement, CustomText, FormatMark } from './customTypes' |
||||
import './BlogEditor.css' |
||||
import { Modal, Box, TextField, Button } from '@mui/material' |
||||
|
||||
import { AlignCenterSVG } from '../../assets/svgs/AlignCenterSVG' |
||||
import { BoldSVG } from '../../assets/svgs/BoldSVG' |
||||
import { ItalicSVG } from '../../assets/svgs/ItalicSVG' |
||||
import { UnderlineSVG } from '../../assets/svgs/UnderlineSVG' |
||||
import { H2SVG } from '../../assets/svgs/H2SVG' |
||||
import { H3SVG } from '../../assets/svgs/H3SVG' |
||||
import { AlignLeftSVG } from '../../assets/svgs/AlignLeftSVG' |
||||
import { AlignRightSVG } from '../../assets/svgs/AlignRightSVG' |
||||
import { CodeBlockSVG } from '../../assets/svgs/CodeBlockSVG' |
||||
import { LinkSVG } from '../../assets/svgs/LinkSVG' |
||||
|
||||
const initialValue: Descendant[] = [ |
||||
{ |
||||
type: 'paragraph', |
||||
children: [{ text: 'Start writing your blog post...' }] |
||||
} |
||||
] |
||||
|
||||
interface MyComponentProps { |
||||
addPostSection?: (value: any) => void |
||||
editPostSection?: (value: any, section: any) => void |
||||
defaultValue?: any |
||||
section?: any |
||||
value: any |
||||
setValue: (value: any) => void |
||||
editorKey?: number |
||||
mode?: string |
||||
} |
||||
|
||||
const ModalBox = styled(Box)(({ theme }) => ({ |
||||
position: 'absolute', |
||||
top: '50%', |
||||
left: '50%', |
||||
transform: 'translate(-50%, -50%)', |
||||
backgroundColor: theme.palette.background.paper, |
||||
boxShadow: theme.shadows[5], |
||||
padding: theme.spacing(2, 4, 3), |
||||
gap: '15px', |
||||
borderRadius: '5px', |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
flex: 0 |
||||
})) |
||||
|
||||
const BlogEditor: React.FC<MyComponentProps> = ({ |
||||
addPostSection, |
||||
editPostSection, |
||||
defaultValue, |
||||
section, |
||||
value, |
||||
setValue, |
||||
editorKey, |
||||
mode |
||||
}) => { |
||||
const editor = useMemo(() => withReact(createEditor()), []) |
||||
|
||||
// const [value, setValue] = useState(defaultValue || initialValue);
|
||||
const isTextAlignmentActive = (editor: Editor, alignment: string) => { |
||||
const [match] = Editor.nodes(editor, { |
||||
match: (n) => { |
||||
return n?.textAlign === alignment?.replace(/^align-/, '') |
||||
} |
||||
}) |
||||
return !!match |
||||
} |
||||
|
||||
const toggleTextAlignment = (editor: Editor, alignment: string) => { |
||||
const isActive = isTextAlignmentActive(editor, alignment) |
||||
Transforms.setNodes( |
||||
editor, |
||||
{ style: { textAlign: isActive ? 'inherit' : alignment } }, |
||||
{ match: (n) => Editor.isBlock(editor, n) } |
||||
) |
||||
} |
||||
|
||||
const toggleMark = (editor: Editor, format: FormatMark) => { |
||||
if ( |
||||
format === 'align-left' || |
||||
format === 'align-center' || |
||||
format === 'align-right' |
||||
) { |
||||
toggleTextAlignment(editor, format) |
||||
} else { |
||||
const isActive = Editor?.marks(editor)?.[format] === true |
||||
if (isActive) { |
||||
Editor?.removeMark(editor, format) |
||||
} else { |
||||
Editor?.addMark(editor, format, true) |
||||
} |
||||
} |
||||
} |
||||
|
||||
const newValue = useMemo(() => [...(value || initialValue)], [value]) |
||||
|
||||
const types = ['paragraph', 'heading-2', 'heading-3'] |
||||
|
||||
const setTextAlignment = (editor, alignment) => { |
||||
const isActive = isTextAlignmentActive(editor, alignment) |
||||
const alignmentType = '' |
||||
Transforms?.setNodes( |
||||
editor, |
||||
{ |
||||
textAlign: isActive ? null : alignment |
||||
}, |
||||
{ |
||||
match: (n) => |
||||
n.type === 'heading-2' || |
||||
n.type === 'heading-3' || |
||||
n.type === 'paragraph' |
||||
} |
||||
) |
||||
} |
||||
|
||||
const ToolbarButton: React.FC<{ |
||||
format: FormatMark | string |
||||
label: string |
||||
editor: Editor |
||||
children: React.ReactNode |
||||
}> = ({ format, label, editor, children }) => { |
||||
useSlate() |
||||
|
||||
let onClick = () => { |
||||
if (format === 'heading-2' || format === 'heading-3') { |
||||
toggleBlock(editor, format) |
||||
} else if ( |
||||
format === 'bold' || |
||||
format === 'italic' || |
||||
format === 'underline' || |
||||
format === '' |
||||
) { |
||||
toggleMark(editor, format) |
||||
} else if ( |
||||
format === 'align-left' || |
||||
format === 'align-center' || |
||||
format === 'align-right' |
||||
) { |
||||
setTextAlignment(editor, format?.replace(/^align-/, '')) |
||||
} |
||||
} |
||||
|
||||
let isActive = false |
||||
|
||||
try { |
||||
if ( |
||||
format === 'align-left' || |
||||
format === 'align-center' || |
||||
format === 'align-right' |
||||
) { |
||||
isActive = isTextAlignmentActive(editor, format) |
||||
} else if (format === 'heading-2' || format === 'heading-3') { |
||||
isActive = isBlockActive(editor, format) |
||||
} else if ( |
||||
format === 'bold' || |
||||
format === 'italic' || |
||||
format === 'underline' || |
||||
format === '' |
||||
) { |
||||
isActive = Editor?.marks(editor)?.[format] === true |
||||
} |
||||
} catch (error) {} |
||||
|
||||
return ( |
||||
<button |
||||
className={`toolbar-button ${isActive ? 'active' : ''}`} |
||||
onMouseDown={(event) => { |
||||
event.preventDefault() |
||||
onClick() |
||||
}} |
||||
> |
||||
{children ? children : label} |
||||
</button> |
||||
) |
||||
} |
||||
|
||||
const ToolbarButtonCodeBlock: React.FC<{ |
||||
format: FormatMark | string |
||||
label: string |
||||
editor: Editor |
||||
children: React.ReactNode |
||||
}> = ({ format, label, editor, children }) => { |
||||
const editor2 = useSlate() |
||||
|
||||
let onClick = () => { |
||||
if (format === 'code-block') { |
||||
toggleBlock(editor, 'code-block') |
||||
} |
||||
} |
||||
let isActive = false |
||||
try { |
||||
if (format === 'code-block') { |
||||
isActive = isBlockActive(editor, format) |
||||
} |
||||
} catch (error) {} |
||||
|
||||
return ( |
||||
<button |
||||
className={`toolbar-button ${isActive ? 'active' : ''}`} |
||||
onMouseDown={(event) => { |
||||
event.preventDefault() |
||||
onClick() |
||||
}} |
||||
> |
||||
{children ? children : label} |
||||
</button> |
||||
) |
||||
} |
||||
|
||||
const ToolbarButtonAlign: React.FC<{ |
||||
format: string |
||||
label: string |
||||
editor: Editor |
||||
}> = ({ format, label, editor }) => { |
||||
const isActive = |
||||
Editor?.nodes(editor, { |
||||
match: (n) => n?.align === format |
||||
})?.length > 0 |
||||
|
||||
return ( |
||||
<button |
||||
className={`toolbar-button ${isActive ? 'active' : ''}`} |
||||
onMouseDown={(event) => { |
||||
event.preventDefault() |
||||
Transforms?.setNodes( |
||||
editor, |
||||
{ align: format }, |
||||
{ match: (n) => Editor?.isBlock(editor, n) } |
||||
) |
||||
}} |
||||
> |
||||
{label} |
||||
</button> |
||||
) |
||||
} |
||||
|
||||
const ToolbarButtonCodeLink: React.FC<{ |
||||
format: FormatMark | string |
||||
label: string |
||||
editor: Editor |
||||
children: React.ReactNode |
||||
}> = ({ format, label, editor, children }) => { |
||||
useSlate() |
||||
|
||||
let isActive = false |
||||
try { |
||||
if (format === 'link') { |
||||
isActive = !!Editor?.marks(editor)?.link |
||||
} |
||||
} catch (error) {} |
||||
|
||||
return ( |
||||
<button |
||||
className={`toolbar-button ${isActive ? 'active' : ''}`} |
||||
onMouseDown={(event) => { |
||||
event.preventDefault() |
||||
const isActive2 = !!Editor?.marks(editor)?.link |
||||
if (isActive2) { |
||||
Editor?.removeMark(editor, 'link') |
||||
return |
||||
} |
||||
// const url = window.prompt('Enter the URL of the link:')
|
||||
setOpen(true) |
||||
}} |
||||
> |
||||
{children ? children : label} |
||||
</button> |
||||
) |
||||
} |
||||
|
||||
// Create a toggleBlock function and an isBlockActive function to handle block elements
|
||||
const toggleBlock = (editor: Editor, format: string) => { |
||||
const isActive = isBlockActive(editor, format) |
||||
Transforms?.unwrapNodes(editor, { |
||||
match: (n) => Editor?.isBlock(editor, n), |
||||
split: true |
||||
}) |
||||
|
||||
if (isActive) { |
||||
Transforms?.setNodes(editor, { type: 'paragraph' }) |
||||
} else { |
||||
Transforms?.setNodes(editor, { type: format }) |
||||
} |
||||
} |
||||
|
||||
const isBlockActive = (editor: Editor, format: string) => { |
||||
const [match] = Editor?.nodes(editor, { |
||||
match: (n) => n?.type === format |
||||
}) |
||||
return !!match |
||||
} |
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => { |
||||
if (event.key === 'Enter' && isBlockActive(editor, 'code-block')) { |
||||
event.preventDefault() |
||||
editor?.insertText('\n') |
||||
} |
||||
|
||||
if (event.key === 'ArrowDown' && isBlockActive(editor, 'code-block')) { |
||||
event.preventDefault() |
||||
Transforms?.insertNodes(editor, { |
||||
type: 'paragraph', |
||||
children: [{ text: '' }] |
||||
}) |
||||
} |
||||
} |
||||
|
||||
const handleChange = (newValue: Descendant[]) => { |
||||
setValue(newValue) |
||||
} |
||||
|
||||
const toggleLink = (editor: Editor, url: string) => { |
||||
const { selection } = editor |
||||
|
||||
if (selection && !Range.isCollapsed(selection)) { |
||||
const isLink = Editor?.marks(editor)?.link === true |
||||
const isInsideLink = isLinkActive(editor) |
||||
|
||||
if (isLink) { |
||||
Editor?.removeMark(editor, 'link') |
||||
} else if (url) { |
||||
Editor?.addMark(editor, 'link', url) |
||||
} |
||||
} |
||||
} |
||||
|
||||
const [open, setOpen] = useState(false) |
||||
|
||||
const initialValue = 'qortal://' |
||||
const [inputValue, setInputValue] = useState(initialValue) |
||||
|
||||
const handleChangeLink = (event) => { |
||||
const newValue = event?.target?.value |
||||
if (newValue?.startsWith(initialValue)) { |
||||
setInputValue(newValue) |
||||
} |
||||
} |
||||
const isLinkActive = (editor: Editor) => { |
||||
const [link] = Editor?.nodes(editor, { |
||||
match: (n) => n?.type === 'link' |
||||
}) |
||||
return !!link |
||||
} |
||||
const handleSaveClick = () => { |
||||
const marks = Editor?.marks(editor) |
||||
const isLink = marks?.link === true |
||||
|
||||
if (isLink) { |
||||
Editor?.removeMark(editor, 'link') |
||||
return // Return early to skip the rest of the function
|
||||
} |
||||
toggleLink(editor, inputValue) |
||||
setOpen(false) |
||||
} |
||||
|
||||
const onClose = () => { |
||||
setOpen(false) |
||||
} |
||||
|
||||
const handlePaste = (event: React.ClipboardEvent) => { |
||||
event.preventDefault() |
||||
const text = event?.clipboardData?.getData('text/plain') |
||||
const isCodeBlock = isBlockActive(editor, 'code-block') |
||||
|
||||
if (isCodeBlock) { |
||||
const lines = text?.split('\n') |
||||
const fragment: Descendant[] = [ |
||||
{ |
||||
type: 'code-block', |
||||
children: lines?.map((line) => ({ |
||||
type: 'code-line', |
||||
children: [{ text: line }] |
||||
})) |
||||
} |
||||
] |
||||
|
||||
Transforms?.insertFragment(editor, fragment) |
||||
} else if (text) { |
||||
const fragment = text?.split('\n').map((line) => ({ |
||||
type: 'paragraph', |
||||
children: [{ text: line }] |
||||
})) |
||||
|
||||
Transforms?.insertFragment(editor, fragment) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
width: '100%', |
||||
border: '1px solid', |
||||
borderRadius: '5px', |
||||
marginTop: '20px', |
||||
padding: '10px' |
||||
}} |
||||
> |
||||
<Slate |
||||
editor={editor} |
||||
value={newValue} |
||||
onChange={(newValue) => handleChange(newValue)} |
||||
key={editorKey || 1} |
||||
> |
||||
<div className="toolbar"> |
||||
<ToolbarButton format="bold" label="B" editor={editor}> |
||||
<BoldSVG height="24px" width="auto" /> |
||||
</ToolbarButton> |
||||
<ToolbarButton format="italic" label="I" editor={editor}> |
||||
<ItalicSVG height="24px" width="auto" /> |
||||
</ToolbarButton> |
||||
<ToolbarButton format="underline" label="U" editor={editor}> |
||||
<UnderlineSVG height="24px" width="auto" /> |
||||
</ToolbarButton> |
||||
|
||||
<ToolbarButton format="heading-2" label="H2" editor={editor}> |
||||
<H2SVG height="24px" width="auto" /> |
||||
</ToolbarButton> |
||||
<ToolbarButton format="heading-3" label="H3" editor={editor}> |
||||
<H3SVG height="24px" width="auto" /> |
||||
</ToolbarButton> |
||||
<ToolbarButton format="align-left" label="L" editor={editor}> |
||||
<AlignLeftSVG height="24px" width="auto" /> |
||||
</ToolbarButton> |
||||
<ToolbarButton format="align-center" label="C" editor={editor}> |
||||
<AlignCenterSVG height="24px" width="auto" /> |
||||
</ToolbarButton> |
||||
<ToolbarButton format="align-right" label="R" editor={editor}> |
||||
<AlignRightSVG height="24px" width="auto" /> |
||||
</ToolbarButton> |
||||
|
||||
<ToolbarButtonCodeBlock |
||||
format="code-block" |
||||
label="Code" |
||||
editor={editor} |
||||
> |
||||
<CodeBlockSVG height="24px" width="auto" /> |
||||
</ToolbarButtonCodeBlock> |
||||
<ToolbarButtonCodeLink format="link" label="Link" editor={editor}> |
||||
<LinkSVG height="24px" width="auto" /> |
||||
</ToolbarButtonCodeLink> |
||||
</div> |
||||
<Editable |
||||
className="blog-editor" |
||||
renderElement={(props) => renderElement({ ...props, mode })} |
||||
renderLeaf={renderLeaf} |
||||
onKeyDown={handleKeyDown} |
||||
onPaste={handlePaste} |
||||
mode={mode} |
||||
/> |
||||
</Slate> |
||||
<Modal open={open} onClose={onClose}> |
||||
<ModalBox> |
||||
<TextField |
||||
label="Link" |
||||
value={inputValue} |
||||
onChange={handleChangeLink} |
||||
/> |
||||
<Button variant="contained" onClick={handleSaveClick}> |
||||
Save |
||||
</Button> |
||||
</ModalBox> |
||||
</Modal> |
||||
{editPostSection && ( |
||||
<Button onClick={() => editPostSection(value, section)}> |
||||
Edit Section |
||||
</Button> |
||||
)} |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
export default BlogEditor |
||||
|
||||
type ExtendedRenderElementProps = RenderElementProps & { mode?: string } |
||||
|
||||
export const renderElement = ({ |
||||
attributes, |
||||
children, |
||||
element, |
||||
mode |
||||
}: ExtendedRenderElementProps) => { |
||||
switch (element.type) { |
||||
case 'block-quote': |
||||
return <blockquote {...attributes}>{children}</blockquote> |
||||
case 'heading-2': |
||||
return ( |
||||
<h2 |
||||
className="h2" |
||||
{...attributes} |
||||
style={{ textAlign: element.textAlign }} |
||||
> |
||||
{children} |
||||
</h2> |
||||
) |
||||
case 'heading-3': |
||||
return ( |
||||
<h3 |
||||
className="h3" |
||||
{...attributes} |
||||
style={{ textAlign: element.textAlign }} |
||||
> |
||||
{children} |
||||
</h3> |
||||
) |
||||
case 'code-block': |
||||
return ( |
||||
<pre {...attributes} className="code-block"> |
||||
<code>{children}</code> |
||||
</pre> |
||||
) |
||||
case 'code-line': |
||||
return <div {...attributes}>{children}</div> |
||||
case 'link': |
||||
return ( |
||||
<a href={element.url} {...attributes}> |
||||
{children} |
||||
</a> |
||||
) |
||||
default: |
||||
return ( |
||||
<p |
||||
className={`paragraph${mode ? `-${mode}` : ''}`} |
||||
{...attributes} |
||||
style={{ textAlign: element.textAlign }} |
||||
> |
||||
{children} |
||||
</p> |
||||
) |
||||
} |
||||
} |
||||
|
||||
|
||||
export const renderLeaf = ({ attributes, children, leaf }: RenderLeafProps) => { |
||||
let el = children |
||||
|
||||
if (leaf.bold) { |
||||
el = <strong>{el}</strong> |
||||
} |
||||
|
||||
if (leaf.italic) { |
||||
el = <em>{el}</em> |
||||
} |
||||
|
||||
if (leaf.underline) { |
||||
el = <u>{el}</u> |
||||
} |
||||
|
||||
if (leaf.link) { |
||||
el = ( |
||||
<a href={leaf.link} {...attributes}> |
||||
{el} |
||||
</a> |
||||
) |
||||
} |
||||
|
||||
return <span {...attributes}>{el}</span> |
||||
} |
@ -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,490 @@
|
||||
import React, { useMemo, useRef, useState } from 'react' |
||||
import { |
||||
Typography, |
||||
Box, |
||||
Popover, |
||||
useTheme, |
||||
Button, |
||||
Input, |
||||
List, |
||||
ListItem, |
||||
ListItemText |
||||
} from '@mui/material' |
||||
import AccountCircle from '@mui/icons-material/AccountCircle' |
||||
import AddBoxIcon from '@mui/icons-material/AddBox' |
||||
import Badge from '@mui/material/Badge' |
||||
import NotificationsIcon from '@mui/icons-material/Notifications' |
||||
import ExitToAppIcon from '@mui/icons-material/ExitToApp' |
||||
import { useNavigate } from 'react-router-dom' |
||||
import { togglePublishBlogModal } from '../../../state/features/globalSlice' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import AutoStoriesIcon from '@mui/icons-material/AutoStories' |
||||
import { RootState } from '../../../state/store' |
||||
import { UserNavbar } from '../../common/UserNavbar/UserNavbar' |
||||
import { removePrefix } from '../../../utils/blogIdformats' |
||||
import { useLocation } from 'react-router-dom' |
||||
import BookmarkIcon from '@mui/icons-material/Bookmark' |
||||
import SubscriptionsIcon from '@mui/icons-material/Subscriptions' |
||||
import { BlockedNamesModal } from '../../common/BlockedNamesModal/BlockedNamesModal' |
||||
import SearchIcon from '@mui/icons-material/Search' |
||||
import EmailIcon from '@mui/icons-material/Email' |
||||
import localforage from 'localforage' |
||||
const notification = localforage.createInstance({ |
||||
name: 'notification' |
||||
}) |
||||
|
||||
import BackspaceIcon from '@mui/icons-material/Backspace' |
||||
import { |
||||
AvatarContainer, |
||||
CreateBlogButton, |
||||
CustomAppBar, |
||||
CustomToolbar, |
||||
DropdownContainer, |
||||
DropdownText, |
||||
QblogLogoContainer, |
||||
StyledButton, |
||||
AuthenticateButton, |
||||
NavbarName |
||||
} from './Navbar-styles' |
||||
import { AccountCircleSVG } from '../../../assets/svgs/AccountCircleSVG' |
||||
import QblogLogo from '../../../assets/img/qBlogLogo.png' |
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' |
||||
import PersonOffIcon from '@mui/icons-material/PersonOff' |
||||
import { NewWindowSVG } from '../../../assets/svgs/NewWindowSVG' |
||||
import { |
||||
addFilteredPosts, |
||||
setFilterValue, |
||||
setIsFiltering |
||||
} from '../../../state/features/blogSlice' |
||||
import { Item } from '../../common/Comments/CommentEditor' |
||||
import { formatDate } from '../../../utils/time' |
||||
interface Props { |
||||
isAuthenticated: boolean |
||||
hasBlog: boolean |
||||
userName: string | null |
||||
userAvatar: string |
||||
blog: any |
||||
authenticate: () => void |
||||
hasAttemptedToFetchBlogInitial: boolean |
||||
} |
||||
|
||||
function useQuery() { |
||||
return new URLSearchParams(useLocation().search) |
||||
} |
||||
|
||||
const NavBar: React.FC<Props> = ({ |
||||
isAuthenticated, |
||||
hasBlog, |
||||
userName, |
||||
userAvatar, |
||||
blog, |
||||
authenticate, |
||||
hasAttemptedToFetchBlogInitial |
||||
}) => { |
||||
const navigate = useNavigate() |
||||
const dispatch = useDispatch() |
||||
const theme = useTheme() |
||||
const query = useQuery() |
||||
const { visitingBlog } = useSelector((state: RootState) => state.global) |
||||
const notifications = useSelector( |
||||
(state: RootState) => state.global.notifications |
||||
) |
||||
const notificationCreatorComment = useSelector( |
||||
(state: RootState) => state.global.notificationCreatorComment |
||||
) |
||||
|
||||
const fullNotifications = useMemo(() => { |
||||
return [...notificationCreatorComment, ...notifications].sort( |
||||
(a, b) => b.created - a.created |
||||
) |
||||
}, [notificationCreatorComment, notifications]) |
||||
const location = useLocation() |
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) |
||||
const [anchorElNotification, setAnchorElNotification] = |
||||
React.useState<HTMLButtonElement | null>(null) |
||||
const [isOpenModal, setIsOpenModal] = React.useState<boolean>(false) |
||||
const [searchVal, setSearchVal] = useState<string>('') |
||||
const searchValRef = useRef('') |
||||
const inputRef = useRef<HTMLInputElement>(null) |
||||
const stripBlogId = removePrefix(visitingBlog?.blogId || '') |
||||
if (visitingBlog?.navbarConfig && location?.pathname?.includes(stripBlogId)) { |
||||
return ( |
||||
<UserNavbar |
||||
title={visitingBlog?.title || ''} |
||||
menuItems={visitingBlog?.navbarConfig?.navItems || []} |
||||
name={visitingBlog?.name || ''} |
||||
blogId={visitingBlog?.blogId || ''} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { |
||||
const target = event.currentTarget as unknown as HTMLButtonElement | null |
||||
setAnchorEl(target) |
||||
} |
||||
const openNotificationPopover = (event: any) => { |
||||
const target = event.currentTarget as unknown as HTMLButtonElement | null |
||||
setAnchorElNotification(target) |
||||
} |
||||
const closeNotificationPopover = () => { |
||||
setAnchorElNotification(null) |
||||
} |
||||
|
||||
const handleClose = () => { |
||||
setAnchorEl(null) |
||||
} |
||||
const onClose = () => { |
||||
setIsOpenModal(false) |
||||
} |
||||
const open = Boolean(anchorEl) |
||||
const id = open ? 'simple-popover' : undefined |
||||
const openPopover = Boolean(anchorElNotification) |
||||
const idNotification = openPopover ? 'simple-popover-notification' : undefined |
||||
|
||||
return ( |
||||
<CustomAppBar position="sticky" elevation={2}> |
||||
<CustomToolbar variant="dense"> |
||||
<QblogLogoContainer |
||||
src={QblogLogo} |
||||
alt="Qblog Logo" |
||||
onClick={() => { |
||||
navigate(`/`) |
||||
dispatch(setIsFiltering(false)) |
||||
dispatch(setFilterValue('')) |
||||
dispatch(addFilteredPosts([])) |
||||
searchValRef.current = '' |
||||
if (!inputRef.current) return |
||||
inputRef.current.value = '' |
||||
}} |
||||
/> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
gap: 1 |
||||
}} |
||||
> |
||||
<Input |
||||
id="standard-adornment-name" |
||||
inputRef={inputRef} |
||||
onChange={(e) => { |
||||
searchValRef.current = e.target.value |
||||
}} |
||||
onKeyDown={(event) => { |
||||
if (event.key === 'Enter' || event.keyCode === 13) { |
||||
if (!searchValRef.current) { |
||||
dispatch(setIsFiltering(false)) |
||||
dispatch(setFilterValue('')) |
||||
dispatch(addFilteredPosts([])) |
||||
searchValRef.current = '' |
||||
if (!inputRef.current) return |
||||
inputRef.current.value = '' |
||||
return |
||||
} |
||||
navigate('/') |
||||
dispatch(setIsFiltering(true)) |
||||
dispatch(addFilteredPosts([])) |
||||
dispatch(setFilterValue(searchValRef.current)) |
||||
} |
||||
}} |
||||
placeholder="Filter by name" |
||||
sx={{ |
||||
'&&:before': { |
||||
borderBottom: 'none' |
||||
}, |
||||
'&&:after': { |
||||
borderBottom: 'none' |
||||
}, |
||||
'&&:hover:before': { |
||||
borderBottom: 'none' |
||||
}, |
||||
'&&.Mui-focused:before': { |
||||
borderBottom: 'none' |
||||
}, |
||||
'&&.Mui-focused': { |
||||
outline: 'none' |
||||
}, |
||||
fontSize: '18px' |
||||
}} |
||||
/> |
||||
|
||||
<SearchIcon |
||||
sx={{ |
||||
cursor: 'pointer' |
||||
}} |
||||
onClick={() => { |
||||
if (!searchValRef.current) { |
||||
dispatch(setIsFiltering(false)) |
||||
dispatch(setFilterValue('')) |
||||
dispatch(addFilteredPosts([])) |
||||
searchValRef.current = '' |
||||
if (!inputRef.current) return |
||||
inputRef.current.value = '' |
||||
return |
||||
} |
||||
navigate('/') |
||||
dispatch(setIsFiltering(true)) |
||||
dispatch(addFilteredPosts([])) |
||||
dispatch(setFilterValue(searchValRef.current)) |
||||
}} |
||||
/> |
||||
<BackspaceIcon |
||||
sx={{ |
||||
cursor: 'pointer' |
||||
}} |
||||
onClick={() => { |
||||
dispatch(setIsFiltering(false)) |
||||
dispatch(setFilterValue('')) |
||||
dispatch(addFilteredPosts([])) |
||||
searchValRef.current = '' |
||||
if (!inputRef.current) return |
||||
inputRef.current.value = '' |
||||
}} |
||||
/> |
||||
</Box> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center' |
||||
}} |
||||
> |
||||
{/* Add isAuthenticated && before username and wrap StyledButton in this condition*/} |
||||
{!isAuthenticated && ( |
||||
<AuthenticateButton onClick={authenticate}> |
||||
<ExitToAppIcon /> |
||||
Authenticate |
||||
</AuthenticateButton> |
||||
)} |
||||
<Badge |
||||
badgeContent={fullNotifications.length} |
||||
color="primary" |
||||
sx={{ |
||||
margin: '0px 12px' |
||||
}} |
||||
> |
||||
<Button |
||||
onClick={(e) => { |
||||
openNotificationPopover(e) |
||||
}} |
||||
sx={{ |
||||
margin: '0px', |
||||
padding: '0px', |
||||
height: 'auto', |
||||
width: 'auto', |
||||
minWidth: 'unset' |
||||
}} |
||||
> |
||||
<NotificationsIcon color="action" /> |
||||
</Button> |
||||
</Badge> |
||||
<Popover |
||||
id={idNotification} |
||||
open={openPopover} |
||||
anchorEl={anchorElNotification} |
||||
onClose={closeNotificationPopover} |
||||
anchorOrigin={{ |
||||
vertical: 'bottom', |
||||
horizontal: 'left' |
||||
}} |
||||
> |
||||
<Box> |
||||
<List |
||||
sx={{ |
||||
maxHeight: '300px', |
||||
overflow: 'auto' |
||||
}} |
||||
> |
||||
{fullNotifications.map((notification: any, index: number) => ( |
||||
<ListItem |
||||
key={index} |
||||
divider |
||||
sx={{ |
||||
cursor: 'pointer' |
||||
}} |
||||
onClick={async () => { |
||||
const str = notification.postId |
||||
const arr = str.split('-post-') |
||||
const str1 = arr[0] |
||||
const str2 = arr[1] |
||||
const blogId = removePrefix(str1) |
||||
navigate( |
||||
`/${notification.postName}/${blogId}/${str2}?comment=${notification.identifier}` |
||||
) |
||||
}} |
||||
> |
||||
<ListItemText |
||||
primary={ |
||||
<React.Fragment> |
||||
<Typography |
||||
component="span" |
||||
variant="body1" |
||||
color="textPrimary" |
||||
> |
||||
From {notification.name} |
||||
</Typography> |
||||
</React.Fragment> |
||||
} |
||||
secondary={ |
||||
<React.Fragment> |
||||
<Typography |
||||
component="span" |
||||
variant="body2" |
||||
color="textSecondary" |
||||
> |
||||
{formatDate(notification.created)} |
||||
</Typography> |
||||
<Typography |
||||
component="span" |
||||
variant="body2" |
||||
color="textSecondary" |
||||
> |
||||
{' -comment'} |
||||
</Typography> |
||||
</React.Fragment> |
||||
} |
||||
/> |
||||
</ListItem> |
||||
))} |
||||
</List> |
||||
</Box> |
||||
</Popover> |
||||
{/* <button |
||||
onClick={async () => { |
||||
await qortalRequest({ |
||||
action: 'SET_TAB_NOTIFICATIONS', |
||||
count: 2 |
||||
}) |
||||
}} |
||||
> |
||||
add notification |
||||
</button> */} |
||||
{isAuthenticated && |
||||
userName && |
||||
hasAttemptedToFetchBlogInitial && |
||||
!hasBlog && ( |
||||
<CreateBlogButton |
||||
onClick={() => { |
||||
dispatch(togglePublishBlogModal(true)) |
||||
}} |
||||
> |
||||
<NewWindowSVG color="#fff" width="18" height="18" /> |
||||
Create Blog |
||||
</CreateBlogButton> |
||||
)} |
||||
{isAuthenticated && userName && hasBlog && ( |
||||
<> |
||||
<StyledButton |
||||
color="primary" |
||||
startIcon={<AddBoxIcon />} |
||||
onClick={() => { |
||||
navigate(`/post/new`) |
||||
}} |
||||
> |
||||
Create Post |
||||
</StyledButton> |
||||
|
||||
<StyledButton |
||||
color="primary" |
||||
startIcon={<AutoStoriesIcon />} |
||||
onClick={() => { |
||||
navigate(`/${userName}/${blog.blogId}`) |
||||
}} |
||||
> |
||||
My Blog |
||||
</StyledButton> |
||||
</> |
||||
)} |
||||
|
||||
{isAuthenticated && userName && ( |
||||
<AvatarContainer onClick={handleClick}> |
||||
<NavbarName>{userName}</NavbarName> |
||||
{!userAvatar ? ( |
||||
<AccountCircleSVG |
||||
color={theme.palette.text.primary} |
||||
width="32" |
||||
height="32" |
||||
/> |
||||
) : ( |
||||
<img |
||||
src={userAvatar} |
||||
alt="User Avatar" |
||||
width="32" |
||||
height="32" |
||||
style={{ |
||||
borderRadius: '50%' |
||||
}} |
||||
/> |
||||
)} |
||||
<ExpandMoreIcon id="expand-icon" sx={{ color: '#ACB6BF' }} /> |
||||
</AvatarContainer> |
||||
)} |
||||
<Popover |
||||
id={id} |
||||
open={open} |
||||
anchorEl={anchorEl} |
||||
onClose={handleClose} |
||||
anchorOrigin={{ |
||||
vertical: 'bottom', |
||||
horizontal: 'left' |
||||
}} |
||||
> |
||||
<DropdownContainer onClick={() => navigate('/favorites')}> |
||||
<BookmarkIcon |
||||
sx={{ |
||||
color: '#50e3c2' |
||||
}} |
||||
/> |
||||
<DropdownText>Favorites</DropdownText> |
||||
</DropdownContainer> |
||||
<DropdownContainer onClick={() => navigate('/subscriptions')}> |
||||
<SubscriptionsIcon |
||||
sx={{ |
||||
color: '#5f50e3' |
||||
}} |
||||
/> |
||||
<DropdownText>Subscriptions</DropdownText> |
||||
</DropdownContainer> |
||||
<DropdownContainer |
||||
onClick={() => { |
||||
setIsOpenModal(true) |
||||
handleClose() |
||||
}} |
||||
> |
||||
<PersonOffIcon |
||||
sx={{ |
||||
color: '#e35050' |
||||
}} |
||||
/> |
||||
<DropdownText>Blocked Names</DropdownText> |
||||
</DropdownContainer> |
||||
<DropdownContainer> |
||||
<a |
||||
href="qortal://APP/Q-Mail" |
||||
className="qortal-link" |
||||
style={{ |
||||
width: '100%', |
||||
display: 'flex', |
||||
gap: '5px', |
||||
alignItems: 'center' |
||||
}} |
||||
> |
||||
<EmailIcon |
||||
sx={{ |
||||
color: '#50e3c2' |
||||
}} |
||||
/> |
||||
|
||||
<DropdownText>Q-Mail</DropdownText> |
||||
</a> |
||||
</DropdownContainer> |
||||
</Popover> |
||||
{isOpenModal && ( |
||||
<BlockedNamesModal open={isOpenModal} onClose={onClose} /> |
||||
)} |
||||
</Box> |
||||
</CustomToolbar> |
||||
</CustomAppBar> |
||||
) |
||||
} |
||||
|
||||
export default NavBar |
@ -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: 20 }} |
||||
/> |
||||
|
||||
<TextField |
||||
id="modal-title-input" |
||||
label="Title" |
||||
value={title} |
||||
onChange={(e) => setTitle(e.target.value)} |
||||
fullWidth |
||||
/> |
||||
<TextField |
||||
id="modal-description-input" |
||||
label="Description" |
||||
value={description} |
||||
onChange={(e) => setDescription(e.target.value)} |
||||
multiline |
||||
rows={4} |
||||
fullWidth |
||||
/> |
||||
{options.length > 0 && ( |
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||
<InputLabel id="Category">Select a Category</InputLabel> |
||||
<Select |
||||
labelId="Category" |
||||
input={<OutlinedInput label="Select a Category" />} |
||||
value={selectedOption?.id || ''} |
||||
onChange={handleOptionChange} |
||||
> |
||||
{options.map((option) => ( |
||||
<MenuItem key={option.id} value={option.id}> |
||||
{option.name} |
||||
</MenuItem> |
||||
))} |
||||
</Select> |
||||
</FormControl> |
||||
)} |
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}> |
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}> |
||||
<TextField |
||||
label="Add a tag" |
||||
value={inputValue} |
||||
onChange={handleInputChange} |
||||
onKeyDown={handleInputKeyDown} |
||||
disabled={chips.length === 3} |
||||
/> |
||||
|
||||
<IconButton onClick={addChip} disabled={chips.length === 3}> |
||||
<AddIcon /> |
||||
</IconButton> |
||||
</Box> |
||||
<ChipContainer> |
||||
{chips.map((chip, index) => ( |
||||
<Chip |
||||
key={index} |
||||
label={chip} |
||||
onDelete={() => handleChipDelete(index)} |
||||
deleteIcon={<CloseIcon />} |
||||
/> |
||||
))} |
||||
</ChipContainer> |
||||
</FormControl> |
||||
{errorMessage && ( |
||||
<Typography color="error" variant="body1"> |
||||
{errorMessage} |
||||
</Typography> |
||||
)} |
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}> |
||||
<Button variant="outlined" color="error" onClick={handleClose}> |
||||
Cancel |
||||
</Button> |
||||
<Button variant="contained" color="success" onClick={handlePublish}> |
||||
Publish |
||||
</Button> |
||||
</Box> |
||||
</Box> |
||||
</Modal> |
||||
) |
||||
} |
||||
|
||||
export default MyModal |
@ -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 |
||||
file?: File |
||||
count?: number |
||||
query?: string |
||||
exactMatchNames?: boolean |
||||
excludeBlocked?: boolean |
||||
mode?: string |
||||
} |
||||
|
||||
declare function qortalRequest(options: QortalRequestOptions): Promise<any> |
||||
declare function qortalRequestWithTimeout( |
||||
options: QortalRequestOptions, |
||||
time: number |
||||
): Promise<any> |
||||
|
||||
declare global { |
||||
interface Window { |
||||
_qdnBase: any // Replace 'any' with the appropriate type if you know it
|
||||
_qdnTheme: string |
||||
} |
||||
} |
||||
|
||||
declare global { |
||||
interface Window { |
||||
showSaveFilePicker: ( |
||||
options?: SaveFilePickerOptions |
||||
) => Promise<FileSystemFileHandle> |
||||
} |
||||
} |
@ -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,162 @@
|
||||
@font-face { |
||||
font-family: 'CambonLight'; |
||||
src: url('./styles/fonts/Cambon-Light.ttf') format('truetype'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Raleway'; |
||||
src: url('./styles/fonts/Raleway.ttf') format('truetype'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Catamaran'; |
||||
src: url('./styles/fonts/Catamaran.ttf') format('truetype'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Oxygen'; |
||||
src: url('./styles/fonts/Oxygen.ttf') format('truetype'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Cairo'; |
||||
src: url('./styles/fonts/Cairo.ttf') format('truetype'); |
||||
} |
||||
|
||||
:root { |
||||
padding: 0px; |
||||
margin: 0px; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.line-clamp { |
||||
height: 100px; |
||||
overflow: hidden; |
||||
display: -webkit-box; |
||||
-webkit-line-clamp: 5; /* number of lines to show */ |
||||
-webkit-box-orient: vertical; |
||||
text-overflow: ellipsis; |
||||
} |
||||
|
||||
.edit-btn:hover { |
||||
opacity: 0.75; |
||||
transition: 0.2s all; |
||||
} |
||||
|
||||
.post-image { |
||||
max-width: 100%; |
||||
border-radius: 5px; |
||||
width: 100%; |
||||
height: 100%; |
||||
} |
||||
|
||||
.grid-item { |
||||
/* Other styles */ |
||||
/* overflow: auto; */ |
||||
} |
||||
|
||||
.grid-item-view { |
||||
/* Other styles */ |
||||
/* overflow: auto; */ |
||||
} |
||||
|
||||
.test-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(4, 1fr); |
||||
position: absolute; |
||||
top: 0; |
||||
bottom: 0; |
||||
left: 0; |
||||
right: 0; |
||||
min-height: 25px; |
||||
} |
||||
|
||||
.test-grid-item { |
||||
border: 1px solid powderblue; |
||||
} |
||||
|
||||
body::-webkit-scrollbar-track { |
||||
background-color: transparent; |
||||
} |
||||
body::-webkit-scrollbar-track:hover { |
||||
background-color: transparent; |
||||
} |
||||
|
||||
body::-webkit-scrollbar { |
||||
width: 16px; |
||||
height: 10px; |
||||
background-color: white; |
||||
} |
||||
|
||||
body::-webkit-scrollbar-thumb { |
||||
background-color: #838eee; |
||||
border-radius: 8px; |
||||
background-clip: content-box; |
||||
border: 4px solid transparent; |
||||
} |
||||
|
||||
body::-webkit-scrollbar-thumb:hover { |
||||
background-color: #6270f0; |
||||
} |
||||
|
||||
.MuiList-root::-webkit-scrollbar-track { |
||||
background-color: transparent; |
||||
} |
||||
.MuiList-root::-webkit-scrollbar-track:hover { |
||||
background-color: transparent; |
||||
} |
||||
|
||||
.MuiList-root::-webkit-scrollbar { |
||||
width: 14px; |
||||
height: 10px; |
||||
background-color: white; |
||||
} |
||||
|
||||
.MuiList-root::-webkit-scrollbar-thumb { |
||||
background-color: lightgray; |
||||
border-radius: 8px; |
||||
background-clip: content-box; |
||||
border: 4px solid transparent; |
||||
} |
||||
|
||||
.MuiList-root::-webkit-scrollbar-thumb:hover { |
||||
background-color: lightslategray; |
||||
} |
||||
|
||||
.my-masonry-grid { |
||||
display: -webkit-box; /* Not needed if autoprefixing */ |
||||
display: -ms-flexbox; /* Not needed if autoprefixing */ |
||||
display: flex; |
||||
margin-left: -20px; /* gutter size offset */ |
||||
width: auto; |
||||
padding: 15px 20px; |
||||
} |
||||
|
||||
.my-masonry-grid_column { |
||||
padding-left: 20px; /* gutter size */ |
||||
background-clip: padding-box; |
||||
} |
||||
|
||||
/* Style your items */ |
||||
.my-masonry-grid_column > li { |
||||
/* change div to reference your elements you put in <Masonry> */ |
||||
margin-bottom: 30px; |
||||
} |
||||
|
||||
.my-svg path { |
||||
fill: red; |
||||
} |
||||
|
||||
.qortal-link { |
||||
text-decoration: none; /* Removes the underline */ |
||||
color: inherit; /* Inherits the color of the parent element */ |
||||
} |
||||
.qortal-link:hover, |
||||
a:focus { |
||||
text-decoration: underline; /* Adds underline on hover and focus for accessibility */ |
||||
} |
||||
|
||||
.glow { |
||||
box-shadow: 0 0 10px #9ecaed, 0 0 20px #9ecaed, 0 0 30px #9ecaed, |
||||
0 0 40px #9ecaed; |
||||
} |
@ -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,19 @@
|
||||
import React from 'react' |
||||
import ReactDOM from 'react-dom/client' |
||||
import App from './App' |
||||
import './index.css' |
||||
import { HashRouter, BrowserRouter } from 'react-router-dom' |
||||
interface CustomWindow extends Window { |
||||
_qdnBase: any // Replace 'any' with the appropriate type if you know it
|
||||
} |
||||
|
||||
const customWindow = window as unknown as CustomWindow |
||||
|
||||
// Now you can access the _qdnTheme property without TypeScript errors
|
||||
const baseUrl = customWindow?._qdnBase || '' |
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( |
||||
<BrowserRouter basename={baseUrl}> |
||||
<App /> |
||||
<div id="modal-root" /> |
||||
</BrowserRouter> |
||||
) |
@ -0,0 +1,951 @@
|
||||
import React, { useMemo, useRef, useState } from 'react' |
||||
import { useParams } from 'react-router-dom' |
||||
import { |
||||
Button, |
||||
Box, |
||||
Typography, |
||||
CardHeader, |
||||
Avatar, |
||||
useTheme, |
||||
Tooltip |
||||
} from '@mui/material' |
||||
import { useNavigate } from 'react-router-dom' |
||||
import { styled } from '@mui/system' |
||||
import AudiotrackIcon from '@mui/icons-material/Audiotrack' |
||||
import ReadOnlySlate from '../../components/editor/ReadOnlySlate' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../../state/store' |
||||
import { checkStructure } from '../../utils/checkStructure' |
||||
import { BlogContent } from '../../interfaces/interfaces' |
||||
import ShareIcon from '@mui/icons-material/Share' |
||||
import { |
||||
setAudio, |
||||
setCurrAudio, |
||||
setIsLoadingGlobal, |
||||
setVisitingBlog |
||||
} from '../../state/features/globalSlice' |
||||
import { VideoPlayer } from '../../components/common/VideoPlayer' |
||||
import { AudioPlayer, IPlaylist } from '../../components/common/AudioPlayer' |
||||
import { Responsive, WidthProvider } from 'react-grid-layout' |
||||
import '/node_modules/react-grid-layout/css/styles.css' |
||||
import '/node_modules/react-resizable/css/styles.css' |
||||
import DynamicHeightItem from '../../components/DynamicHeightItem' |
||||
import { |
||||
addPrefix, |
||||
buildIdentifierFromCreateTitleIdAndId, |
||||
removePrefix |
||||
} from '../../utils/blogIdformats' |
||||
import { DynamicHeightItemMinimal } from '../../components/DynamicHeightItemMinimal' |
||||
import { ReusableModal } from '../../components/modals/ReusableModal' |
||||
import AudioElement from '../../components/AudioElement' |
||||
import ErrorBoundary from '../../components/common/ErrorBoundary' |
||||
import { CommentSection } from '../../components/common/Comments/CommentSection' |
||||
import { Tipping } from '../../components/common/Tipping/Tipping' |
||||
import FileElement from '../../components/FileElement' |
||||
import { CopyToClipboard } from 'react-copy-to-clipboard' |
||||
import { setNotification } from '../../state/features/notificationsSlice' |
||||
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource' |
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive) |
||||
const initialMinHeight = 2 // Define an initial minimum height for grid items
|
||||
|
||||
const md = [ |
||||
{ i: 'a', x: 0, y: 0, w: 4, h: initialMinHeight }, |
||||
{ i: 'b', x: 6, y: 0, w: 4, h: initialMinHeight } |
||||
] |
||||
const sm = [ |
||||
{ i: 'a', x: 0, y: 0, w: 6, h: initialMinHeight }, |
||||
{ i: 'b', x: 6, y: 0, w: 6, h: initialMinHeight } |
||||
] |
||||
const xs = [ |
||||
{ i: 'a', x: 0, y: 0, w: 6, h: initialMinHeight }, |
||||
{ i: 'b', x: 6, y: 0, w: 6, h: initialMinHeight } |
||||
] |
||||
|
||||
interface ILayoutGeneralSettings { |
||||
padding: number |
||||
blogPostType: string |
||||
} |
||||
export const BlogIndividualPost = () => { |
||||
const { user, postId: postIdTemp, blog:blogTemp } = useParams() |
||||
|
||||
const blog = React.useMemo(()=> { |
||||
if(postIdTemp && postIdTemp?.includes('-post-')){ |
||||
const str = postIdTemp |
||||
const arr = str.split('-post-') |
||||
const str1 = arr[0] |
||||
const blogId = removePrefix(str1) |
||||
return blogId |
||||
} else { |
||||
return blogTemp |
||||
} |
||||
}, [postIdTemp]) |
||||
|
||||
const postId = React.useMemo(()=> { |
||||
if(postIdTemp && postIdTemp?.includes('-post-')){ |
||||
const str = postIdTemp |
||||
const arr = str.split('-post-') |
||||
const str2 = arr[1] |
||||
return str2 |
||||
} else { |
||||
return postIdTemp |
||||
} |
||||
}, [postIdTemp]) |
||||
|
||||
const blogFull = React.useMemo(() => { |
||||
if (!blog) return '' |
||||
return addPrefix(blog) |
||||
}, [blog]) |
||||
const { user: userState } = useSelector((state: RootState) => state.auth) |
||||
const { audios, audioPostId } = useSelector( |
||||
(state: RootState) => state.global |
||||
) |
||||
|
||||
const [avatarUrl, setAvatarUrl] = React.useState<string>('') |
||||
const dispatch = useDispatch() |
||||
const navigate = useNavigate() |
||||
const theme = useTheme() |
||||
// const [currAudio, setCurrAudio] = React.useState<number | null>(null)
|
||||
const [layouts, setLayouts] = React.useState<any>({ md, sm, xs }) |
||||
const [count, setCount] = React.useState<number>(1) |
||||
const [layoutGeneralSettings, setLayoutGeneralSettings] = |
||||
React.useState<ILayoutGeneralSettings | null>(null) |
||||
const [currentBreakpoint, setCurrentBreakpoint] = React.useState<any>() |
||||
const handleLayoutChange = (layout: any, layoutss: any) => { |
||||
// const redoLayouts = setAutoHeight(layoutss)
|
||||
setLayouts(layoutss) |
||||
// saveLayoutsToLocalStorage(layoutss)
|
||||
} |
||||
const [blogContent, setBlogContent] = React.useState<BlogContent | null>(null) |
||||
const [isOpenSwitchPlaylistModal, setisOpenSwitchPlaylistModal] = |
||||
useState<boolean>(false) |
||||
const tempSaveAudio = useRef<any>(null) |
||||
const saveAudio = React.useRef<any>(null) |
||||
|
||||
const fullPostId = useMemo(() => { |
||||
if (!blog || !postId) return '' |
||||
dispatch(setIsLoadingGlobal(true)) |
||||
const formBlogId = addPrefix(blog) |
||||
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId) |
||||
return formPostId |
||||
}, [blog, postId]) |
||||
const getBlogPost = React.useCallback(async () => { |
||||
try { |
||||
if (!blog || !postId) return |
||||
dispatch(setIsLoadingGlobal(true)) |
||||
const formBlogId = addPrefix(blog) |
||||
const formPostId = buildIdentifierFromCreateTitleIdAndId( |
||||
formBlogId, |
||||
postId |
||||
) |
||||
const url = `/arbitrary/BLOG_POST/${user}/${formPostId}` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
|
||||
const responseData = await response.json() |
||||
|
||||
if (checkStructure(responseData)) { |
||||
setBlogContent(responseData) |
||||
if (responseData?.layouts) { |
||||
setLayouts(responseData?.layouts) |
||||
} |
||||
if (responseData?.layoutGeneralSettings) { |
||||
setLayoutGeneralSettings(responseData.layoutGeneralSettings) |
||||
} |
||||
const filteredAudios = (responseData?.postContent || []).filter( |
||||
(content: any) => content?.type === 'audio' |
||||
) |
||||
|
||||
const transformAudios = filteredAudios?.map((fa: any) => { |
||||
return { |
||||
...(fa?.content || {}), |
||||
id: fa?.id |
||||
} |
||||
}) |
||||
|
||||
if (!audios && transformAudios.length > 0) { |
||||
saveAudio.current = { audios: transformAudios, postId: formPostId } |
||||
dispatch(setAudio({ audios: transformAudios, postId: formPostId })) |
||||
} else if ( |
||||
formPostId === audioPostId && |
||||
audios?.length !== transformAudios.length |
||||
) { |
||||
tempSaveAudio.current = { |
||||
message: |
||||
"This post's audio playlist has updated. Would you like to switch?" |
||||
} |
||||
setisOpenSwitchPlaylistModal(true) |
||||
} |
||||
} |
||||
} catch (error) { |
||||
} finally { |
||||
dispatch(setIsLoadingGlobal(false)) |
||||
} |
||||
}, [user, postId, blog]) |
||||
React.useEffect(() => { |
||||
getBlogPost() |
||||
}, [postId]) |
||||
|
||||
const switchPlayList = () => { |
||||
const filteredAudios = (blogContent?.postContent || []).filter( |
||||
(content) => content?.type === 'audio' |
||||
) |
||||
|
||||
const formatAudios = filteredAudios.map((fa) => { |
||||
return { |
||||
...(fa?.content || {}), |
||||
id: fa?.id |
||||
} |
||||
}) |
||||
if (!blog || !postId) return |
||||
const formBlogId = addPrefix(blog) |
||||
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId) |
||||
dispatch(setAudio({ audios: formatAudios, postId: formPostId })) |
||||
if (tempSaveAudio?.current?.currentSelection) { |
||||
const findIndex = (formatAudios || []).findIndex( |
||||
(item) => |
||||
item?.identifier === |
||||
tempSaveAudio?.current?.currentSelection?.content?.identifier |
||||
) |
||||
if (findIndex >= 0) { |
||||
dispatch(setCurrAudio(findIndex)) |
||||
} |
||||
} |
||||
setisOpenSwitchPlaylistModal(false) |
||||
} |
||||
|
||||
const getAvatar = React.useCallback(async () => { |
||||
try { |
||||
let url = await qortalRequest({ |
||||
action: 'GET_QDN_RESOURCE_URL', |
||||
name: user, |
||||
service: 'THUMBNAIL', |
||||
identifier: 'qortal_avatar' |
||||
}) |
||||
|
||||
setAvatarUrl(url) |
||||
} catch (error) {} |
||||
}, [user]) |
||||
React.useEffect(() => { |
||||
getAvatar() |
||||
}, []) |
||||
|
||||
const onBreakpointChange = React.useCallback((newBreakpoint: any) => { |
||||
setCurrentBreakpoint(newBreakpoint) |
||||
}, []) |
||||
|
||||
const onResizeStop = React.useCallback((layout: any, layoutItem: any) => { |
||||
// Update the layout state with the new position and size of the component
|
||||
setCount((prev) => prev + 1) |
||||
}, []) |
||||
|
||||
// const audios = React.useMemo<IPlaylist[]>(() => {
|
||||
// const filteredAudios = (blogContent?.postContent || []).filter(
|
||||
// (content) => content.type === 'audio'
|
||||
// )
|
||||
|
||||
// return filteredAudios.map((fa) => {
|
||||
// return {
|
||||
// ...fa.content,
|
||||
// id: fa.id
|
||||
// }
|
||||
// })
|
||||
// }, [blogContent])
|
||||
|
||||
const handleResize = () => { |
||||
setCount((prev) => prev + 1) |
||||
} |
||||
|
||||
React.useEffect(() => { |
||||
window.addEventListener('resize', handleResize) |
||||
|
||||
return () => { |
||||
window.removeEventListener('resize', handleResize) |
||||
} |
||||
}, []) |
||||
|
||||
const handleCount = React.useCallback(() => { |
||||
// Update the layout state with the new position and size of the component
|
||||
setCount((prev) => prev + 1) |
||||
}, []) |
||||
|
||||
const getBlog = React.useCallback(async () => { |
||||
let name = user |
||||
if (!name) return |
||||
if (!blogFull) return |
||||
try { |
||||
const urlBlog = `/arbitrary/BLOG/${name}/${blogFull}` |
||||
const response = await fetch(urlBlog, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
dispatch(setVisitingBlog({ ...responseData, name })) |
||||
} catch (error) {} |
||||
}, [user, blogFull]) |
||||
|
||||
React.useEffect(() => { |
||||
getBlog() |
||||
}, [user, blogFull]) |
||||
|
||||
if (!blogContent) return null |
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
flexDirection: 'column' |
||||
}} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
maxWidth: '1400px', |
||||
// margin: '15px',
|
||||
width: '95%', |
||||
paddingBottom: '50px' |
||||
}} |
||||
> |
||||
{user === userState?.name && ( |
||||
<Button |
||||
sx={{ backgroundColor: theme.palette.secondary.main }} |
||||
onClick={() => { |
||||
navigate(`/${user}/${blog}/${postId}/edit`) |
||||
}} |
||||
> |
||||
Edit Post |
||||
</Button> |
||||
)} |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
gap: 1 |
||||
}} |
||||
> |
||||
<CardHeader |
||||
onClick={() => { |
||||
navigate(`/${user}/${blog}`) |
||||
}} |
||||
sx={{ |
||||
cursor: 'pointer', |
||||
'& .MuiCardHeader-content': { |
||||
overflow: 'hidden' |
||||
}, |
||||
padding: '10px 0px' |
||||
}} |
||||
avatar={<Avatar src={avatarUrl} alt={`${user}'s avatar`} />} |
||||
subheader={ |
||||
<Typography |
||||
sx={{ fontFamily: 'Cairo', fontSize: '25px' }} |
||||
color={theme.palette.text.primary} |
||||
>{` ${user}`}</Typography> |
||||
} |
||||
/> |
||||
{user && ( |
||||
<Tipping |
||||
name={user || ''} |
||||
onSubmit={() => { |
||||
// setNameTip('')
|
||||
}} |
||||
onClose={() => { |
||||
// setNameTip('')
|
||||
}} |
||||
/> |
||||
)} |
||||
</Box> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
gap: 1, |
||||
alignItems: 'center', |
||||
justifyContent: 'center' |
||||
}} |
||||
> |
||||
<Typography |
||||
variant="h1" |
||||
color="textPrimary" |
||||
sx={{ |
||||
textAlign: 'center' |
||||
}} |
||||
> |
||||
{blogContent?.title} |
||||
</Typography> |
||||
<Tooltip title={`Copy post link`} arrow> |
||||
<Box |
||||
sx={{ |
||||
cursor: 'pointer' |
||||
}} |
||||
> |
||||
<CopyToClipboard |
||||
text={`qortal://APP/Q-Blog/${user}/${blog}/${postId}`} |
||||
onCopy={() => { |
||||
dispatch( |
||||
setNotification({ |
||||
msg: 'Copied to clipboard!', |
||||
alertType: 'success' |
||||
}) |
||||
) |
||||
}} |
||||
> |
||||
<ShareIcon /> |
||||
</CopyToClipboard> |
||||
</Box> |
||||
</Tooltip> |
||||
<CommentSection postId={fullPostId} postName={user || ''} /> |
||||
</Box> |
||||
|
||||
{(layoutGeneralSettings?.blogPostType === 'builder' || |
||||
!layoutGeneralSettings?.blogPostType) && ( |
||||
<Content |
||||
layouts={layouts} |
||||
blogContent={blogContent} |
||||
onResizeStop={onResizeStop} |
||||
onBreakpointChange={onBreakpointChange} |
||||
handleLayoutChange={handleLayoutChange} |
||||
> |
||||
{blogContent?.postContent?.map((section: any) => { |
||||
if (section?.type === 'editor') { |
||||
return ( |
||||
<div key={section?.id} className="grid-item-view"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItem |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={layoutGeneralSettings?.padding} |
||||
> |
||||
<ReadOnlySlate content={section.content} /> |
||||
</DynamicHeightItem> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
if (section?.type === 'image') { |
||||
return ( |
||||
<div key={section?.id} className="grid-item-view"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItem |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={layoutGeneralSettings?.padding} |
||||
> |
||||
<img |
||||
src={section.content.image} |
||||
className="post-image" |
||||
/> |
||||
</DynamicHeightItem> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
if (section?.type === 'video') { |
||||
return ( |
||||
<div key={section?.id} className="grid-item-view"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItem |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={layoutGeneralSettings?.padding} |
||||
> |
||||
<ContextMenuResource |
||||
name={section.content.name} |
||||
service={section.content.service} |
||||
identifier={section.content.identifier} |
||||
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`} |
||||
> |
||||
<VideoPlayer |
||||
name={section.content.name} |
||||
service={section.content.service} |
||||
identifier={section.content.identifier} |
||||
setCount={handleCount} |
||||
user={user} |
||||
postId={fullPostId} |
||||
/> |
||||
</ContextMenuResource> |
||||
</DynamicHeightItem> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
if (section?.type === 'audio') { |
||||
return ( |
||||
<div key={section?.id} className="grid-item-view"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItem |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={layoutGeneralSettings?.padding} |
||||
> |
||||
<ContextMenuResource |
||||
name={section.content.name} |
||||
service={section.content.service} |
||||
identifier={section.content.identifier} |
||||
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`} |
||||
> |
||||
<AudioElement |
||||
key={section.id} |
||||
audioInfo={section.content} |
||||
postId={fullPostId} |
||||
user={user ? user : ''} |
||||
onClick={() => { |
||||
if (!blog || !postId) return |
||||
|
||||
const formBlogId = addPrefix(blog) |
||||
const formPostId = |
||||
buildIdentifierFromCreateTitleIdAndId( |
||||
formBlogId, |
||||
postId |
||||
) |
||||
if (audioPostId && formPostId !== audioPostId) { |
||||
tempSaveAudio.current = { |
||||
...(tempSaveAudio.current || {}), |
||||
currentSelection: section, |
||||
message: |
||||
'You are current on a playlist. Would you like to switch?' |
||||
} |
||||
setisOpenSwitchPlaylistModal(true) |
||||
} else { |
||||
if (!audios && saveAudio?.current) { |
||||
const findIndex = ( |
||||
saveAudio?.current?.audios || [] |
||||
).findIndex( |
||||
(item: any) => |
||||
item.identifier === |
||||
section.content.identifier |
||||
) |
||||
dispatch(setAudio(saveAudio?.current)) |
||||
dispatch(setCurrAudio(findIndex)) |
||||
return |
||||
} |
||||
|
||||
const findIndex = (audios || []).findIndex( |
||||
(item) => |
||||
item.identifier === |
||||
section.content.identifier |
||||
) |
||||
if (findIndex >= 0) { |
||||
dispatch(setCurrAudio(findIndex)) |
||||
} |
||||
} |
||||
}} |
||||
title={section.content?.title} |
||||
description={section.content?.description} |
||||
author="" |
||||
/> |
||||
</ContextMenuResource> |
||||
</DynamicHeightItem> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
if (section?.type === 'file') { |
||||
return ( |
||||
<div key={section?.id} className="grid-item"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItemMinimal |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={0} |
||||
> |
||||
<ContextMenuResource |
||||
name={section.content.name} |
||||
service={section.content.service} |
||||
identifier={section.content.identifier} |
||||
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`} |
||||
> |
||||
<FileElement |
||||
key={section.id} |
||||
fileInfo={section.content} |
||||
postId={fullPostId} |
||||
user={user ? user : ''} |
||||
title={section.content?.title} |
||||
description={section.content?.description} |
||||
mimeType={section.content?.mimeType} |
||||
author="" |
||||
/> |
||||
</ContextMenuResource> |
||||
</DynamicHeightItemMinimal> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
})} |
||||
</Content> |
||||
)} |
||||
{layoutGeneralSettings?.blogPostType === 'minimal' && ( |
||||
<> |
||||
{layouts?.rows?.map((row: any, rowIndex: number) => { |
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
width: '100%', |
||||
flexDirection: 'row', |
||||
alignItems: 'center', |
||||
justifyContent: 'center', |
||||
marginTop: '25px', |
||||
gap: 2 |
||||
}} |
||||
> |
||||
{row?.ids?.map((elementId: string) => { |
||||
const section: any = blogContent?.postContent?.find( |
||||
(el) => el?.id === elementId |
||||
) |
||||
if (!section) return null |
||||
if (section?.type === 'editor') { |
||||
return ( |
||||
<div |
||||
key={section?.id} |
||||
className="grid-item" |
||||
style={{ |
||||
maxWidth: '800px', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
width: '100%' |
||||
}} |
||||
> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItemMinimal |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={0} |
||||
> |
||||
<ReadOnlySlate |
||||
key={section.id} |
||||
content={section.content} |
||||
/> |
||||
</DynamicHeightItemMinimal> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
if (section?.type === 'image') { |
||||
return ( |
||||
<div key={section.id} className="grid-item"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItemMinimal |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
type="image" |
||||
padding={0} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
position: 'relative', |
||||
width: '100%', |
||||
height: '100%' |
||||
}} |
||||
> |
||||
<img |
||||
src={section.content.image} |
||||
className="post-image" |
||||
style={{ |
||||
objectFit: 'contain', |
||||
maxHeight: '50vh' |
||||
}} |
||||
/> |
||||
</Box> |
||||
</DynamicHeightItemMinimal> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (section?.type === 'video') { |
||||
return ( |
||||
<div key={section?.id} className="grid-item"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItemMinimal |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={0} |
||||
> |
||||
<ContextMenuResource |
||||
name={section.content.name} |
||||
service={section.content.service} |
||||
identifier={section.content.identifier} |
||||
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
position: 'relative', |
||||
width: '100%', |
||||
height: '100%' |
||||
}} |
||||
> |
||||
<VideoPlayer |
||||
name={section.content.name} |
||||
service={section.content.service} |
||||
identifier={section.content.identifier} |
||||
customStyle={{ |
||||
height: '50vh' |
||||
}} |
||||
user={user} |
||||
postId={fullPostId} |
||||
/> |
||||
</Box> |
||||
</ContextMenuResource> |
||||
</DynamicHeightItemMinimal> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
if (section?.type === 'audio') { |
||||
return ( |
||||
<div key={section?.id} className="grid-item"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItemMinimal |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={0} |
||||
> |
||||
<ContextMenuResource |
||||
name={section.content.name} |
||||
service={section.content.service} |
||||
identifier={section.content.identifier} |
||||
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`} |
||||
> |
||||
<AudioElement |
||||
key={section.id} |
||||
audioInfo={section.content} |
||||
postId={fullPostId} |
||||
user={user ? user : ''} |
||||
onClick={() => { |
||||
if (!blog || !postId) return |
||||
const formBlogId = addPrefix(blog) |
||||
const formPostId = |
||||
buildIdentifierFromCreateTitleIdAndId( |
||||
formBlogId, |
||||
postId |
||||
) |
||||
if (formPostId !== audioPostId) { |
||||
tempSaveAudio.current = { |
||||
...(tempSaveAudio.current || {}), |
||||
currentSelection: section, |
||||
message: |
||||
'You are current on a playlist. Would you like to switch?' |
||||
} |
||||
setisOpenSwitchPlaylistModal(true) |
||||
} else { |
||||
const findIndex = ( |
||||
audios || [] |
||||
).findIndex( |
||||
(item) => |
||||
item.identifier === |
||||
section.content.identifier |
||||
) |
||||
if (findIndex >= 0) { |
||||
dispatch(setCurrAudio(findIndex)) |
||||
} |
||||
} |
||||
}} |
||||
title={section.content?.title} |
||||
description={section.content?.description} |
||||
author="" |
||||
/> |
||||
</ContextMenuResource> |
||||
</DynamicHeightItemMinimal> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
if (section?.type === 'file') { |
||||
return ( |
||||
<div key={section?.id} className="grid-item"> |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography> |
||||
Error loading content: Invalid Data |
||||
</Typography> |
||||
} |
||||
> |
||||
<DynamicHeightItemMinimal |
||||
layouts={layouts} |
||||
setLayouts={setLayouts} |
||||
i={section.id} |
||||
breakpoint={currentBreakpoint} |
||||
count={count} |
||||
padding={0} |
||||
> |
||||
<ContextMenuResource |
||||
name={section.content.name} |
||||
service={section.content.service} |
||||
identifier={section.content.identifier} |
||||
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`} |
||||
> |
||||
<FileElement |
||||
key={section.id} |
||||
fileInfo={section.content} |
||||
postId={fullPostId} |
||||
user={user ? user : ''} |
||||
title={section.content?.title} |
||||
description={section.content?.description} |
||||
mimeType={section.content?.mimeType} |
||||
author="" |
||||
/> |
||||
</ContextMenuResource> |
||||
</DynamicHeightItemMinimal> |
||||
</ErrorBoundary> |
||||
</div> |
||||
) |
||||
} |
||||
})} |
||||
</Box> |
||||
) |
||||
})} |
||||
</> |
||||
)} |
||||
<ReusableModal open={isOpenSwitchPlaylistModal}> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
gap: 1 |
||||
}} |
||||
> |
||||
<Typography> |
||||
{tempSaveAudio?.current?.message |
||||
? tempSaveAudio?.current?.message |
||||
: 'You are current on a playlist. Would you like to switch?'} |
||||
</Typography> |
||||
</Box> |
||||
<Button |
||||
variant="contained" |
||||
onClick={() => setisOpenSwitchPlaylistModal(false)} |
||||
> |
||||
Cancel |
||||
</Button> |
||||
<Button variant="contained" onClick={switchPlayList}> |
||||
Switch |
||||
</Button> |
||||
</ReusableModal> |
||||
</Box> |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
const Content = ({ |
||||
children, |
||||
layouts, |
||||
blogContent, |
||||
onResizeStop, |
||||
onBreakpointChange, |
||||
handleLayoutChange |
||||
}: any) => { |
||||
if (layouts && blogContent?.layouts) { |
||||
return ( |
||||
<ErrorBoundary |
||||
fallback={ |
||||
<Typography>Error loading content: Invalid Layout</Typography> |
||||
} |
||||
> |
||||
<ResponsiveGridLayout |
||||
layouts={layouts} |
||||
breakpoints={{ md: 996, sm: 768, xs: 480 }} |
||||
cols={{ md: 4, sm: 3, xs: 1 }} |
||||
measureBeforeMount={false} |
||||
onLayoutChange={handleLayoutChange} |
||||
autoSize={true} |
||||
compactType={null} |
||||
isBounded={true} |
||||
resizeHandles={['se', 'sw', 'ne', 'nw']} |
||||
rowHeight={25} |
||||
onResizeStop={onResizeStop} |
||||
onBreakpointChange={onBreakpointChange} |
||||
isDraggable={false} |
||||
isResizable={false} |
||||
margin={[0, 0]} |
||||
> |
||||
{children} |
||||
</ResponsiveGridLayout> |
||||
</ErrorBoundary> |
||||
) |
||||
} |
||||
return children |
||||
} |
@ -0,0 +1,301 @@
|
||||
import React from 'react' |
||||
import { useNavigate } from 'react-router-dom' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../../state/store' |
||||
import { useParams } from 'react-router-dom' |
||||
import { Typography, Box, Button, useTheme } from '@mui/material' |
||||
import EditIcon from '@mui/icons-material/Edit' |
||||
import BlogPostPreview from '../BlogList/PostPreview' |
||||
import { |
||||
setIsLoadingGlobal, |
||||
setVisitingBlog, |
||||
toggleEditBlogModal |
||||
} from '../../state/features/globalSlice' |
||||
import { |
||||
addSubscription, |
||||
BlogPost, |
||||
removeSubscription |
||||
} from '../../state/features/blogSlice' |
||||
import { useFetchPosts } from '../../hooks/useFetchPosts' |
||||
import LazyLoad from '../../components/common/LazyLoad' |
||||
import { addPrefix, removePrefix } from '../../utils/blogIdformats' |
||||
import Masonry from 'react-masonry-css' |
||||
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource' |
||||
|
||||
const breakpointColumnsObj = { |
||||
default: 5, |
||||
1600: 4, |
||||
1300: 3, |
||||
940: 2, |
||||
700: 1, |
||||
500: 1 |
||||
} |
||||
export const BlogIndividualProfile = () => { |
||||
const navigate = useNavigate() |
||||
const theme = useTheme() |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const { currentBlog } = useSelector((state: RootState) => state.global) |
||||
const subscriptions = useSelector( |
||||
(state: RootState) => state.blog.subscriptions |
||||
) |
||||
|
||||
const { blog: blogShortVersion, user: username } = useParams() |
||||
const blog = React.useMemo(() => { |
||||
if (!blogShortVersion) return '' |
||||
return addPrefix(blogShortVersion) |
||||
}, [blogShortVersion]) |
||||
const dispatch = useDispatch() |
||||
const [userBlog, setUserBlog] = React.useState<any>(null) |
||||
const { checkAndUpdatePost, getBlogPost, hashMapPosts } = useFetchPosts() |
||||
|
||||
const [blogPosts, setBlogPosts] = React.useState<BlogPost[]>([]) |
||||
|
||||
const getBlogPosts = React.useCallback(async () => { |
||||
let name = username |
||||
|
||||
if (!name) return |
||||
if (!blog) return |
||||
|
||||
try { |
||||
dispatch(setIsLoadingGlobal(true)) |
||||
const offset = blogPosts.length |
||||
//TODO - NAME SHOULD BE EXACT
|
||||
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=${blog}-post-&limit=20&exactmatchnames=true&name=${name}&includemetadata=true&offset=${offset}&reverse=true` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
|
||||
const structureData = responseData.map((post: any): BlogPost => { |
||||
return { |
||||
title: post?.metadata?.title, |
||||
category: post?.metadata?.category, |
||||
categoryName: post?.metadata?.categoryName, |
||||
tags: post?.metadata?.tags || [], |
||||
description: post?.metadata?.description, |
||||
createdAt: '', |
||||
user: post.name, |
||||
postImage: '', |
||||
id: post.identifier |
||||
} |
||||
}) |
||||
setBlogPosts(structureData) |
||||
const copiedBlogPosts: BlogPost[] = [...blogPosts] |
||||
structureData.forEach((post: BlogPost) => { |
||||
const index = blogPosts.findIndex((p) => p.id === post.id) |
||||
if (index !== -1) { |
||||
copiedBlogPosts[index] = post |
||||
} else { |
||||
copiedBlogPosts.push(post) |
||||
} |
||||
}) |
||||
setBlogPosts(copiedBlogPosts) |
||||
|
||||
for (const content of structureData) { |
||||
if (content.user && content.id) { |
||||
const res = checkAndUpdatePost(content) |
||||
|
||||
if (res) { |
||||
getBlogPost(content.user, content.id, content) |
||||
} |
||||
} |
||||
} |
||||
} catch (error) { |
||||
} finally { |
||||
dispatch(setIsLoadingGlobal(false)) |
||||
} |
||||
}, [username, blog, blogPosts]) |
||||
const getBlog = React.useCallback(async () => { |
||||
let name = username |
||||
|
||||
if (!name) return |
||||
if (!blog) return |
||||
try { |
||||
const urlBlog = `/arbitrary/BLOG/${name}/${blog}` |
||||
const response = await fetch(urlBlog, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
dispatch(setVisitingBlog({ ...responseData, name })) |
||||
setUserBlog(responseData) |
||||
} catch (error) {} |
||||
}, [username, blog]) |
||||
|
||||
React.useEffect(() => { |
||||
getBlog() |
||||
}, [username, blog]) |
||||
const getPosts = React.useCallback(async () => { |
||||
await getBlogPosts() |
||||
}, [getBlogPosts]) |
||||
|
||||
const subscribe = async () => { |
||||
try { |
||||
if (!user?.name) return |
||||
const body = { |
||||
items: [username] |
||||
} |
||||
|
||||
const listName = `q-blog-subscriptions-${user.name}` |
||||
|
||||
const response = await qortalRequest({ |
||||
action: 'ADD_LIST_ITEMS', |
||||
list_name: listName, |
||||
items: [username] |
||||
}) |
||||
if (response === true) { |
||||
dispatch(addSubscription(username)) |
||||
} |
||||
} catch (error) {} |
||||
} |
||||
const unsubscribe = async () => { |
||||
try { |
||||
if (!user?.name) return |
||||
|
||||
const listName = `q-blog-subscriptions-${user.name}` |
||||
|
||||
const response = await qortalRequest({ |
||||
action: 'DELETE_LIST_ITEM', |
||||
list_name: listName, |
||||
item: username |
||||
}) |
||||
if (response === true) { |
||||
dispatch(removeSubscription(username)) |
||||
} |
||||
} catch (error) {} |
||||
} |
||||
if (!userBlog) return null |
||||
return ( |
||||
<> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
gap: 1, |
||||
alignItems: 'center', |
||||
justifyContent: 'center' |
||||
}} |
||||
> |
||||
<Typography |
||||
variant="h1" |
||||
color="textPrimary" |
||||
sx={{ |
||||
textAlign: 'center', |
||||
marginTop: '20px' |
||||
}} |
||||
> |
||||
{currentBlog?.blogId === blog ? currentBlog?.title : userBlog.title} |
||||
</Typography> |
||||
{currentBlog?.blogId === blog && ( |
||||
<EditIcon |
||||
sx={{ |
||||
cursor: 'pointer' |
||||
}} |
||||
onClick={() => { |
||||
dispatch(toggleEditBlogModal(true)) |
||||
}} |
||||
></EditIcon> |
||||
)} |
||||
{subscriptions.includes(username) && ( |
||||
<Button |
||||
sx={{ |
||||
backgroundColor: theme.palette.primary.light, |
||||
color: theme.palette.text.primary, |
||||
fontFamily: 'Arial' |
||||
}} |
||||
onClick={unsubscribe} |
||||
> |
||||
Unsubscribe |
||||
</Button> |
||||
)} |
||||
{!subscriptions.includes(username) && ( |
||||
<Button |
||||
sx={{ |
||||
backgroundColor: theme.palette.primary.light, |
||||
color: theme.palette.text.primary, |
||||
fontFamily: 'Arial' |
||||
}} |
||||
onClick={subscribe} |
||||
> |
||||
Subscribe |
||||
</Button> |
||||
)} |
||||
</Box> |
||||
|
||||
<Masonry |
||||
breakpointCols={breakpointColumnsObj} |
||||
className="my-masonry-grid" |
||||
columnClassName="my-masonry-grid_column" |
||||
style={{ backgroundColor: theme.palette.background.default }} |
||||
> |
||||
{blogPosts.map((post, index) => { |
||||
const existingPost = hashMapPosts[post.id] |
||||
let blogPost = post |
||||
if (existingPost) { |
||||
blogPost = existingPost |
||||
} |
||||
const str = blogPost.id |
||||
const arr = str.split('-post-') |
||||
const str1 = arr[0] |
||||
|
||||
const blogId = removePrefix(str1) |
||||
const str2 = arr[1] |
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
gap: 1, |
||||
alignItems: 'center', |
||||
width: 'auto', |
||||
position: 'relative', |
||||
' @media (max-width: 450px)': { |
||||
width: '100%' |
||||
} |
||||
}} |
||||
> |
||||
<ContextMenuResource |
||||
name={blogPost.user} |
||||
service="BLOG_POST" |
||||
identifier={blogPost.id} |
||||
link={`qortal://APP/Q-Blog/${blogPost.user}/${blogId}/${str2}`} |
||||
> |
||||
<BlogPostPreview |
||||
onClick={() => { |
||||
navigate(`/${blogPost.user}/${blogId}/${str2}`) |
||||
}} |
||||
description={blogPost?.description} |
||||
title={blogPost?.title} |
||||
createdAt={blogPost?.createdAt} |
||||
author={blogPost.user} |
||||
postImage={blogPost?.postImage} |
||||
blogPost={blogPost} |
||||
tags={blogPost?.tags} |
||||
/> |
||||
</ContextMenuResource> |
||||
{blogPost.user === user?.name && ( |
||||
<EditIcon |
||||
className="edit-btn" |
||||
sx={{ |
||||
position: 'absolute', |
||||
zIndex: 10, |
||||
bottom: '25px', |
||||
right: '25px', |
||||
cursor: 'pointer' |
||||
}} |
||||
onClick={() => { |
||||
navigate(`/${blogPost.user}/${blogId}/${str2}/edit`) |
||||
}} |
||||
/> |
||||
)} |
||||
</Box> |
||||
) |
||||
})} |
||||
</Masonry> |
||||
<LazyLoad onLoadMore={getPosts}></LazyLoad> |
||||
</> |
||||
) |
||||
} |
@ -0,0 +1,225 @@
|
||||
import React, { FC, useCallback, useEffect, useRef } from 'react' |
||||
import { useNavigate } from 'react-router-dom' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../../state/store' |
||||
import EditIcon from '@mui/icons-material/Edit' |
||||
import { |
||||
Box, |
||||
Button, |
||||
List, |
||||
ListItem, |
||||
Typography, |
||||
useTheme |
||||
} from '@mui/material' |
||||
import BlogPostPreview from './PostPreview' |
||||
import { useFetchPosts } from '../../hooks/useFetchPosts' |
||||
import LazyLoad from '../../components/common/LazyLoad' |
||||
import { removePrefix } from '../../utils/blogIdformats' |
||||
import Masonry from 'react-masonry-css' |
||||
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource' |
||||
|
||||
const breakpointColumnsObj = { |
||||
default: 5, |
||||
1600: 4, |
||||
1300: 3, |
||||
940: 2, |
||||
700: 1, |
||||
500: 1 |
||||
} |
||||
interface BlogListProps { |
||||
mode?: string |
||||
} |
||||
export const BlogList = ({ mode }: BlogListProps) => { |
||||
const theme = useTheme() |
||||
const prevVal = useRef('') |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const hashMapPosts = useSelector( |
||||
(state: RootState) => state.blog.hashMapPosts |
||||
) |
||||
const favoritesLocal = useSelector( |
||||
(state: RootState) => state.blog.favoritesLocal |
||||
) |
||||
const subscriptionPosts = useSelector( |
||||
(state: RootState) => state.blog.subscriptionPosts |
||||
) |
||||
const countNewPosts = useSelector( |
||||
(state: RootState) => state.blog.countNewPosts |
||||
) |
||||
const isFiltering = useSelector((state: RootState) => state.blog.isFiltering) |
||||
const filterValue = useSelector((state: RootState) => state.blog.filterValue) |
||||
const filteredPosts = useSelector( |
||||
(state: RootState) => state.blog.filteredPosts |
||||
) |
||||
|
||||
const { posts: globalPosts, favorites } = useSelector( |
||||
(state: RootState) => state.blog |
||||
) |
||||
const navigate = useNavigate() |
||||
const { |
||||
getBlogPosts, |
||||
getBlogPostsFavorites, |
||||
getBlogPostsSubscriptions, |
||||
checkNewMessages, |
||||
getNewPosts, |
||||
getBlogFilteredPosts |
||||
} = useFetchPosts() |
||||
const getPosts = React.useCallback(async () => { |
||||
if (isFiltering) { |
||||
getBlogFilteredPosts(filterValue) |
||||
return |
||||
} |
||||
if (mode === 'favorites') { |
||||
getBlogPostsFavorites() |
||||
return |
||||
} |
||||
if (mode === 'subscriptions' && user?.name) { |
||||
getBlogPostsSubscriptions(user.name) |
||||
return |
||||
} |
||||
await getBlogPosts() |
||||
}, [getBlogPosts, mode, favoritesLocal, user?.name, isFiltering, filterValue]) |
||||
|
||||
let posts = globalPosts |
||||
|
||||
if (mode === 'favorites') { |
||||
posts = favorites |
||||
} |
||||
if (mode === 'subscriptions') { |
||||
posts = subscriptionPosts |
||||
} |
||||
if (isFiltering) { |
||||
posts = filteredPosts |
||||
} |
||||
const interval = useRef<any>(null) |
||||
|
||||
const checkNewMessagesFunc = useCallback(() => { |
||||
let isCalling = false |
||||
interval.current = setInterval(async () => { |
||||
if (isCalling) return |
||||
isCalling = true |
||||
const res = await checkNewMessages() |
||||
isCalling = false |
||||
}, 30000) // 1 second interval
|
||||
}, [checkNewMessages]) |
||||
|
||||
useEffect(() => { |
||||
if (!mode) { |
||||
checkNewMessagesFunc() |
||||
} |
||||
return () => { |
||||
if (interval?.current) { |
||||
clearInterval(interval.current) |
||||
} |
||||
} |
||||
}, [mode, checkNewMessagesFunc]) |
||||
|
||||
useEffect(() => { |
||||
if (isFiltering && filterValue !== prevVal?.current) { |
||||
prevVal.current = filterValue |
||||
getPosts() |
||||
} |
||||
}, [filterValue, isFiltering, filteredPosts]) |
||||
// if (!favoritesLocal) return null
|
||||
|
||||
return ( |
||||
<> |
||||
{!mode && countNewPosts > 0 && !isFiltering && ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center' |
||||
}} |
||||
> |
||||
<Typography> |
||||
{countNewPosts === 1 |
||||
? `There is ${countNewPosts} new post` |
||||
: `There are ${countNewPosts} new posts`} |
||||
</Typography> |
||||
<Button |
||||
sx={{ |
||||
backgroundColor: theme.palette.primary.light, |
||||
color: theme.palette.text.primary, |
||||
fontFamily: 'Arial' |
||||
}} |
||||
onClick={getNewPosts} |
||||
> |
||||
Load new Posts |
||||
</Button> |
||||
</Box> |
||||
)} |
||||
|
||||
<Masonry |
||||
breakpointCols={breakpointColumnsObj} |
||||
className="my-masonry-grid" |
||||
columnClassName="my-masonry-grid_column" |
||||
> |
||||
{posts.map((post, index) => { |
||||
const existingPost = hashMapPosts[post.id] |
||||
let blogPost = post |
||||
if (existingPost) { |
||||
blogPost = existingPost |
||||
} |
||||
const str = blogPost.id |
||||
const arr = str.split('-post-') |
||||
const str1 = arr[0] |
||||
const str2 = arr[1] |
||||
const blogId = removePrefix(str1) |
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
gap: 1, |
||||
alignItems: 'center', |
||||
width: 'auto', |
||||
position: 'relative', |
||||
' @media (max-width: 450px)': { |
||||
width: '100%' |
||||
} |
||||
}} |
||||
key={blogPost.id} |
||||
> |
||||
<ContextMenuResource |
||||
name={blogPost.user} |
||||
service="BLOG_POST" |
||||
identifier={blogPost.id} |
||||
link={`qortal://APP/Q-Blog/${blogPost.user}/${blogId}/${str2}`} |
||||
> |
||||
<BlogPostPreview |
||||
onClick={() => { |
||||
navigate(`/${blogPost.user}/${blogId}/${str2}`) |
||||
}} |
||||
description={blogPost?.description} |
||||
title={blogPost?.title} |
||||
createdAt={blogPost?.createdAt} |
||||
author={blogPost.user} |
||||
postImage={blogPost?.postImage} |
||||
blogPost={blogPost} |
||||
isValid={blogPost?.isValid} |
||||
tags={blogPost?.tags} |
||||
/> |
||||
</ContextMenuResource> |
||||
{blogPost.user === user?.name && ( |
||||
<EditIcon |
||||
className="edit-btn" |
||||
sx={{ |
||||
position: 'absolute', |
||||
zIndex: 10, |
||||
bottom: '25px', |
||||
right: '25px', |
||||
cursor: 'pointer' |
||||
}} |
||||
onClick={() => { |
||||
navigate(`/${blogPost.user}/${blogId}/${str2}/edit`) |
||||
}} |
||||
/> |
||||
)} |
||||
</Box> |
||||
) |
||||
})} |
||||
</Masonry> |
||||
{/* </List> */} |
||||
<LazyLoad onLoadMore={getPosts}></LazyLoad> |
||||
</> |
||||
) |
||||
} |
@ -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,320 @@
|
||||
import React, { useMemo, useState } from 'react' |
||||
import { |
||||
Avatar, |
||||
Card, |
||||
CardContent, |
||||
CardHeader, |
||||
CardMedia, |
||||
Typography, |
||||
Box, |
||||
Button, |
||||
Tooltip, |
||||
useTheme |
||||
} from '@mui/material' |
||||
import Dialog from '@mui/material/Dialog' |
||||
import DialogActions from '@mui/material/DialogActions' |
||||
import DialogContent from '@mui/material/DialogContent' |
||||
import DialogContentText from '@mui/material/DialogContentText' |
||||
import DialogTitle from '@mui/material/DialogTitle' |
||||
import { styled } from '@mui/system' |
||||
|
||||
import { |
||||
CardContentContainer, |
||||
StyledCard, |
||||
StyledCardContent, |
||||
TitleText, |
||||
AuthorText, |
||||
StyledCardHeader, |
||||
StyledCardCol, |
||||
IconsBox, |
||||
BlockIconContainer, |
||||
BookmarkIconContainer |
||||
} from './PostPreview-styles' |
||||
import moment from 'moment' |
||||
import { |
||||
blockUser, |
||||
BlogPost, |
||||
removeFavorites, |
||||
removeSubscription, |
||||
upsertFavorites |
||||
} from '../../state/features/blogSlice' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder' |
||||
import BookmarkIcon from '@mui/icons-material/Bookmark' |
||||
import { AppDispatch, RootState } from '../../state/store' |
||||
import BlockIcon from '@mui/icons-material/Block' |
||||
import { CustomIcon } from '../../components/common/CustomIcon' |
||||
import ResponsiveImage from '../../components/common/ResponsiveImage' |
||||
import { formatDate } from '../../utils/time' |
||||
interface BlogPostPreviewProps { |
||||
title: string |
||||
createdAt: number | string |
||||
author: string |
||||
postImage?: string |
||||
description: any |
||||
blogPost: BlogPost |
||||
onClick?: () => void |
||||
isValid?: boolean |
||||
tags?: string[] |
||||
} |
||||
|
||||
const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({ |
||||
title, |
||||
createdAt, |
||||
author, |
||||
postImage, |
||||
description, |
||||
onClick, |
||||
blogPost, |
||||
isValid, |
||||
tags |
||||
}) => { |
||||
const [avatarUrl, setAvatarUrl] = React.useState<string>('') |
||||
const [showIcons, setShowIcons] = React.useState<boolean>(false) |
||||
|
||||
const dispatch = useDispatch<AppDispatch>() |
||||
const theme = useTheme() |
||||
const favoritesLocal = useSelector( |
||||
(state: RootState) => state.blog.favoritesLocal |
||||
) |
||||
const [isOpenAlert, setIsOpenAlert] = useState<boolean>(false) |
||||
const subscriptions = useSelector( |
||||
(state: RootState) => state.blog.subscriptions |
||||
) |
||||
const username = useSelector((state: RootState) => state.auth?.user?.name) |
||||
|
||||
function extractTextFromSlate(nodes: any) { |
||||
if (!Array.isArray(nodes)) return '' |
||||
let text = '' |
||||
|
||||
for (const node of nodes) { |
||||
if (node.text) { |
||||
text += node.text |
||||
} else if (node.children) { |
||||
text += extractTextFromSlate(node.children) |
||||
} |
||||
} |
||||
|
||||
return text |
||||
} |
||||
const getAvatar = React.useCallback(async () => { |
||||
try { |
||||
let url = await qortalRequest({ |
||||
action: 'GET_QDN_RESOURCE_URL', |
||||
name: author, |
||||
service: 'THUMBNAIL', |
||||
identifier: 'qortal_avatar' |
||||
}) |
||||
|
||||
setAvatarUrl(url) |
||||
} catch (error) {} |
||||
}, [author]) |
||||
|
||||
React.useEffect(() => { |
||||
getAvatar() |
||||
}, []) |
||||
|
||||
const isFavorite = useMemo(() => { |
||||
if (!favoritesLocal) return false |
||||
return favoritesLocal.find((fav) => fav?.id === blogPost?.id) |
||||
}, [favoritesLocal, blogPost?.id]) |
||||
|
||||
const blockUserFunc = async (user: string) => { |
||||
if (user === 'Q-Blog') return |
||||
if (subscriptions.includes(user) && username) { |
||||
try { |
||||
const listName = `q-blog-subscriptions-${username}` |
||||
|
||||
const response = await qortalRequest({ |
||||
action: 'DELETE_LIST_ITEM', |
||||
list_name: listName, |
||||
item: user |
||||
}) |
||||
if (response === true) { |
||||
dispatch(removeSubscription(user)) |
||||
} |
||||
} catch (error) {} |
||||
} |
||||
|
||||
try { |
||||
const response = await qortalRequest({ |
||||
action: 'ADD_LIST_ITEMS', |
||||
list_name: 'blockedNames_q-blog', |
||||
items: [user] |
||||
}) |
||||
|
||||
if (response === true) { |
||||
dispatch(blockUser(user)) |
||||
dispatch(removeFavorites(blogPost.id)) |
||||
} |
||||
} catch (error) {} |
||||
} |
||||
|
||||
const continueToPost = () => { |
||||
if (isValid === false) { |
||||
setIsOpenAlert(true) |
||||
return |
||||
} |
||||
if (!onClick) return |
||||
onClick() |
||||
} |
||||
|
||||
const handleClose = () => { |
||||
setIsOpenAlert(false) |
||||
} |
||||
|
||||
const dimensions = useMemo(() => { |
||||
if (Array.isArray(tags)) { |
||||
const imgDimensions = tags[tags.length - 2] |
||||
if (!imgDimensions?.includes('v1.')) return '' |
||||
return imgDimensions |
||||
} |
||||
|
||||
return '' |
||||
}, [tags]) |
||||
|
||||
return ( |
||||
<> |
||||
<StyledCard |
||||
onClick={continueToPost} |
||||
onMouseEnter={() => setShowIcons(true)} |
||||
onMouseLeave={() => setShowIcons(false)} |
||||
> |
||||
<ResponsiveImage src={postImage || ''} dimensions={dimensions} /> |
||||
{/* {postImage && ( |
||||
<Box sx={{ padding: '2px' }}> |
||||
<img |
||||
src={postImage} |
||||
style={{ |
||||
width: '100%', |
||||
height: 'auto', |
||||
borderRadius: '8px' |
||||
}} |
||||
/> |
||||
</Box> |
||||
)} */} |
||||
<CardContentContainer> |
||||
<StyledCardHeader |
||||
sx={{ |
||||
'& .MuiCardHeader-content': { |
||||
overflow: 'hidden' |
||||
} |
||||
}} |
||||
> |
||||
<Box> |
||||
<Avatar src={avatarUrl} alt={`${author}'s avatar`} /> |
||||
</Box> |
||||
<StyledCardCol> |
||||
<TitleText |
||||
color={theme.palette.text.primary} |
||||
noWrap |
||||
variant="body1" |
||||
> |
||||
{title} |
||||
</TitleText> |
||||
<AuthorText |
||||
color={ |
||||
theme.palette.mode === 'light' |
||||
? theme.palette.text.secondary |
||||
: '#d6e8ff' |
||||
} |
||||
> |
||||
{author} |
||||
</AuthorText> |
||||
</StyledCardCol> |
||||
</StyledCardHeader> |
||||
<StyledCardContent> |
||||
<Typography |
||||
variant="body2" |
||||
color={theme.palette.text.primary} |
||||
sx={{ |
||||
wordBreak: 'break-word' |
||||
}} |
||||
> |
||||
{description} |
||||
</Typography> |
||||
<Box sx={{ textAlign: 'flex-start', width: '100%' }}> |
||||
<Typography variant="h6" color={theme.palette.text.primary}> |
||||
{formatDate(+createdAt)} |
||||
</Typography> |
||||
</Box> |
||||
</StyledCardContent> |
||||
</CardContentContainer> |
||||
</StyledCard> |
||||
<IconsBox |
||||
sx={{ opacity: showIcons ? 1 : 0 }} |
||||
onMouseEnter={() => setShowIcons(true)} |
||||
onMouseLeave={() => setShowIcons(false)} |
||||
> |
||||
{username && isFavorite && ( |
||||
<Tooltip title="Remove from favorites" placement="top"> |
||||
<BookmarkIconContainer |
||||
onMouseEnter={() => setShowIcons(true)} |
||||
onMouseLeave={() => setShowIcons(false)} |
||||
> |
||||
<BookmarkIcon |
||||
sx={{ |
||||
color: 'red' |
||||
}} |
||||
onClick={() => { |
||||
dispatch(removeFavorites(blogPost.id)) |
||||
}} |
||||
/> |
||||
</BookmarkIconContainer> |
||||
</Tooltip> |
||||
)} |
||||
{username && !isFavorite && ( |
||||
<Tooltip title="Save to favorites" placement="top"> |
||||
<BookmarkIconContainer |
||||
onMouseEnter={() => setShowIcons(true)} |
||||
onMouseLeave={() => setShowIcons(false)} |
||||
> |
||||
<BookmarkBorderIcon |
||||
onClick={() => { |
||||
dispatch(upsertFavorites([blogPost])) |
||||
}} |
||||
/> |
||||
</BookmarkIconContainer> |
||||
</Tooltip> |
||||
)} |
||||
<Tooltip title="Block user content" placement="top"> |
||||
<BlockIconContainer |
||||
onMouseEnter={() => setShowIcons(true)} |
||||
onMouseLeave={() => setShowIcons(false)} |
||||
> |
||||
<BlockIcon |
||||
onClick={() => { |
||||
blockUserFunc(blogPost.user) |
||||
}} |
||||
/> |
||||
</BlockIconContainer> |
||||
</Tooltip> |
||||
</IconsBox> |
||||
|
||||
<Dialog |
||||
open={isOpenAlert} |
||||
onClose={handleClose} |
||||
aria-labelledby="alert-dialog-title" |
||||
aria-describedby="alert-dialog-description" |
||||
> |
||||
<DialogTitle id="alert-dialog-title"> |
||||
Invalid Content Structure |
||||
</DialogTitle> |
||||
<DialogContent> |
||||
<DialogContentText id="alert-dialog-description"> |
||||
This post seems to contain an invalid content structure. Click |
||||
continue to proceed |
||||
</DialogContentText> |
||||
</DialogContent> |
||||
<DialogActions> |
||||
<Button onClick={handleClose}>Close</Button> |
||||
<Button onClick={onClick} autoFocus> |
||||
Continue |
||||
</Button> |
||||
</DialogActions> |
||||
</Dialog> |
||||
</> |
||||
) |
||||
} |
||||
|
||||
export default BlogPostPreview |
@ -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> |
||||
) |
||||
} |
@ -0,0 +1,7 @@
|
||||
import React from 'react' |
||||
|
||||
export const Home = () => { |
||||
return ( |
||||
<div>Home</div> |
||||
) |
||||
} |
@ -0,0 +1,279 @@
|
||||
import React, { |
||||
FC, |
||||
useCallback, |
||||
useEffect, |
||||
useMemo, |
||||
useRef, |
||||
useState |
||||
} from 'react' |
||||
import { useNavigate } from 'react-router-dom' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../../state/store' |
||||
import EditIcon from '@mui/icons-material/Edit' |
||||
import { Box, Button, Input, Typography, useTheme } from '@mui/material' |
||||
import { useFetchPosts } from '../../hooks/useFetchPosts' |
||||
import LazyLoad from '../../components/common/LazyLoad' |
||||
import { removePrefix } from '../../utils/blogIdformats' |
||||
import { NewMessage } from './NewMessage' |
||||
import Tabs from '@mui/material/Tabs' |
||||
import Tab from '@mui/material/Tab' |
||||
import { useFetchMail } from '../../hooks/useFetchMail' |
||||
import { ShowMessage } from './ShowMessage' |
||||
import { fetchAndEvaluateMail } from '../../utils/fetchMail' |
||||
import { addToHashMapMail } from '../../state/features/mailSlice' |
||||
import { |
||||
setIsLoadingGlobal, |
||||
setUserAvatarHash |
||||
} from '../../state/features/globalSlice' |
||||
import SimpleTable from './MailTable' |
||||
import { MAIL_SERVICE_TYPE } from '../../constants/mail' |
||||
import { BlogPost } from '../../state/features/blogSlice' |
||||
|
||||
interface AliasMailProps { |
||||
value: string |
||||
} |
||||
export const AliasMail = ({ value }: AliasMailProps) => { |
||||
const theme = useTheme() |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const [isOpen, setIsOpen] = useState<boolean>(false) |
||||
const [message, setMessage] = useState<any>(null) |
||||
const [replyTo, setReplyTo] = useState<any>(null) |
||||
const [valueTab, setValueTab] = React.useState(0) |
||||
const [aliasValue, setAliasValue] = useState('') |
||||
const [alias, setAlias] = useState<string[]>([]) |
||||
const hashMapPosts = useSelector( |
||||
(state: RootState) => state.blog.hashMapPosts |
||||
) |
||||
const [mailMessages, setMailMessages] = useState<any[]>([]) |
||||
const hashMapMailMessages = useSelector( |
||||
(state: RootState) => state.mail.hashMapMailMessages |
||||
) |
||||
|
||||
const fullMailMessages = useMemo(() => { |
||||
return mailMessages.map((msg) => { |
||||
let message = msg |
||||
const existingMessage = hashMapMailMessages[msg.id] |
||||
if (existingMessage) { |
||||
message = existingMessage |
||||
} |
||||
return message |
||||
}) |
||||
}, [mailMessages, hashMapMailMessages]) |
||||
const dispatch = useDispatch() |
||||
const navigate = useNavigate() |
||||
|
||||
const getAvatar = async (user: string) => { |
||||
try { |
||||
let url = await qortalRequest({ |
||||
action: 'GET_QDN_RESOURCE_URL', |
||||
name: user, |
||||
service: 'THUMBNAIL', |
||||
identifier: 'qortal_avatar' |
||||
}) |
||||
dispatch( |
||||
setUserAvatarHash({ |
||||
name: user, |
||||
url |
||||
}) |
||||
) |
||||
} catch (error) {} |
||||
} |
||||
|
||||
const checkNewMessages = React.useCallback( |
||||
async (recipientName: string, recipientAddress: string) => { |
||||
try { |
||||
const query = `qortal_qmail_${value}_mail` |
||||
const url = `/arbitrary/resources/search?mode=ALL&service=${MAIL_SERVICE_TYPE}&query=${query}&limit=20&includemetadata=true&reverse=true&excludeblocked=true` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
const latestPost = mailMessages[0] |
||||
if (!latestPost) return |
||||
const findPost = responseData?.findIndex( |
||||
(item: any) => item?.identifier === latestPost?.id |
||||
) |
||||
if (findPost === -1) { |
||||
return |
||||
} |
||||
const newArray = responseData.slice(0, findPost) |
||||
const structureData = newArray.map((post: any): BlogPost => { |
||||
return { |
||||
title: post?.metadata?.title, |
||||
category: post?.metadata?.category, |
||||
categoryName: post?.metadata?.categoryName, |
||||
tags: post?.metadata?.tags || [], |
||||
description: post?.metadata?.description, |
||||
createdAt: post?.created, |
||||
updated: post?.updated, |
||||
user: post.name, |
||||
id: post.identifier |
||||
} |
||||
}) |
||||
setMailMessages((prev) => { |
||||
const updatedMessages = [...prev] |
||||
|
||||
structureData.forEach((newMessage: any) => { |
||||
const existingIndex = updatedMessages.findIndex( |
||||
(prevMessage) => prevMessage.id === newMessage.id |
||||
) |
||||
|
||||
if (existingIndex !== -1) { |
||||
// Replace existing message
|
||||
updatedMessages[existingIndex] = newMessage |
||||
} else { |
||||
// Add new message
|
||||
updatedMessages.unshift(newMessage) |
||||
} |
||||
}) |
||||
|
||||
return updatedMessages |
||||
}) |
||||
return |
||||
} catch (error) {} |
||||
}, |
||||
[mailMessages] |
||||
) |
||||
|
||||
const getMailMessages = React.useCallback( |
||||
async (recipientName: string, recipientAddress: string) => { |
||||
try { |
||||
const offset = mailMessages.length |
||||
|
||||
dispatch(setIsLoadingGlobal(true)) |
||||
const query = `qortal_qmail_${value}_mail` |
||||
const url = `/arbitrary/resources/search?mode=ALL&service=${MAIL_SERVICE_TYPE}&query=${query}&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true` |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
} |
||||
}) |
||||
const responseData = await response.json() |
||||
const structureData = responseData.map((post: any): BlogPost => { |
||||
return { |
||||
title: post?.metadata?.title, |
||||
category: post?.metadata?.category, |
||||
categoryName: post?.metadata?.categoryName, |
||||
tags: post?.metadata?.tags || [], |
||||
description: post?.metadata?.description, |
||||
createdAt: post?.created, |
||||
updated: post?.updated, |
||||
user: post.name, |
||||
id: post.identifier |
||||
} |
||||
}) |
||||
setMailMessages((prev) => { |
||||
const updatedMessages = [...prev] |
||||
|
||||
structureData.forEach((newMessage: any) => { |
||||
const existingIndex = updatedMessages.findIndex( |
||||
(prevMessage) => prevMessage.id === newMessage.id |
||||
) |
||||
|
||||
if (existingIndex !== -1) { |
||||
// Replace existing message
|
||||
updatedMessages[existingIndex] = newMessage |
||||
} else { |
||||
// Add new message
|
||||
updatedMessages.push(newMessage) |
||||
} |
||||
}) |
||||
|
||||
return updatedMessages |
||||
}) |
||||
|
||||
for (const content of structureData) { |
||||
if (content.user && content.id) { |
||||
getAvatar(content.user) |
||||
} |
||||
} |
||||
} catch (error) { |
||||
} finally { |
||||
dispatch(setIsLoadingGlobal(false)) |
||||
} |
||||
}, |
||||
[mailMessages, hashMapMailMessages] |
||||
) |
||||
const getMessages = React.useCallback(async () => { |
||||
if (!user?.name || !user?.address) return |
||||
await getMailMessages(user.name, user.address) |
||||
}, [getMailMessages, user]) |
||||
|
||||
const interval = useRef<any>(null) |
||||
|
||||
const checkNewMessagesFunc = useCallback(() => { |
||||
if (!user?.name || !user?.address) return |
||||
let isCalling = false |
||||
interval.current = setInterval(async () => { |
||||
if (isCalling || !user?.name || !user?.address) return |
||||
isCalling = true |
||||
const res = await checkNewMessages(user?.name, user.address) |
||||
isCalling = false |
||||
}, 30000) |
||||
}, [checkNewMessages, user]) |
||||
|
||||
useEffect(() => { |
||||
checkNewMessagesFunc() |
||||
return () => { |
||||
if (interval?.current) { |
||||
clearInterval(interval.current) |
||||
} |
||||
} |
||||
}, [checkNewMessagesFunc]) |
||||
|
||||
const openMessage = async ( |
||||
user: string, |
||||
messageIdentifier: string, |
||||
content: any |
||||
) => { |
||||
try { |
||||
const existingMessage = hashMapMailMessages[messageIdentifier] |
||||
if (existingMessage) { |
||||
setMessage(existingMessage) |
||||
} |
||||
dispatch(setIsLoadingGlobal(true)) |
||||
const res = await fetchAndEvaluateMail({ |
||||
user, |
||||
messageIdentifier, |
||||
content, |
||||
otherUser: user |
||||
}) |
||||
setMessage(res) |
||||
dispatch(addToHashMapMail(res)) |
||||
setIsOpen(true) |
||||
} catch (error) { |
||||
} finally { |
||||
dispatch(setIsLoadingGlobal(false)) |
||||
} |
||||
} |
||||
|
||||
const firstMount = useRef(false) |
||||
useEffect(() => { |
||||
if (user?.name && !firstMount.current) { |
||||
getMessages() |
||||
firstMount.current = true |
||||
} |
||||
}, [user]) |
||||
|
||||
return ( |
||||
<> |
||||
<NewMessage replyTo={replyTo} setReplyTo={setReplyTo} alias={value} /> |
||||
<ShowMessage |
||||
isOpen={isOpen} |
||||
setIsOpen={setIsOpen} |
||||
message={message} |
||||
setReplyTo={setReplyTo} |
||||
alias={value} |
||||
/> |
||||
<SimpleTable |
||||
openMessage={openMessage} |
||||
data={fullMailMessages} |
||||
></SimpleTable> |
||||
<LazyLoad onLoadMore={getMessages}></LazyLoad> |
||||
</> |
||||
) |
||||
} |
@ -0,0 +1,342 @@
|
||||
import React, { |
||||
FC, |
||||
useCallback, |
||||
useEffect, |
||||
useMemo, |
||||
useRef, |
||||
useState |
||||
} from 'react' |
||||
import { useNavigate } from 'react-router-dom' |
||||
import { useDispatch, useSelector } from 'react-redux' |
||||
import { RootState } from '../../state/store' |
||||
import EditIcon from '@mui/icons-material/Edit' |
||||
import CloseIcon from '@mui/icons-material/Close' |
||||
import { |
||||
Box, |
||||
Button, |
||||
Input, |
||||
Typography, |
||||
useTheme, |
||||
IconButton |
||||
} from '@mui/material' |
||||
import { useFetchPosts } from '../../hooks/useFetchPosts' |
||||
import LazyLoad from '../../components/common/LazyLoad' |
||||
import { removePrefix } from '../../utils/blogIdformats' |
||||
import { NewMessage } from './NewMessage' |
||||
import Tabs from '@mui/material/Tabs' |
||||
import Tab from '@mui/material/Tab' |
||||
import { useFetchMail } from '../../hooks/useFetchMail' |
||||
import { ShowMessage } from './ShowMessage' |
||||
import { fetchAndEvaluateMail } from '../../utils/fetchMail' |
||||
import { addToHashMapMail } from '../../state/features/mailSlice' |
||||
import { setIsLoadingGlobal } from '../../state/features/globalSlice' |
||||
import SimpleTable from './MailTable' |
||||
import { AliasMail } from './AliasMail' |
||||
|
||||
export const Mail = () => { |
||||
const theme = useTheme() |
||||
const { user } = useSelector((state: RootState) => state.auth) |
||||
const [isOpen, setIsOpen] = useState<boolean>(false) |
||||
const [message, setMessage] = useState<any>(null) |
||||
const [replyTo, setReplyTo] = useState<any>(null) |
||||
const [valueTab, setValueTab] = React.useState(0) |
||||
const [aliasValue, setAliasValue] = useState('') |
||||
const [alias, setAlias] = useState<string[]>([]) |
||||
const hashMapPosts = useSelector( |
||||
(state: RootState) => state.blog.hashMapPosts |
||||
) |
||||
const hashMapMailMessages = useSelector( |
||||
(state: RootState) => state.mail.hashMapMailMessages |
||||
) |
||||
const mailMessages = useSelector( |
||||
(state: RootState) => state.mail.mailMessages |
||||
) |
||||
|
||||
const fullMailMessages = useMemo(() => { |
||||
return mailMessages.map((msg) => { |
||||
let message = msg |
||||
const existingMessage = hashMapMailMessages[msg.id] |
||||
if (existingMessage) { |
||||
message = existingMessage |
||||
} |
||||
return message |
||||
}) |
||||
}, [mailMessages, hashMapMailMessages]) |
||||
const dispatch = useDispatch() |
||||
const navigate = useNavigate() |
||||
|
||||
const { getMailMessages, checkNewMessages } = useFetchMail() |
||||
const getMessages = React.useCallback(async () => { |
||||
if (!user?.name || !user?.address) return |
||||
await getMailMessages(user.name, user.address) |
||||
}, [getMailMessages, user]) |
||||
|
||||
const interval = useRef<any>(null) |
||||
|
||||
const checkNewMessagesFunc = useCallback(() => { |
||||
if (!user?.name || !user?.address) return |
||||
let isCalling = false |
||||
interval.current = setInterval(async () => { |
||||
if (isCalling || !user?.name || !user?.address) return |
||||
isCalling = true |
||||
const res = await checkNewMessages(user?.name, user.address) |
||||
isCalling = false |
||||
}, 30000) |
||||
}, [checkNewMessages, user]) |
||||
|
||||
useEffect(() => { |
||||
checkNewMessagesFunc() |
||||
return () => { |
||||
if (interval?.current) { |
||||
clearInterval(interval.current) |
||||
} |
||||
} |
||||
}, [checkNewMessagesFunc]) |
||||
|
||||
const openMessage = async ( |
||||
user: string, |
||||
messageIdentifier: string, |
||||
content: any |
||||
) => { |
||||
try { |
||||
const existingMessage = hashMapMailMessages[messageIdentifier] |
||||
if (existingMessage) { |
||||
setMessage(existingMessage) |
||||
} |
||||
dispatch(setIsLoadingGlobal(true)) |
||||
const res = await fetchAndEvaluateMail({ |
||||
user, |
||||
messageIdentifier, |
||||
content, |
||||
otherUser: user |
||||
}) |
||||
setMessage(res) |
||||
dispatch(addToHashMapMail(res)) |
||||
setIsOpen(true) |
||||
} catch (error) { |
||||
} finally { |
||||
dispatch(setIsLoadingGlobal(false)) |
||||
} |
||||
} |
||||
|
||||
const firstMount = useRef(false) |
||||
useEffect(() => { |
||||
if (user?.name && !firstMount.current) { |
||||
getMessages() |
||||
firstMount.current = true |
||||
} |
||||
}, [user]) |
||||
|
||||
function a11yProps(index: number) { |
||||
return { |
||||
id: `mail-tabs-${index}`, |
||||
'aria-controls': `mail-tabs-${index}` |
||||
} |
||||
} |
||||
|
||||
const handleChange = (event: React.SyntheticEvent, newValue: number) => { |
||||
setValueTab(newValue) |
||||
} |
||||
|
||||
function CustomTabLabel({ index, label }: any) { |
||||
return ( |
||||
<div style={{ display: 'flex', alignItems: 'center' }}> |
||||
<span>{label}</span> |
||||
<IconButton |
||||
edge="end" |
||||
color="inherit" |
||||
size="small" |
||||
onClick={(event) => { |
||||
setValueTab(0) |
||||
const newList = [...alias] |
||||
|
||||
newList.splice(index, 1) |
||||
|
||||
setAlias(newList) |
||||
}} |
||||
> |
||||
<CloseIcon fontSize="inherit" /> |
||||
</IconButton> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
width: '100%', |
||||
flexDirection: 'column', |
||||
backgroundColor: 'background.paper' |
||||
}} |
||||
> |
||||
<Box |
||||
sx={{ |
||||
borderBottom: 1, |
||||
borderColor: 'divider', |
||||
display: 'flex', |
||||
width: '100%', |
||||
alignItems: 'center', |
||||
justifyContent: 'flex-start' |
||||
}} |
||||
> |
||||
<Tabs |
||||
value={valueTab} |
||||
onChange={handleChange} |
||||
aria-label="basic tabs example" |
||||
> |
||||
<Tab label={user?.name} {...a11yProps(0)} /> |
||||
{alias.map((alia, index) => { |
||||
return ( |
||||
<Tab |
||||
sx={{ |
||||
'&.Mui-selected': { |
||||
color: theme.palette.text.primary, |
||||
fontWeight: theme.typography.fontWeightMedium |
||||
} |
||||
}} |
||||
key={alia} |
||||
label={<CustomTabLabel index={index} label={alia} />} |
||||
{...a11yProps(1 + index)} |
||||
/> |
||||
) |
||||
})} |
||||
</Tabs> |
||||
<Input |
||||
id="standard-adornment-alias" |
||||
onChange={(e) => { |
||||
setAliasValue(e.target.value) |
||||
}} |
||||
value={aliasValue} |
||||
placeholder="Type in alias" |
||||
sx={{ |
||||
marginLeft: '20px', |
||||
'&&:before': { |
||||
borderBottom: 'none' |
||||
}, |
||||
'&&:after': { |
||||
borderBottom: 'none' |
||||
}, |
||||
'&&:hover:before': { |
||||
borderBottom: 'none' |
||||
}, |
||||
'&&.Mui-focused:before': { |
||||
borderBottom: 'none' |
||||
}, |
||||
'&&.Mui-focused': { |
||||
outline: 'none' |
||||
}, |
||||
fontSize: '18px' |
||||
}} |
||||
/> |
||||
<Button |
||||
onClick={() => { |
||||
setAlias((prev) => [...prev, aliasValue]) |
||||
setAliasValue('') |
||||
}} |
||||
variant="contained" |
||||
> |
||||
+ alias |
||||
</Button> |
||||
</Box> |
||||
<NewMessage replyTo={replyTo} setReplyTo={setReplyTo} /> |
||||
<ShowMessage |
||||
isOpen={isOpen} |
||||
setIsOpen={setIsOpen} |
||||
message={message} |
||||
setReplyTo={setReplyTo} |
||||
/> |
||||
{/* {countNewPosts > 0 && ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center' |
||||
}} |
||||
> |
||||
<Typography> |
||||
{countNewPosts === 1 |
||||
? `There is ${countNewPosts} new message` |
||||
: `There are ${countNewPosts} new messages`} |
||||
</Typography> |
||||
<Button |
||||
sx={{ |
||||
backgroundColor: theme.palette.primary.light, |
||||
color: theme.palette.text.primary, |
||||
fontFamily: 'Arial' |
||||
}} |
||||
onClick={getNewPosts} |
||||
> |
||||
Load new Posts |
||||
</Button> |
||||
</Box> |
||||
)} */} |
||||
<TabPanel value={valueTab} index={0}> |
||||
<SimpleTable |
||||
openMessage={openMessage} |
||||
data={fullMailMessages} |
||||
></SimpleTable> |
||||
<LazyLoad onLoadMore={getMessages}></LazyLoad> |
||||
</TabPanel> |
||||
{alias.map((alia, index) => { |
||||
return ( |
||||
<TabPanel key={alia} value={valueTab} index={1 + index}> |
||||
<AliasMail value={alia} /> |
||||
</TabPanel> |
||||
) |
||||
})} |
||||
|
||||
{/* <Box> |
||||
{mailMessages.map((message, index) => { |
||||
const existingMessage = hashMapMailMessages[message.id] |
||||
let mailMessage = message |
||||
if (existingMessage) { |
||||
mailMessage = existingMessage |
||||
} |
||||
return ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
gap: 1, |
||||
alignItems: 'center', |
||||
width: 'auto', |
||||
position: 'relative', |
||||
' @media (max-width: 450px)': { |
||||
width: '100%' |
||||
} |
||||
}} |
||||
key={mailMessage.id} |
||||
> |
||||
hello |
||||
</Box> |
||||
) |
||||
})} |
||||
</Box> */} |
||||
</Box> |
||||
) |
||||
} |
||||
|
||||
interface TabPanelProps { |
||||
children?: React.ReactNode |
||||
index: number |
||||
value: number |
||||
} |
||||
|
||||
export function TabPanel(props: TabPanelProps) { |
||||
const { children, value, index, ...other } = props |
||||
|
||||
return ( |
||||
<div |
||||
role="tabpanel" |
||||
hidden={value !== index} |
||||
id={`mail-tabs-${index}`} |
||||
aria-labelledby={`mail-tabs-${index}`} |
||||
{...other} |
||||
style={{ |
||||
width: '100%' |
||||
}} |
||||
> |
||||
{value === index && children} |
||||
</div> |
||||
) |
||||
} |