Initial q-blog commit in its own repo
24
.gitignore
vendored
Normal file
@ -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?
|
10
.prettierrc.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"tabWidth": 2,
|
||||
"semi": true
|
||||
}
|
14
index.html
Normal file
@ -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>
|
6695
package-lock.json
generated
Normal file
54
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
67
src/App.tsx
Normal file
@ -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
|
BIN
src/assets/img/arrr.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/img/btc.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/img/dgb.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
src/assets/img/doge.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
src/assets/img/ltc.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/img/qBlogLogo.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
src/assets/img/qort.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/img/rvn.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
25
src/assets/svgs/AccountCircleSVG.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
interface AccountCircleSVGProps {
|
||||
color: string
|
||||
height: string
|
||||
width: string
|
||||
}
|
||||
|
||||
export const AccountCircleSVG: React.FC<AccountCircleSVGProps> = ({
|
||||
color,
|
||||
height,
|
||||
width
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={height}
|
||||
viewBox="0 96 960 960"
|
||||
width={width}
|
||||
>
|
||||
<path
|
||||
fill={color}
|
||||
d="M222 801q63-44 125-67.5T480 710q71 0 133.5 23.5T739 801q44-54 62.5-109T820 576q0-145-97.5-242.5T480 236q-145 0-242.5 97.5T140 576q0 61 19 116t63 109Zm257.814-195Q422 606 382.5 566.314q-39.5-39.686-39.5-97.5t39.686-97.314q39.686-39.5 97.5-39.5t97.314 39.686q39.5 39.686 39.5 97.5T577.314 566.5q-39.686 39.5-97.5 39.5Zm.654 370Q398 976 325 944.5q-73-31.5-127.5-86t-86-127.266Q80 658.468 80 575.734T111.5 420.5q31.5-72.5 86-127t127.266-86q72.766-31.5 155.5-31.5T635.5 207.5q72.5 31.5 127 86t86 127.032q31.5 72.532 31.5 155T848.5 731q-31.5 73-86 127.5t-127.032 86q-72.532 31.5-155 31.5ZM480 916q55 0 107.5-16T691 844q-51-36-104-55t-107-19q-54 0-107 19t-104 55q51 40 103.5 56T480 916Zm0-370q34 0 55.5-21.5T557 469q0-34-21.5-55.5T480 392q-34 0-55.5 21.5T403 469q0 34 21.5 55.5T480 546Zm0-77Zm0 374Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
21
src/assets/svgs/AlignCenterSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/AlignLeftSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/AlignRightSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/BoldSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/CodeBlockSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/H2SVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/H3SVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/ItalicSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/LinkSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
25
src/assets/svgs/NewWindowSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
17
src/assets/svgs/UnderlineSVG.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
1
src/assets/svgs/accountCircle.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path 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>
|
After Width: | Height: | Size: 896 B |
5
src/assets/svgs/interfaces.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface SVGProps {
|
||||
color: string
|
||||
height: string
|
||||
width: string
|
||||
}
|
230
src/components/AudioElement.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
96
src/components/DynamicHeightItem.tsx
Normal file
@ -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
|
39
src/components/DynamicHeightItemMinimal.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
445
src/components/FileElement.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
253
src/components/common/AudioPanel.tsx
Normal file
@ -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
|
||||
}
|
192
src/components/common/AudioPlayer.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
366
src/components/common/AudioPublishModal.tsx
Normal file
@ -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,
|
||||
}));
|
100
src/components/common/BlockedNamesModal/BlockedNamesModal.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Modal,
|
||||
Typography,
|
||||
SelectChangeEvent,
|
||||
ListItem,
|
||||
List,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
import {
|
||||
StyledModal,
|
||||
ModalContent,
|
||||
ModalText
|
||||
} from './BlockedNamesModal-styles'
|
||||
|
||||
interface PostModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const BlockedNamesModal: React.FC<PostModalProps> = ({
|
||||
open,
|
||||
onClose
|
||||
}) => {
|
||||
const [blockedNames, setBlockedNames] = useState<string[]>([])
|
||||
const theme = useTheme()
|
||||
const getBlockedNames = React.useCallback(async () => {
|
||||
try {
|
||||
const listName = `blockedNames_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>
|
||||
)
|
||||
}
|
336
src/components/common/Comments/Comment.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
258
src/components/common/Comments/CommentEditor.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
386
src/components/common/Comments/CommentSection.tsx
Normal file
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
82
src/components/common/ContextMenu/ContextMenuResource.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
16
src/components/common/CustomIcon.tsx
Normal file
@ -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} />
|
||||
}
|
289
src/components/common/DownloadTaskManager.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
55
src/components/common/DraggableResizableGrid.tsx
Normal file
@ -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
|
36
src/components/common/ErrorBoundary.tsx
Normal file
@ -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
|
257
src/components/common/FilePanel.tsx
Normal file
@ -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
|
||||
}
|
317
src/components/common/GenericPublishModal.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
89
src/components/common/ImageUploader.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { Box, Button, TextField, Typography, Modal } from '@mui/material'
|
||||
import {
|
||||
useDropzone,
|
||||
DropzoneRootProps,
|
||||
DropzoneInputProps
|
||||
} from 'react-dropzone'
|
||||
import Compressor from 'compressorjs'
|
||||
|
||||
const toBase64 = (file: File): Promise<string | ArrayBuffer | null> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = (error) => {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
interface ImageUploaderProps {
|
||||
children: React.ReactNode
|
||||
onPick: (base64Img: string) => void
|
||||
}
|
||||
|
||||
const ImageUploader: React.FC<ImageUploaderProps> = ({ children, onPick }) => {
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 1) {
|
||||
return
|
||||
}
|
||||
let compressedFile: File | undefined
|
||||
|
||||
try {
|
||||
const image = acceptedFiles[0]
|
||||
await new Promise<void>((resolve) => {
|
||||
new Compressor(image, {
|
||||
quality: 0.6,
|
||||
maxWidth: 1200,
|
||||
mimeType: 'image/webp',
|
||||
success(result) {
|
||||
const file = new File([result], 'name', {
|
||||
type: 'image/webp'
|
||||
})
|
||||
compressedFile = file
|
||||
resolve()
|
||||
},
|
||||
error(err) {}
|
||||
})
|
||||
})
|
||||
if (!compressedFile) return
|
||||
const base64Img = await toBase64(compressedFile)
|
||||
|
||||
onPick(base64Img as string)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
[onPick]
|
||||
)
|
||||
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive
|
||||
}: {
|
||||
getRootProps: () => DropzoneRootProps
|
||||
getInputProps: () => DropzoneInputProps
|
||||
isDragActive: boolean
|
||||
} = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': []
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUploader
|
47
src/components/common/LazyLoad.tsx
Normal file
@ -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
|
86
src/components/common/Notification/Notification.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { toast, ToastContainer, Zoom, Slide } from 'react-toastify'
|
||||
import { removeNotification } from '../../../state/features/notificationsSlice'
|
||||
import 'react-toastify/dist/ReactToastify.css'
|
||||
import { RootState } from '../../../state/store'
|
||||
|
||||
const Notification = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const { alertTypes } = useSelector((state: RootState) => state.notifications)
|
||||
|
||||
if (alertTypes.alertError) {
|
||||
toast.error(`❌ ${alertTypes?.alertError}`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 4000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
icon: false
|
||||
})
|
||||
dispatch(removeNotification())
|
||||
}
|
||||
if (alertTypes.alertSuccess) {
|
||||
toast.success(`✔️ ${alertTypes?.alertSuccess}`, {
|
||||
position: 'bottom-right',
|
||||
autoClose: 4000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
icon: false
|
||||
})
|
||||
dispatch(removeNotification())
|
||||
}
|
||||
if (alertTypes.alertInfo) {
|
||||
toast.info(`${alertTypes?.alertInfo}`, {
|
||||
position: 'top-right',
|
||||
autoClose: 1300,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
theme: 'light'
|
||||
})
|
||||
dispatch(removeNotification())
|
||||
}
|
||||
|
||||
if (alertTypes.alertInfo) {
|
||||
return (
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={2000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
toastStyle={{ fontSize: '16px' }}
|
||||
transition={Slide}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContainer
|
||||
transition={Zoom}
|
||||
position="bottom-right"
|
||||
autoClose={false}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
draggable
|
||||
pauseOnHover
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Notification
|
43
src/components/common/PageLoader.tsx
Normal file
@ -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;
|
25
src/components/common/Portal.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
interface PortalProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Portal: React.FC<PortalProps> = ({ children }) => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
return () => setMounted(false)
|
||||
}, [])
|
||||
|
||||
return mounted
|
||||
? createPortal(
|
||||
children,
|
||||
document.querySelector('#modal-root') as HTMLElement
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
export default Portal
|
281
src/components/common/PostPublishModal.tsx
Normal file
@ -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
|
111
src/components/common/PublishAudio.tsx
Normal file
@ -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
|
||||
}
|
||||
}
|
120
src/components/common/PublishGeneric.tsx
Normal file
@ -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
|
||||
}
|
||||
}
|
112
src/components/common/PublishVideo.tsx
Normal file
@ -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
|
||||
}
|
||||
}
|
124
src/components/common/ResponsiveImage.tsx
Normal file
@ -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
|
289
src/components/common/Tipping/Tipping.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
55
src/components/common/UserNavbar/UserNavbar-styles.ts
Normal file
@ -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)"
|
||||
}
|
||||
}))
|
135
src/components/common/UserNavbar/UserNavbar.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
51
src/components/common/VideoContent.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
284
src/components/common/VideoPanel.tsx
Normal file
@ -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
|
||||
}
|
832
src/components/common/VideoPlayer.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
287
src/components/common/VideoPublishModal.tsx
Normal file
@ -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
|
78
src/components/editor/BlogEditor.css
Normal file
@ -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;
|
||||
}
|
||||
|
574
src/components/editor/BlogEditor.tsx
Normal file
@ -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>
|
||||
}
|
25
src/components/editor/ReadOnlySlate.tsx
Normal file
@ -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;
|
47
src/components/editor/customTypes.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
112
src/components/layout/Navbar/Navbar-styles.ts
Normal file
@ -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",
|
||||
}));
|
490
src/components/layout/Navbar/Navbar.tsx
Normal file
@ -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
|
70
src/components/modals/ConsentModal.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
247
src/components/modals/EditBlogModal.tsx
Normal file
@ -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
|
281
src/components/modals/PublishBlogModal.tsx
Normal file
@ -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
|
47
src/components/modals/ReusableModal.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
3
src/constants/mail.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const MAIL_SERVICE_TYPE: 'MAIL_PRIVATE' = 'MAIL_PRIVATE'
|
||||
export const MAIL_ATTACHMENT_SERVICE_TYPE: 'ATTACHMENT_PRIVATE' =
|
||||
'ATTACHMENT_PRIVATE'
|
61
src/global.d.ts
vendored
Normal file
@ -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>
|
||||
}
|
||||
}
|
469
src/hooks/useFetchMail.tsx
Normal file
@ -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
|
||||
}
|
||||
}
|
362
src/hooks/useFetchPosts.tsx
Normal file
@ -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
|
||||
}
|
||||
}
|
162
src/index.css
Normal file
@ -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;
|
||||
}
|
9
src/index.d.ts
vendored
Normal file
@ -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
|
||||
}
|
8
src/interfaces/interfaces.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface BlogContent {
|
||||
postContent: any[]
|
||||
title: string
|
||||
createdAt: number
|
||||
user?: any
|
||||
postId?: string
|
||||
layouts?: any
|
||||
}
|
19
src/main.tsx
Normal file
@ -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>
|
||||
)
|
951
src/pages/BlogIndividualPost/BlogIndividualPost.tsx
Normal file
@ -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
|
||||
}
|
301
src/pages/BlogIndividualProfile/BlogIndividualProfile.tsx
Normal file
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
225
src/pages/BlogList/BlogList.tsx
Normal file
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
134
src/pages/BlogList/PostPreview-styles.ts
Normal file
@ -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)",
|
||||
}
|
||||
})
|
320
src/pages/BlogList/PostPreview.tsx
Normal file
@ -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
|
7
src/pages/CreateEditProfile/CreatEditProfile.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
export const CreatEditProfile = () => {
|
||||
return (
|
||||
<div>CreatEditProfile</div>
|
||||
)
|
||||
}
|
14
src/pages/CreatePost/CreatePost-styles.ts
Normal file
@ -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)"
|
||||
}
|
||||
}));
|
194
src/pages/CreatePost/CreatePost.tsx
Normal file
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
1409
src/pages/CreatePost/CreatePostBuilder.tsx
Normal file
1390
src/pages/CreatePost/CreatePostMinimal.tsx
Normal file
261
src/pages/CreatePost/components/Navbar/NavbarBuilder.tsx
Normal file
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
157
src/pages/CreatePost/components/Toolbar/EditorToolbar.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
562
src/pages/EditPost/EditPost.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
7
src/pages/Home/Home.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
export const Home = () => {
|
||||
return (
|
||||
<div>Home</div>
|
||||
)
|
||||
}
|
279
src/pages/Mail/AliasMail.tsx
Normal file
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
342
src/pages/Mail/Mail.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|