mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 10:34:17 +00:00
merge from local
This commit is contained in:
parent
f871dd3c83
commit
93638f0fa3
41
packages/app/config/context-menu/default/index.js
Normal file
41
packages/app/config/context-menu/default/index.js
Normal file
@ -0,0 +1,41 @@
|
||||
export default {
|
||||
"default-context": (items) => {
|
||||
const text = window.getSelection().toString()
|
||||
|
||||
if (text) {
|
||||
items.push({
|
||||
label: "Copy",
|
||||
icon: "Copy",
|
||||
action: (clickedItem, ctx) => {
|
||||
copyToClipboard(text)
|
||||
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: "Paste",
|
||||
icon: "Clipboard",
|
||||
action: (clickedItem, ctx) => {
|
||||
app.message.error("This action is not supported by your browser")
|
||||
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Report a bug",
|
||||
icon: "AlertTriangle",
|
||||
action: (clickedItem, ctx) => {
|
||||
app.eventBus.emit("app.reportBug", {
|
||||
clickedItem,
|
||||
})
|
||||
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
import download from "@utils/download"
|
||||
import copyToClipboard from "@utils/copyToClipboard"
|
||||
|
||||
export default {
|
||||
"default-context": () => {
|
||||
const items = []
|
||||
|
||||
const text = window.getSelection().toString()
|
||||
|
||||
if (text) {
|
||||
items.push({
|
||||
label: "Copy",
|
||||
icon: "Copy",
|
||||
action: (clickedItem, ctx) => {
|
||||
copyToClipboard(text)
|
||||
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: "Paste",
|
||||
icon: "Clipboard",
|
||||
action: (clickedItem, ctx) => {
|
||||
app.message.error("This action is not supported by your browser")
|
||||
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Report a bug",
|
||||
icon: "AlertTriangle",
|
||||
action: (clickedItem, ctx) => {
|
||||
app.eventBus.emit("app.reportBug", {
|
||||
clickedItem,
|
||||
})
|
||||
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
},
|
||||
"postCard-context": (parent, element, control) => {
|
||||
const items = [
|
||||
{
|
||||
label: "Copy ID",
|
||||
icon: "Copy",
|
||||
action: () => {
|
||||
copyToClipboard(parent.id)
|
||||
control.close()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Copy Link",
|
||||
icon: "Link",
|
||||
action: () => {
|
||||
copyToClipboard(`${window.location.origin}/post/${parent.id}`)
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
let media = null
|
||||
|
||||
// if element div has `addition` class, search inside it for video or image
|
||||
if (element.classList.contains("addition") || element.classList.contains("image-wrapper")) {
|
||||
media = element.querySelector("video, img")
|
||||
}
|
||||
|
||||
// if element div has `plyr__poster` class, search outside it for video or image
|
||||
if (element.classList.contains("plyr__poster")) {
|
||||
console.log(element.parentElement)
|
||||
media = element.parentElement.querySelector("video")
|
||||
}
|
||||
|
||||
// if media is found, and is a video, search for the source
|
||||
if (media && media.tagName === "VIDEO") {
|
||||
media = media.querySelector("source")
|
||||
}
|
||||
|
||||
if (media) {
|
||||
items.push({
|
||||
type: "separator"
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Copy media URL",
|
||||
icon: "Copy",
|
||||
action: () => {
|
||||
copyToClipboard(media.src)
|
||||
control.close()
|
||||
}
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Open media in new tab",
|
||||
icon: "ExternalLink",
|
||||
action: () => {
|
||||
window.open(media.src, "_blank")
|
||||
control.close()
|
||||
}
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Download media",
|
||||
icon: "Download",
|
||||
action: () => {
|
||||
download(media.src)
|
||||
control.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
70
packages/app/config/context-menu/post/index.js
Normal file
70
packages/app/config/context-menu/post/index.js
Normal file
@ -0,0 +1,70 @@
|
||||
import copyToClipboard from "@utils/copyToClipboard"
|
||||
import download from "@utils/download"
|
||||
|
||||
export default {
|
||||
"post-card": (items, parent, element, control) => {
|
||||
items.push({
|
||||
label: "Copy ID",
|
||||
icon: "Copy",
|
||||
action: () => {
|
||||
copyToClipboard(parent.id)
|
||||
control.close()
|
||||
}
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Copy Link",
|
||||
icon: "Link",
|
||||
action: () => {
|
||||
copyToClipboard(`${window.location.origin}/post/${parent.id}`)
|
||||
control.close()
|
||||
}
|
||||
})
|
||||
|
||||
let media = null
|
||||
|
||||
if (parent.querySelector(".attachment")) {
|
||||
media = parent.querySelector(".attachment")
|
||||
media = media.querySelector("video, img")
|
||||
|
||||
if (media.querySelector("source")) {
|
||||
media = media.querySelector("source")
|
||||
}
|
||||
}
|
||||
|
||||
if (media) {
|
||||
items.push({
|
||||
type: "separator",
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Copy media URL",
|
||||
icon: "Copy",
|
||||
action: () => {
|
||||
copyToClipboard(media.src)
|
||||
control.close()
|
||||
}
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Open media in new tab",
|
||||
icon: "ExternalLink",
|
||||
action: () => {
|
||||
window.open(media.src, "_blank")
|
||||
control.close()
|
||||
}
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: "Download media",
|
||||
icon: "Download",
|
||||
action: () => {
|
||||
download(media.src)
|
||||
control.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
@ -40,11 +40,12 @@
|
||||
"@mui/material": "^5.11.9",
|
||||
"@ragestudio/cordova-nfc": "^1.2.0",
|
||||
"@sentry/browser": "^7.64.0",
|
||||
"@tanstack/react-virtual": "^3.5.0",
|
||||
"@tauri-apps/api": "^1.5.4",
|
||||
"@tsmx/human-readable": "^1.0.7",
|
||||
"antd": "^5.6.4",
|
||||
"antd": "^5.17.0",
|
||||
"antd-mobile": "^5.31.0",
|
||||
"axios": "^1.4.0",
|
||||
"axios": "^1.6.8",
|
||||
"bear-react-carousel": "^4.0.10-alpha.0",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-music-controls-plugin-v3": "^1.1.0",
|
||||
@ -65,7 +66,7 @@
|
||||
"lottie-react": "^2.4.0",
|
||||
"lru-cache": "^10.0.0",
|
||||
"luxon": "^3.0.4",
|
||||
"million": "^2.5.4-beta.1",
|
||||
"million": "^2.6.4",
|
||||
"mime": "^3.0.0",
|
||||
"moment": "2.29.4",
|
||||
"mpegts.js": "^1.6.10",
|
||||
@ -95,61 +96,15 @@
|
||||
"remark-gfm": "^3.0.1",
|
||||
"rxjs": "^7.5.5",
|
||||
"store": "^2.0.12",
|
||||
"ua-parser-js": "^1.0.36"
|
||||
"ua-parser-js": "^1.0.36",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^2.0.4",
|
||||
"@electron-forge/cli": "^6.0.0-beta.66",
|
||||
"@electron-forge/maker-deb": "^6.0.0-beta.66",
|
||||
"@electron-forge/maker-rpm": "^6.0.0-beta.66",
|
||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.66",
|
||||
"@electron-forge/maker-zip": "^6.0.0-beta.66",
|
||||
"@electron-forge/plugin-vite": "^6.4.2",
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/node": "^16.4.10",
|
||||
"@types/react": "^17.0.15",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/react-router-config": "^5.0.3",
|
||||
"@types/react-router-dom": "^5.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.0",
|
||||
"concurrently": "^7.4.0",
|
||||
"cors": "2.8.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^21.0.1",
|
||||
"electron-builder": "^24.6.4",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"express": "^4.17.1",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"config": {
|
||||
"forge": {
|
||||
"packagerConfig": {},
|
||||
"makers": [
|
||||
{
|
||||
"name": "@electron-forge/maker-squirrel",
|
||||
"config": {
|
||||
"name": "comty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "@electron-forge/maker-zip",
|
||||
"platforms": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@electron-forge/maker-deb",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"name": "@electron-forge/maker-rpm",
|
||||
"config": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
42
packages/app/public/dev-logo_alt.svg
Normal file
42
packages/app/public/dev-logo_alt.svg
Normal file
@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="COMTY" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.35 72">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #4b6fb5;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #6f8cc4;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #b7c5e1;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #93a9d3;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="ISO-COMPOSED">
|
||||
<path id="C-BODY" class="cls-1" d="M64.43,19.59s-7.75,4.48-11.22,6.48c-.89.51-2.02.25-2.62-.59-4.54-6.28-13.01-9.53-21.82-6.06-4.07,1.6-7.38,4.73-9.1,8.75-5.63,13.16,3.87,25.83,16.34,25.83,4.31,0,8.26-1.51,11.36-4.04.81-.65,1.97-.62,2.71.11l9.92,9.93c.82.81.78,2.15-.08,2.92-9.71,8.63-24.13,12.09-38.92,6.04C9.38,64.21,0,49.7,0,37.14V2C0,.9.9,0,2.01,0h34c12.56,0,23.6,6.44,30.04,16.19.63.97.33,2.27-1.62,3.4Z"/>
|
||||
<g id="C-MASK" class="cls-5">
|
||||
<path class="cls-3" d="M18,0v18H0V2C0,.9.9,0,2.01,0h15.99Z"/>
|
||||
<rect class="cls-4" x="18" y=".01" width="18" height="18"/>
|
||||
<path class="cls-2" d="M54,4.84C48.77,1.81,42.71.05,36.24.01h-.24v18h18V4.84Z"/>
|
||||
<rect class="cls-4" y="17.97" width="18" height="18"/>
|
||||
<path class="cls-2" d="M18,35.97H0v1.18C0,42.71,1.85,48.65,4.91,53.97h13.09v-18Z"/>
|
||||
<path class="cls-2" d="M21.23,25.16c.96-1.57,2.28-2.9,3.85-3.87,3.71-2.3,7.43-3.26,10.92-3.26v-.05h-18v18h.02c0-3.46.97-7.15,3.21-10.81Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="cls-1" d="M40.45,23.38c-10.75-3.5-20.54,6.29-17.04,17.04,1.25,3.84,4.3,6.88,8.13,8.13,10.75,3.5,20.54-6.29,17.04-17.04-1.25-3.84-4.3-6.88-8.13-8.13ZM29.64,42.33h0c-.95-.95-.95-2.48,0-3.42l9.31-9.31c.95-.95,2.48-.95,3.42,0h0c.95.95.95,2.48,0,3.42l-9.31,9.31c-.95.95-2.48.95-3.42,0Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
72
packages/app/public/dev-logo_full.svg
Normal file
72
packages/app/public/dev-logo_full.svg
Normal file
@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="COMTY" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 292.9 72" style="enable-background:new 0 0 292.9 72;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#4B6FB5;}
|
||||
.st1{display:none;}
|
||||
.st2{display:inline;fill:#FF6064;}
|
||||
.st3{display:inline;fill:#FF8083;}
|
||||
.st4{display:inline;fill:#FFA0A2;}
|
||||
.st5{display:inline;fill:#FFBFC1;}
|
||||
.st6{display:inline;}
|
||||
.st7{opacity:0.65;fill:#FF7C8C;}
|
||||
.st8{opacity:0.65;fill:#FF4971;}
|
||||
.st9{opacity:0.65;fill:#FF4457;}
|
||||
.st10{opacity:0.5;}
|
||||
.st11{fill:#B7C5E1;}
|
||||
.st12{fill:#93A9D3;}
|
||||
.st13{fill:#6F8CC4;}
|
||||
</style>
|
||||
<path id="Y-BODY" class="st0" d="M262,72h-13.6c-1.1,0-2-0.9-2-2v-9.5c0-1.1,0.9-2,2-2h5.8c0.7,0,1.4-0.4,1.7-1l5.3-9.2
|
||||
c0.4-0.6,0.4-1.4,0-2L246.7,21c-0.8-1.3,0.2-3,1.7-3h11c0.7,0,1.4,0.4,1.7,1l6.8,11.8c0.8,1.3,2.7,1.3,3.5,0l6.8-11.8
|
||||
c0.4-0.6,1-1,1.7-1h11c1.5,0,2.5,1.7,1.7,3l-28.9,50C263.4,71.6,262.7,72,262,72z"/>
|
||||
<path id="T-BODY" class="st0" d="M240,18h-7.2c-1.1,0-2-0.9-2-2V2c0-1.1-0.9-2-2-2h-9.5c-1.1,0-2,0.9-2,2v14c0,1.1-0.9,2-2,2H208
|
||||
c-1.1,0-2,0.9-2,2v9.5c0,1.1,0.9,2,2,2h7.2c1.1,0,2,0.9,2,2V70c0,1.1,0.9,2,2,2h9.5c1.1,0,2-0.9,2-2V33.5c0-1.1,0.9-2,2-2h7.2
|
||||
c1.1,0,2-0.9,2-2V20C242,18.9,241.1,18,240,18z"/>
|
||||
<path id="M-BODY" class="st0" d="M201.5,40.5L201.5,40.5c0-12.4-10.1-22.5-22.5-22.5c-5.5,0-10.6,2-14.5,5.3c-0.7,0.6-1.8,0.6-2.6,0
|
||||
c-3.9-3.3-9-5.3-14.5-5.3c-2.8,0-5.5,0.5-8,1.5c-0.5,0.2-1.1-0.2-1.1-0.7v0c0-0.4-0.3-0.8-0.8-0.8h-10.7c-1.1,0-2,0.9-2,2v50
|
||||
c0,1.1,0.9,2,2,2h9.1h0.4h0.1c1.1,0,2-0.9,2-2V40.5c0-5,4-9,9-9c4.9,0,8.9,4,9,8.9V70c0,1.1,0.9,2,2,2h0.1h0.4h8.7h0.4h0
|
||||
c1.1,0,2-0.9,2-2V40.5h0c0-5,4-9,9-9c4.9,0,9,4,9,8.9V70c0,1.1,0.9,2,2,2h0.1h0.4h9c1.1,0,2-0.9,2-2V40.5z"/>
|
||||
<path id="O-BODY" class="st0" d="M93.4,18c-14.9,0-27,12.1-27,27s12.1,27,27,27s27-12.1,27-27S108.3,18,93.4,18z M93.4,58.5
|
||||
c-7.5,0-13.5-6-13.5-13.5c0-7.5,6-13.5,13.5-13.5c7.5,0,13.5,6,13.5,13.5C106.9,52.4,100.8,58.5,93.4,58.5z"/>
|
||||
<g id="Colors" class="st1">
|
||||
<rect x="0.1" y="-27.5" class="st2" width="22.5" height="22.5"/>
|
||||
<rect x="22.5" y="-27.5" class="st3" width="22.5" height="22.5"/>
|
||||
<rect x="45" y="-27.5" class="st4" width="22.5" height="22.5"/>
|
||||
<rect x="67.5" y="-27.5" class="st5" width="22.5" height="22.5"/>
|
||||
</g>
|
||||
<g id="old_x5F_iso" class="st1">
|
||||
<path id="C_00000029753336008474061920000012106504942734039743_" class="st2" d="M129.4-23c-12.5,0-22-12.7-16.3-25.8
|
||||
c1.7-4,5-7.1,9.1-8.7c8.8-3.5,17.3-0.2,21.8,6.1c0.6,0.8,1.7,1.1,2.6,0.6l11.2-6.5c1.9-1.1,2.2-2.4,1.6-3.4
|
||||
c-6.4-9.7-17.5-16.2-30-16.2h-34c-1.1,0-2,0.9-2,2l0,35.1c0,12.6,9.4,27.1,21,31.8c14.8,6,29.2,2.6,38.9-6c0.9-0.8,0.9-2.1,0.1-2.9
|
||||
l-9.9-9.9c-0.7-0.7-1.9-0.8-2.7-0.1C137.6-24.5,133.7-23,129.4-23z"/>
|
||||
<g id="_x2D__00000003068151388543984340000009125836902913508246_" class="st6">
|
||||
<path class="st7" d="M111.4-76.9H95.3c-1.1,0-1.9,0.9-1.9,2v16h18V-76.9z"/>
|
||||
<rect x="111.4" y="-76.9" class="st8" width="18" height="18"/>
|
||||
<path class="st9" d="M147.3-72.1c-5.2-3-11.3-4.8-17.8-4.8h-0.2v18h16c1.1,0,2-0.9,2-2V-72.1z"/>
|
||||
<rect x="93.4" y="-59" class="st8" width="18" height="18"/>
|
||||
<path class="st9" d="M111.4-41h-18v1.2c0,5.6,1.8,11.5,4.9,16.8h11.1c1.1,0,2-0.9,2-2V-41z"/>
|
||||
<path class="st9" d="M113-48.8c1.7-4,5-7.1,9.1-8.7c2.5-1,4.9-1.4,7.2-1.4V-59h-18v18h0C111.4-43.5,111.9-46.2,113-48.8z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g id="ISO-COMPOSED_00000069363719166014630790000000862934890392767654_">
|
||||
<path id="C-BODY_00000127745992915324767630000016197953627436916150_" class="st0" d="M64.4,19.6c0,0-7.8,4.5-11.2,6.5
|
||||
c-0.9,0.5-2,0.3-2.6-0.6c-4.5-6.3-13-9.5-21.8-6.1c-4.1,1.6-7.4,4.7-9.1,8.8C14,41.3,23.5,54,36,54c4.3,0,8.3-1.5,11.4-4
|
||||
c0.8-0.6,2-0.6,2.7,0.1L60,60c0.8,0.8,0.8,2.1-0.1,2.9C50.2,71.6,35.8,75,21,69C9.4,64.2,0,49.7,0,37.1V2c0-1.1,0.9-2,2-2h34
|
||||
c12.6,0,23.6,6.4,30,16.2C66.7,17.2,66.4,18.5,64.4,19.6z"/>
|
||||
<g id="C-MASK_00000045611387297072415010000003497810413074703294_" class="st10">
|
||||
<path class="st11" d="M18,0v18H0V2c0-1.1,0.9-2,2-2H18z"/>
|
||||
<rect x="18" y="0" class="st12" width="18" height="18"/>
|
||||
<path class="st13" d="M54,4.8c-5.2-3-11.3-4.8-17.8-4.8H36v18h18V4.8z"/>
|
||||
<rect y="18" class="st12" width="18" height="18"/>
|
||||
<path class="st13" d="M18,36H0v1.2C0,42.7,1.8,48.7,4.9,54H18V36z"/>
|
||||
<path class="st13" d="M21.2,25.2c1-1.6,2.3-2.9,3.8-3.9C28.8,19,32.5,18,36,18V18H18v18h0C18,32.5,19,28.8,21.2,25.2z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="st0" d="M40.5,23.4c-10.7-3.5-20.5,6.3-17,17c1.2,3.8,4.3,6.9,8.1,8.1c10.7,3.5,20.5-6.3,17-17
|
||||
C47.3,27.7,44.3,24.6,40.5,23.4z M29.6,42.3L29.6,42.3c-0.9-0.9-0.9-2.5,0-3.4l9.3-9.3c0.9-0.9,2.5-0.9,3.4,0l0,0
|
||||
c0.9,0.9,0.9,2.5,0,3.4l-9.3,9.3C32.1,43.3,30.6,43.3,29.6,42.3z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 4.6 KiB |
@ -28,7 +28,6 @@ import {
|
||||
NotificationsCenter,
|
||||
PostCreator,
|
||||
} from "@components"
|
||||
import { DOMWindow } from "@components/RenderWindow"
|
||||
import { Icons } from "@components/Icons"
|
||||
import DesktopTopBar from "@components/DesktopTopBar"
|
||||
|
||||
@ -40,7 +39,9 @@ import Splash from "./splash"
|
||||
|
||||
import "@styles/index.less"
|
||||
|
||||
CapacitorUpdater.notifyAppReady()
|
||||
if (IS_MOBILE_HOST) {
|
||||
CapacitorUpdater.notifyAppReady()
|
||||
}
|
||||
|
||||
class ComtyApp extends React.Component {
|
||||
constructor(props) {
|
||||
@ -52,7 +53,6 @@ class ComtyApp extends React.Component {
|
||||
}
|
||||
|
||||
state = {
|
||||
desktopMode: false,
|
||||
session: null,
|
||||
initialized: false,
|
||||
}
|
||||
@ -62,7 +62,7 @@ class ComtyApp extends React.Component {
|
||||
window.app.version = config.package.version
|
||||
window.app.confirm = antd.Modal.confirm
|
||||
window.app.message = antd.message
|
||||
window.app.isCapacitor = window.navigator.userAgent === "capacitor"
|
||||
window.app.isCapacitor = IS_MOBILE_HOST
|
||||
|
||||
if (window.app.version !== window.localStorage.getItem("last_version")) {
|
||||
app.message.info(`Comty has been updated to version ${window.app.version}!`)
|
||||
@ -157,16 +157,14 @@ class ComtyApp extends React.Component {
|
||||
framed: false
|
||||
})
|
||||
},
|
||||
openMessages: () => {
|
||||
app.location.push("/messages")
|
||||
},
|
||||
openFullImageViewer: (src) => {
|
||||
const win = new DOMWindow({
|
||||
id: "fullImageViewer",
|
||||
className: "fullImageViewer",
|
||||
})
|
||||
|
||||
win.render(<Lightbox
|
||||
app.cores.window_mng.render("image_lightbox", <Lightbox
|
||||
small={src}
|
||||
large={src}
|
||||
onClose={() => win.remove()}
|
||||
onClose={() => app.cores.window_mng.close("image_lightbox")}
|
||||
hideDownload
|
||||
showRotate
|
||||
/>)
|
||||
@ -298,17 +296,6 @@ class ComtyApp extends React.Component {
|
||||
},
|
||||
})
|
||||
},
|
||||
"app.no_session": async () => {
|
||||
const location = window.location.pathname
|
||||
|
||||
if (location !== "/auth" && location !== "/register") {
|
||||
antd.notification.info({
|
||||
message: "You are not logged in, to use some features you will need to log in.",
|
||||
btn: <antd.Button type="primary" onClick={() => app.controls.openLoginForm()}>Login</antd.Button>,
|
||||
duration: 15,
|
||||
})
|
||||
}
|
||||
},
|
||||
"session.invalid": async (error) => {
|
||||
const token = await SessionModel.token
|
||||
|
||||
|
@ -11,6 +11,7 @@ import * as lib3 from "react-icons/md"
|
||||
import * as lib4 from "react-icons/io"
|
||||
import * as lib5 from "react-icons/si"
|
||||
import * as lib6 from "react-icons/fa"
|
||||
import * as lib7 from "react-icons/tb"
|
||||
|
||||
const marginedStyle = {
|
||||
width: "1em",
|
||||
@ -42,6 +43,7 @@ export const Icons = {
|
||||
...lib4,
|
||||
...lib5,
|
||||
...lib6,
|
||||
...lib7,
|
||||
}
|
||||
|
||||
export function createIconRender(icon, props) {
|
||||
|
@ -1,8 +0,0 @@
|
||||
export { default as TopBar } from "./topBar"
|
||||
export { default as BottomBar } from "./bottomBar"
|
||||
export { default as Drawer } from "./drawer"
|
||||
export { default as Sidebar } from "./sidebar"
|
||||
export { default as Sidedrawer } from "./sidedrawer"
|
||||
export { default as Modal } from "./modal"
|
||||
export { default as Header } from "./header"
|
||||
export { default as ToolsBar } from "./toolsBar"
|
@ -1,79 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal, Button } from "antd"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
// TODO: Implement translucid mode
|
||||
// TODO: Implement close on click mask (and Should remove the close button)
|
||||
|
||||
export default class AppModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.controller = app.ModalController = {
|
||||
open: this.open,
|
||||
close: this.close,
|
||||
modalRef: this.modalRef,
|
||||
}
|
||||
}
|
||||
|
||||
state = {
|
||||
currentRender: null,
|
||||
renderParams: {}
|
||||
}
|
||||
|
||||
modalRef = React.createRef()
|
||||
|
||||
open = (render, params = {}) => {
|
||||
this.setState({
|
||||
currentRender: render,
|
||||
renderParams: params
|
||||
})
|
||||
}
|
||||
|
||||
close = () => {
|
||||
this.setState({
|
||||
currentRender: null,
|
||||
renderParams: {}
|
||||
})
|
||||
}
|
||||
|
||||
handleModalClose = () => {
|
||||
this.close()
|
||||
}
|
||||
|
||||
renderModal = () => {
|
||||
return <div className="appModalWrapper">
|
||||
<Button
|
||||
icon={<Icons.X />}
|
||||
className="closeButton"
|
||||
onClick={this.handleModalClose}
|
||||
shape="circle"
|
||||
/>
|
||||
|
||||
<div className="appModal" ref={this.modalRef}>
|
||||
{
|
||||
React.createElement(this.state.currentRender, {
|
||||
...this.state.renderParams.props ?? {},
|
||||
close: this.close,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Modal
|
||||
open={this.state.currentRender}
|
||||
maskClosable={this.state.renderParams.maskClosable ?? true}
|
||||
modalRender={this.renderModal}
|
||||
maskStyle={{
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
backdropFilter: "blur(5px)"
|
||||
}}
|
||||
destroyOnClose
|
||||
centered
|
||||
/>
|
||||
}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
.appModalWrapper {
|
||||
.closeButton {
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
pointer-events: all;
|
||||
|
||||
font-size: 1.2rem;
|
||||
|
||||
transform: translate(-25px, -10px);
|
||||
|
||||
svg {
|
||||
margin: 0 !important;
|
||||
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.appModal {
|
||||
pointer-events: all;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-self: center;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
//min-height: 500px;
|
||||
min-width: 600px;
|
||||
|
||||
padding: 30px;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
@ -18,8 +18,6 @@ export default React.forwardRef((props, ref) => {
|
||||
const insideViewportCb = (entries) => {
|
||||
const { fetching, onBottom } = props
|
||||
|
||||
console.log("entries", entries)
|
||||
|
||||
entries.forEach(element => {
|
||||
if (element.intersectionRatio > 0 && !fetching) {
|
||||
onBottom()
|
||||
@ -50,14 +48,14 @@ export default React.forwardRef((props, ref) => {
|
||||
>
|
||||
{children}
|
||||
|
||||
<lb style={{ clear: "both" }} />
|
||||
<div style={{ clear: "both" }} />
|
||||
|
||||
<lb
|
||||
<div
|
||||
id="bottom"
|
||||
className="bottom"
|
||||
style={{ display: hasMore ? "block" : "none" }}
|
||||
>
|
||||
{loadingComponent && React.createElement(loadingComponent)}
|
||||
</lb>
|
||||
</div>
|
||||
</div>
|
||||
})
|
@ -21,19 +21,19 @@ import MusicModel from "@models/music"
|
||||
import "./index.less"
|
||||
|
||||
const PlaylistTypeDecorators = {
|
||||
"single": (props) => <span className="playlistType">
|
||||
"single": () => <span className="playlistType">
|
||||
<Icons.MdMusicNote />
|
||||
Single
|
||||
</span>,
|
||||
"album": (props) => <span className="playlistType">
|
||||
"album": () => <span className="playlistType">
|
||||
<Icons.MdAlbum />
|
||||
Album
|
||||
</span>,
|
||||
"ep": (props) => <span className="playlistType">
|
||||
"ep": () => <span className="playlistType">
|
||||
<Icons.MdAlbum />
|
||||
EP
|
||||
</span>,
|
||||
"mix": (props) => <span className="playlistType">
|
||||
"mix": () => <span className="playlistType">
|
||||
<Icons.MdMusicNote />
|
||||
Mix
|
||||
</span>,
|
||||
@ -228,12 +228,14 @@ export default (props) => {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
const playlistType = playlist.type?.toLowerCase() ?? "playlist"
|
||||
|
||||
return <PlaylistContext.Provider value={contextValues}>
|
||||
<WithPlayerContext>
|
||||
<div
|
||||
className={classnames(
|
||||
"playlist_view",
|
||||
props.type ?? playlist.type,
|
||||
playlistType,
|
||||
)}
|
||||
>
|
||||
|
||||
@ -257,9 +259,9 @@ export default (props) => {
|
||||
|
||||
<div className="play_info_statistics">
|
||||
{
|
||||
playlist.type && PlaylistTypeDecorators[playlist.type] && <div className="play_info_statistics_item">
|
||||
playlistType && PlaylistTypeDecorators[playlistType] && <div className="play_info_statistics_item">
|
||||
{
|
||||
PlaylistTypeDecorators[playlist.type]()
|
||||
PlaylistTypeDecorators[playlistType]()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
@ -84,6 +84,10 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
return <></>
|
||||
}
|
||||
|
||||
if (this.props.tabs.length === 0) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
// slip the active tab by splitting on "."
|
||||
if (!this.state.activeTab) {
|
||||
console.error("PagePanelWithNavMenu: activeTab is not defined")
|
||||
|
@ -64,7 +64,7 @@ export default (props) => {
|
||||
onClickReply,
|
||||
} = props.actions ?? {}
|
||||
|
||||
const genItems = () => {
|
||||
const generateMoreMenuItems = () => {
|
||||
let items = MoreActionsItems
|
||||
|
||||
if (isSelf) {
|
||||
@ -104,7 +104,7 @@ export default (props) => {
|
||||
<div className="action" id="more">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: genItems(),
|
||||
items: generateMoreMenuItems(),
|
||||
onClick: handleDropdownClickItem,
|
||||
}}
|
||||
trigger={["click"]}
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
background-color: black;
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
.bear-react-carousel__pagination-group {
|
||||
top: 0;
|
||||
|
@ -12,6 +12,14 @@ import PostAttachments from "./components/attachments"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const articleAnimationProps = {
|
||||
layout: true,
|
||||
initial: { y: -100, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1, },
|
||||
exit: { scale: 0, opacity: 0 },
|
||||
transition: { duration: 0.1, },
|
||||
}
|
||||
|
||||
const messageRegexs = [
|
||||
{
|
||||
regex: /https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})(&[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+)*/g,
|
||||
@ -164,26 +172,21 @@ export default class PostCard extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <motion.div
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1, }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.1,
|
||||
}}
|
||||
layout
|
||||
key={this.props.index}
|
||||
id={this.state.data._id}
|
||||
post_id={this.state.data._id}
|
||||
style={this.props.style}
|
||||
user-id={this.state.data.user_id}
|
||||
context-menu={"postCard-context"}
|
||||
return <motion.article
|
||||
className={classnames(
|
||||
"post_card",
|
||||
{
|
||||
["open"]: this.state.open,
|
||||
}
|
||||
)}
|
||||
id={this.state.data._id}
|
||||
style={this.props.style}
|
||||
{...articleAnimationProps}
|
||||
>
|
||||
<div
|
||||
className="post_card_content"
|
||||
context-menu={"post-card"}
|
||||
user-id={this.state.data.user_id}
|
||||
>
|
||||
<PostHeader
|
||||
postData={this.state.data}
|
||||
@ -237,6 +240,8 @@ export default class PostCard extends React.PureComponent {
|
||||
<span>View {this.state.hasReplies} replies</span>
|
||||
</div>
|
||||
}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</motion.article>
|
||||
}
|
||||
}
|
@ -33,6 +33,13 @@
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.post_card_content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.post_content {
|
||||
position: relative;
|
||||
|
||||
|
@ -1,233 +0,0 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import { Rnd } from "react-rnd"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
class DOMWindow {
|
||||
constructor(props = {}) {
|
||||
this.props = props
|
||||
|
||||
this.id = this.props.id
|
||||
this.key = 0
|
||||
|
||||
this.currentRender = null
|
||||
this.root = document.getElementById("app_windows")
|
||||
this.element = document.createElement("div")
|
||||
|
||||
this.element.setAttribute("id", this.id)
|
||||
this.element.setAttribute("key", this.key)
|
||||
this.element.setAttribute("classname", this.props.className)
|
||||
|
||||
// if props clickOutsideToClose is true, add event listener to close window
|
||||
if (this.props.clickOutsideToClose) {
|
||||
document.addEventListener("click", this.handleWrapperClick)
|
||||
}
|
||||
|
||||
// handle root container
|
||||
if (!this.root) {
|
||||
this.root = document.createElement("div")
|
||||
this.root.setAttribute("id", "app_windows")
|
||||
|
||||
document.body.append(this.root)
|
||||
}
|
||||
|
||||
// get all windows opened has container
|
||||
const rootNodes = this.root.childNodes
|
||||
|
||||
// ensure this window has last key from rootNode
|
||||
if (rootNodes.length > 0) {
|
||||
const lastChild = rootNodes[rootNodes.length - 1]
|
||||
const lastChildKey = Number(lastChild.getAttribute("key"))
|
||||
|
||||
this.key = lastChildKey + 1
|
||||
}
|
||||
}
|
||||
|
||||
handleWrapperClick = (event) => {
|
||||
if (!this.currentRender) {
|
||||
return
|
||||
}
|
||||
|
||||
// if click in not renderer fragment, close window
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.remove()
|
||||
}
|
||||
}
|
||||
|
||||
render = (fragment) => {
|
||||
this.root.appendChild(this.element)
|
||||
|
||||
this.currentRender = fragment
|
||||
|
||||
return ReactDOM.render(fragment, this.element)
|
||||
}
|
||||
|
||||
remove = () => {
|
||||
this.root.removeChild(this.element)
|
||||
this.currentRender = null
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.element.remove()
|
||||
this.currentRender = null
|
||||
}
|
||||
|
||||
createDefaultWindow = (children, props) => {
|
||||
return this.render(<DefaultWindowRender {...this.props} {...props} id={this.id} key={this.key} destroy={this.destroy} >
|
||||
{children}
|
||||
</DefaultWindowRender>)
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultWindowRender extends React.Component {
|
||||
state = {
|
||||
actions: [],
|
||||
dimensions: {
|
||||
height: this.props.height ?? 600,
|
||||
width: this.props.width ?? 400,
|
||||
},
|
||||
position: this.props.defaultPosition,
|
||||
visible: false,
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this.setDefaultActions()
|
||||
|
||||
if (typeof this.props.actions !== "undefined") {
|
||||
if (Array.isArray(this.props.actions)) {
|
||||
const actions = this.state.actions ?? []
|
||||
|
||||
this.props.actions.forEach((action) => {
|
||||
actions.push(action)
|
||||
})
|
||||
|
||||
this.setState({ actions })
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.state.position) {
|
||||
this.setState({ position: this.getCenterPosition() })
|
||||
}
|
||||
|
||||
this.toggleVisibility(true)
|
||||
}
|
||||
|
||||
toggleVisibility = (to) => {
|
||||
this.setState({ visible: to ?? !this.state.visible })
|
||||
}
|
||||
|
||||
getCenterPosition = () => {
|
||||
const dimensions = this.state?.dimensions ?? {}
|
||||
|
||||
const windowHeight = dimensions.height ?? 600
|
||||
const windowWidth = dimensions.width ?? 400
|
||||
|
||||
return {
|
||||
x: window.innerWidth / 2 - windowWidth / 2,
|
||||
y: window.innerHeight / 2 - windowHeight / 2,
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultActions = () => {
|
||||
const { actions } = this.state
|
||||
|
||||
actions.push({
|
||||
key: "close",
|
||||
render: () => <Icons.XCircle style={{ margin: 0, padding: 0 }} />,
|
||||
onClick: () => {
|
||||
this.props.destroy()
|
||||
},
|
||||
})
|
||||
|
||||
this.setState({ actions })
|
||||
}
|
||||
|
||||
renderActions = () => {
|
||||
const actions = this.state.actions
|
||||
|
||||
if (Array.isArray(actions)) {
|
||||
return actions.map((action) => {
|
||||
return (
|
||||
<div key={action.key} onClick={action.onClick} {...action.props}>
|
||||
{React.isValidElement(action.render) ? action.render : React.createElement(action.render)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
getComponentRender = () => {
|
||||
return React.isValidElement(this.props.children)
|
||||
? React.cloneElement(this.props.children, this.props.renderProps)
|
||||
: React.createElement(this.props.children, this.props.renderProps)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { position, dimensions, visible } = this.state
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
default={{
|
||||
...position,
|
||||
...dimensions,
|
||||
}}
|
||||
onResize={(e, direction, ref, delta, position) => {
|
||||
this.setState({
|
||||
dimensions: {
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
},
|
||||
position,
|
||||
})
|
||||
}}
|
||||
minWidth={
|
||||
this.props.minWidth
|
||||
}
|
||||
minHeight={
|
||||
this.props.minHeight
|
||||
}
|
||||
enableResizing={
|
||||
this.props.enableResizing ?? true
|
||||
}
|
||||
disableDragging={
|
||||
this.props.disableDragging ?? false
|
||||
}
|
||||
dragHandleClassName={
|
||||
this.props.dragHandleClassName ?? "window_topbar"
|
||||
}
|
||||
bounds={
|
||||
this.props.bounds ?? "#root"
|
||||
}
|
||||
>
|
||||
{
|
||||
this.props.useWrapper
|
||||
? <div
|
||||
style={{
|
||||
height: dimensions.height,
|
||||
width: dimensions.width,
|
||||
}}
|
||||
className="window_wrapper"
|
||||
>
|
||||
<div className="window_topbar">
|
||||
<div className="title">{this.props.id}</div>
|
||||
<div className="actions">{this.renderActions()}</div>
|
||||
</div>
|
||||
|
||||
<div className="window_body">{this.getComponentRender()}</div>
|
||||
</div>
|
||||
: this.props.children
|
||||
}
|
||||
</Rnd>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { DOMWindow, DefaultWindowRender }
|
@ -1,93 +0,0 @@
|
||||
@wrapper_background: rgba(255, 255, 255, 1);
|
||||
|
||||
@topbar_height: 30px;
|
||||
@topbar_background: rgba(0, 0, 0, 0.4);
|
||||
|
||||
.window_wrapper {
|
||||
border-radius: 12px;
|
||||
|
||||
background-color: @wrapper_background;
|
||||
border: 1px solid rgba(161, 133, 133, 0.2);
|
||||
overflow: hidden;
|
||||
|
||||
&.translucid {
|
||||
border: unset;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
backdrop-filter: blur(10px);
|
||||
--webkit-backdrop-filter: blur(10px);
|
||||
filter: drop-shadow(8px 8px 10px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
.window_topbar {
|
||||
position: sticky;
|
||||
z-index: 51;
|
||||
background-color: @topbar_background;
|
||||
|
||||
height: @topbar_height;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
> div {
|
||||
margin: 0 5px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: 20px;
|
||||
|
||||
color: #fff - @topbar_background;
|
||||
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
color: #fff - @topbar_background;
|
||||
|
||||
> div {
|
||||
transition: all 150ms ease-in-out;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
line-height: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
> div:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.window_body {
|
||||
z-index: 50;
|
||||
|
||||
padding: 10px 20px;
|
||||
height: calc(100% - @topbar_height);
|
||||
width: 100%;
|
||||
|
||||
overflow: overlay;
|
||||
user-select: text !important;
|
||||
--webkit-user-select: text !important;
|
||||
|
||||
> div {
|
||||
user-select: text !important;
|
||||
--webkit-user-select: text !important;
|
||||
}
|
||||
}
|
@ -5,12 +5,11 @@ import { Icons } from "@components/Icons"
|
||||
import WidgetItemPreview from "@components/WidgetItemPreview"
|
||||
|
||||
import useRequest from "comty.js/hooks/useRequest"
|
||||
import WidgetModel from "comty.js/models/widget"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export const WidgetBrowser = (props) => {
|
||||
const [L_Widgets, R_Widgets, E_Widgets, M_Widgets] = useRequest(WidgetModel.browse)
|
||||
const [L_Widgets, R_Widgets, E_Widgets, M_Widgets] = []
|
||||
|
||||
const [searchValue, setSearchValue] = React.useState("")
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
import * as Layout from "./Layout"
|
||||
export { default as Footer } from "./Footer"
|
||||
|
||||
export { default as RenderError } from "./RenderError"
|
||||
@ -41,8 +40,3 @@ export { default as UserBadges } from "./UserBadges"
|
||||
export { default as UserCard } from "./UserCard"
|
||||
export { default as FollowersList } from "./FollowersList"
|
||||
export { default as UserPreview } from "./UserPreview"
|
||||
|
||||
// OTHERS
|
||||
export * as Window from "./RenderWindow"
|
||||
|
||||
export { Layout }
|
@ -1,5 +1,5 @@
|
||||
.contextMenu {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
z-index: 100000;
|
||||
|
||||
display: flex;
|
||||
@ -8,7 +8,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 200px;
|
||||
width: 230px;
|
||||
height: fit-content;
|
||||
|
||||
background-color: var(--background-color-primary);
|
||||
@ -16,11 +16,11 @@
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
padding: 10px;
|
||||
padding: 7px;
|
||||
|
||||
font-family: "Recursive", sans-serif;
|
||||
font-weight: 600;
|
||||
font-family: var(--fontFamily);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
|
||||
h1,
|
||||
@ -42,7 +42,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 10px 10px 10px 20px;
|
||||
padding: 10px 10px 10px 15px;
|
||||
|
||||
transition: all 50ms ease-in-out;
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-color-accent);
|
||||
padding-left: 25px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React from "react"
|
||||
import Core from "evite/src/core"
|
||||
|
||||
import { DOMWindow } from "@components/RenderWindow"
|
||||
import ContextMenu from "./components/contextMenu"
|
||||
|
||||
import InternalContexts from "@config/context-menu"
|
||||
import DefaultContenxt from "@config/context-menu/default"
|
||||
import PostCardContext from "@config/context-menu/post"
|
||||
|
||||
export default class ContextMenuCore extends Core {
|
||||
static namespace = "contextMenu"
|
||||
@ -15,13 +15,10 @@ export default class ContextMenuCore extends Core {
|
||||
registerContext: this.registerContext.bind(this),
|
||||
}
|
||||
|
||||
contexts = Object()
|
||||
|
||||
DOMWindow = new DOMWindow({
|
||||
id: "contextMenu",
|
||||
className: "contextMenuWrapper",
|
||||
clickOutsideToClose: true,
|
||||
})
|
||||
contexts = {
|
||||
...DefaultContenxt,
|
||||
...PostCardContext,
|
||||
}
|
||||
|
||||
async onInitialize() {
|
||||
if (app.isMobile) {
|
||||
@ -65,27 +62,26 @@ export default class ContextMenuCore extends Core {
|
||||
contexts.push("default-context")
|
||||
}
|
||||
|
||||
for await (const context of contexts) {
|
||||
let contextObject = this.contexts[context] || InternalContexts[context]
|
||||
for await (const [index, context] of contexts.entries()) {
|
||||
let contextObject = this.contexts[context]
|
||||
|
||||
if (!contextObject) {
|
||||
this.console.warn(`Context ${context} not found`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof contextObject === "function") {
|
||||
contextObject = await contextObject(parentElement, element, {
|
||||
contextObject = await contextObject(items, parentElement, element, {
|
||||
close: this.hide,
|
||||
})
|
||||
}
|
||||
|
||||
// push divider
|
||||
if (items.length > 0) {
|
||||
if (contexts.length > 0 && index !== contexts.length - 1) {
|
||||
items.push({
|
||||
type: "separator"
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(contextObject)) {
|
||||
items.push(...contextObject)
|
||||
} else {
|
||||
items.push(contextObject)
|
||||
}
|
||||
}
|
||||
|
||||
// fullfill each item with a correspondent index if missing declared
|
||||
@ -142,10 +138,13 @@ export default class ContextMenuCore extends Core {
|
||||
}
|
||||
|
||||
show(payload) {
|
||||
this.DOMWindow.render(React.createElement(ContextMenu, payload))
|
||||
app.cores.window_mng.render("context-menu", React.createElement(ContextMenu, payload), {
|
||||
createOrUpdate: true,
|
||||
closeOnClickOutside: true,
|
||||
})
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.DOMWindow.remove()
|
||||
app.cores.window_mng.close("context-menu")
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@ class NotificationFeedback {
|
||||
return (window.app.cores.settings.get("sfx:notifications_volume") ?? 50) / 100
|
||||
}
|
||||
|
||||
static playHaptic = async (options = {}) => {
|
||||
static playHaptic = async () => {
|
||||
if (app.cores.settings.get("haptics:notifications_feedback")) {
|
||||
await Haptics.vibrate()
|
||||
}
|
||||
@ -28,7 +28,11 @@ class NotificationFeedback {
|
||||
}
|
||||
}
|
||||
|
||||
static async feedback(type) {
|
||||
static async feedback({ type = "notification", feedback = true }) {
|
||||
if (!feedback) {
|
||||
return false
|
||||
}
|
||||
|
||||
NotificationFeedback.playHaptic(type)
|
||||
NotificationFeedback.playAudio(type)
|
||||
}
|
||||
|
@ -22,10 +22,15 @@ export default class NotificationCore extends Core {
|
||||
|
||||
public = {
|
||||
new: this.new,
|
||||
close: this.close,
|
||||
}
|
||||
|
||||
async new(notification, options = {}) {
|
||||
NotificationUI.notify(notification, options)
|
||||
NotificationFeedback.feedback(options.type)
|
||||
async new(notification) {
|
||||
NotificationUI.notify(notification)
|
||||
NotificationFeedback.feedback(notification)
|
||||
}
|
||||
|
||||
async close(id) {
|
||||
NotificationUI.close(id)
|
||||
}
|
||||
}
|
@ -5,12 +5,7 @@ import { notification as Notf, Space, Button } from "antd"
|
||||
import { Icons, createIconRender } from "@components/Icons"
|
||||
|
||||
class NotificationUI {
|
||||
static async notify(
|
||||
notification,
|
||||
options = {
|
||||
type: "info"
|
||||
}
|
||||
) {
|
||||
static async notify(notification) {
|
||||
if (typeof notification === "string") {
|
||||
notification = {
|
||||
title: "New notification",
|
||||
@ -19,8 +14,8 @@ class NotificationUI {
|
||||
}
|
||||
|
||||
const notfObj = {
|
||||
duration: options.duration ?? 4,
|
||||
key: options.key ?? Date.now(),
|
||||
duration: typeof notification.duration === "undefined" ? 4 : notification.duration,
|
||||
key: notification.key ?? Date.now(),
|
||||
}
|
||||
|
||||
if (notification.title) {
|
||||
@ -73,11 +68,11 @@ class NotificationUI {
|
||||
notfObj.icon = React.isValidElement(notification.icon) ? notification.icon : (createIconRender(notification.icon) ?? <Icons.Bell />)
|
||||
}
|
||||
|
||||
if (Array.isArray(options.actions)) {
|
||||
if (Array.isArray(notification.actions)) {
|
||||
notfObj.btn = (
|
||||
<Space>
|
||||
{
|
||||
options.actions.map((action, index) => {
|
||||
notification.actions.map((action, index) => {
|
||||
const handleClick = () => {
|
||||
if (typeof action.onClick === "function") {
|
||||
action.onClick()
|
||||
@ -101,11 +96,24 @@ class NotificationUI {
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof Notf[options.type] !== "function") {
|
||||
options.type = "info"
|
||||
if (typeof notification.closable) {
|
||||
notfObj.closable = notification.closable
|
||||
}
|
||||
|
||||
return Notf[options.type](notfObj)
|
||||
if (notification.type === "loading") {
|
||||
notification.type = "open"
|
||||
notfObj.icon = <Icons.LoadingOutlined />
|
||||
}
|
||||
|
||||
if (typeof Notf[notification.type] !== "function") {
|
||||
notification.type = "info"
|
||||
}
|
||||
|
||||
return Notf[notification.type](notfObj)
|
||||
}
|
||||
|
||||
static close(key) {
|
||||
Notf.destroy(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -110,7 +110,8 @@ export default class Player extends Core {
|
||||
set: (target, prop, value) => {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}),
|
||||
gradualFadeMs: Player.gradualFadeMs,
|
||||
}
|
||||
|
||||
internalEvents = {
|
||||
@ -128,10 +129,6 @@ export default class Player extends Core {
|
||||
},
|
||||
}
|
||||
|
||||
wsEvents = {
|
||||
|
||||
}
|
||||
|
||||
async onInitialize() {
|
||||
this.native_controls.initialize()
|
||||
|
||||
@ -695,6 +692,10 @@ export default class Player extends Core {
|
||||
}
|
||||
|
||||
async resumePlayback() {
|
||||
if (!this.state.playback_status === "playing") {
|
||||
return true
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
if (!this.track_instance) {
|
||||
this.console.error("No audio instance")
|
||||
|
@ -32,8 +32,7 @@ export default class RemoteStorage extends Core {
|
||||
const fn = async () => new Promise((resolve, reject) => {
|
||||
const uploader = new ChunkedUpload({
|
||||
endpoint: `${app.cores.api.client().mainOrigin}/upload/chunk`,
|
||||
// TODO: get chunk size from settings
|
||||
splitChunkSize: 5 * 1024 * 1024, // 5MB in bytes
|
||||
splitChunkSize: 5 * 1024 * 1024,
|
||||
file: file,
|
||||
service: service,
|
||||
})
|
||||
|
@ -239,8 +239,6 @@ export default class StyleCore extends Core {
|
||||
app.eventBus.emit("style.update", {
|
||||
...this.public.mutation,
|
||||
})
|
||||
|
||||
ConfigProvider.config({ theme: this.public.mutation })
|
||||
}
|
||||
|
||||
applyVariant(variant = (this.public.theme.defaultVariant ?? "light")) {
|
||||
|
@ -0,0 +1,17 @@
|
||||
import React from "react"
|
||||
|
||||
export default React.createContext({
|
||||
title: null,
|
||||
close: () => { },
|
||||
updatePosition: () => { },
|
||||
updateDimensions: () => { },
|
||||
updateTitle: () => { },
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
dimensions: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
})
|
@ -0,0 +1,196 @@
|
||||
import React from "react"
|
||||
import { Rnd } from "react-rnd"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import WindowContext from "./context"
|
||||
|
||||
export default class DefaultWindowRender extends React.Component {
|
||||
static contextType = WindowContext
|
||||
|
||||
ref = React.createRef()
|
||||
|
||||
state = {
|
||||
renderError: false,
|
||||
title: null,
|
||||
actions: [],
|
||||
dimensions: {
|
||||
height: this.props.height ?? 600,
|
||||
width: this.props.width ?? 400,
|
||||
},
|
||||
position: this.props.defaultPosition,
|
||||
visible: false,
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this.setDefaultActions()
|
||||
|
||||
if (typeof this.props.actions !== "undefined") {
|
||||
if (Array.isArray(this.props.actions)) {
|
||||
const actions = this.state.actions ?? []
|
||||
|
||||
this.props.actions.forEach((action) => {
|
||||
actions.push(action)
|
||||
})
|
||||
|
||||
this.setState({ actions })
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.state.position) {
|
||||
this.setState({ position: this.getCenterPosition() })
|
||||
}
|
||||
|
||||
this.toggleVisibility(true)
|
||||
}
|
||||
|
||||
componentDidCatch = (error) => {
|
||||
console.error(error)
|
||||
|
||||
this.setState({
|
||||
renderError: error,
|
||||
})
|
||||
}
|
||||
|
||||
updateTitle = (title) => {
|
||||
this.setState({ title })
|
||||
}
|
||||
|
||||
updateDimensions = (dimensions = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
}) => {
|
||||
this.ref.current?.updateSize({
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
})
|
||||
}
|
||||
|
||||
updatePosition = (position = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}) => {
|
||||
this.ref.current?.updatePosition({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
})
|
||||
}
|
||||
|
||||
toggleVisibility = (to) => {
|
||||
this.setState({ visible: to ?? !this.state.visible })
|
||||
}
|
||||
|
||||
getCenterPosition = () => {
|
||||
const dimensions = this.state?.dimensions ?? {}
|
||||
|
||||
const windowHeight = dimensions.height ?? 600
|
||||
const windowWidth = dimensions.width ?? 400
|
||||
|
||||
return {
|
||||
x: window.innerWidth / 2 - windowWidth / 2,
|
||||
y: window.innerHeight / 2 - windowHeight / 2,
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultActions = () => {
|
||||
const { actions } = this.state
|
||||
|
||||
actions.push({
|
||||
key: "close",
|
||||
render: () => <Icons.XCircle style={{ margin: 0, padding: 0 }} />,
|
||||
onClick: () => {
|
||||
this.props.close()
|
||||
},
|
||||
})
|
||||
|
||||
this.setState({ actions })
|
||||
}
|
||||
|
||||
renderActions = () => {
|
||||
const actions = this.state.actions
|
||||
|
||||
if (Array.isArray(actions)) {
|
||||
return actions.map((action) => {
|
||||
return (
|
||||
<div key={action.key} onClick={action.onClick} {...action.props}>
|
||||
{React.isValidElement(action.render) ? action.render : React.createElement(action.render)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
getComponentRender = () => {
|
||||
const ctx = {
|
||||
...this.props,
|
||||
updateTitle: this.updateTitle,
|
||||
updateDimensions: this.updateDimensions,
|
||||
updatePosition: this.updatePosition,
|
||||
close: this.props.close,
|
||||
position: this.state.position,
|
||||
dimensions: this.state.dimensions,
|
||||
}
|
||||
|
||||
return <WindowContext.Provider value={ctx}>
|
||||
{
|
||||
React.isValidElement(this.props.children)
|
||||
? React.cloneElement(this.props.children, ctx)
|
||||
: React.createElement(this.props.children, ctx)
|
||||
}
|
||||
</WindowContext.Provider>
|
||||
}
|
||||
|
||||
render() {
|
||||
const { position, dimensions, visible } = this.state
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <Rnd
|
||||
ref={this.ref}
|
||||
default={{
|
||||
...position,
|
||||
...dimensions,
|
||||
}}
|
||||
minWidth={
|
||||
this.props.minWidth
|
||||
}
|
||||
minHeight={
|
||||
this.props.minHeight
|
||||
}
|
||||
enableResizing={
|
||||
this.props.enableResizing ?? true
|
||||
}
|
||||
disableDragging={
|
||||
this.props.disableDragging ?? false
|
||||
}
|
||||
dragHandleClassName={
|
||||
this.props.dragHandleClassName ?? "window_topbar"
|
||||
}
|
||||
bounds={
|
||||
this.props.bounds ?? "#root"
|
||||
}
|
||||
className="window_wrapper"
|
||||
>
|
||||
<div className="window_topbar">
|
||||
<div className="title">{this.state.title}</div>
|
||||
<div className="actions">{this.renderActions()}</div>
|
||||
</div>
|
||||
|
||||
<div className="window_body">
|
||||
{
|
||||
this.state.renderError && <div className="render_error">
|
||||
<h1>Render Error</h1>
|
||||
<code>{this.state.renderError.message}</code>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
!this.state.renderError && this.getComponentRender()
|
||||
}
|
||||
</div>
|
||||
</Rnd>
|
||||
}
|
||||
}
|
115
packages/app/src/cores/windows/index.less
Normal file
115
packages/app/src/cores/windows/index.less
Normal file
@ -0,0 +1,115 @@
|
||||
@topbar_height: 30px;
|
||||
@topbar_background: var(--background-color-accent);
|
||||
|
||||
.window_wrapper {
|
||||
border-radius: 12px;
|
||||
|
||||
background-color: var(--background-color-primary);
|
||||
border: 2px solid var(--border-color);
|
||||
|
||||
//filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.5));
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window_topbar {
|
||||
position: sticky;
|
||||
|
||||
z-index: 51;
|
||||
|
||||
background-color: @topbar_background;
|
||||
|
||||
color: var(--text-color);
|
||||
|
||||
height: @topbar_height;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
>div {
|
||||
margin: 0 5px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: 20px;
|
||||
|
||||
color: var(--text-color);
|
||||
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
color: var(--text-color);
|
||||
|
||||
>div {
|
||||
transition: all 150ms ease-in-out;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
line-height: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
>div:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.window_body {
|
||||
z-index: 50;
|
||||
|
||||
padding: 10px 20px;
|
||||
height: calc(100% - @topbar_height);
|
||||
width: 100%;
|
||||
|
||||
overflow: overlay;
|
||||
user-select: text !important;
|
||||
--webkit-user-select: text !important;
|
||||
|
||||
color: var(--text-color);
|
||||
|
||||
>div {
|
||||
user-select: text !important;
|
||||
--webkit-user-select: text !important;
|
||||
}
|
||||
|
||||
.render_error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
}
|
151
packages/app/src/cores/windows/windows.core.jsx
Normal file
151
packages/app/src/cores/windows/windows.core.jsx
Normal file
@ -0,0 +1,151 @@
|
||||
import React from "react"
|
||||
import Core from "evite/src/core"
|
||||
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import DefaultWindow from "./components/defaultWindow"
|
||||
|
||||
import DefaultWindowContext from "./components/defaultWindow/context"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default class WindowManager extends Core {
|
||||
static namespace = "window_mng"
|
||||
|
||||
static idMount = "windows"
|
||||
|
||||
root = null
|
||||
|
||||
windows = []
|
||||
|
||||
public = {
|
||||
close: this.close.bind(this),
|
||||
render: this.render.bind(this),
|
||||
}
|
||||
|
||||
async onInitialize() {
|
||||
this.root = document.createElement("div")
|
||||
|
||||
this.root.setAttribute("id", this.constructor.idMount)
|
||||
|
||||
document.body.append(this.root)
|
||||
}
|
||||
|
||||
handleWrapperClick = (id, event) => {
|
||||
const element = this.root.querySelector(`#${id}`)
|
||||
|
||||
if (element) {
|
||||
if (!element.contains(event.target)) {
|
||||
this.close(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new element with the specified id and appends it to the root element.
|
||||
* If the element already exists and createOrUpdate option is false, it will throw an error.
|
||||
* If the element already exists and createOrUpdate option is true, it will update the element.
|
||||
* If useFrame option is true, it wraps the fragment with a DefaultWindow component before rendering.
|
||||
*
|
||||
* @param {string} id - The id of the element to create or update.
|
||||
* @param {ReactElement} fragment - The React element to render inside the created element.
|
||||
* @param {Object} options - The options for creating or updating the element.
|
||||
* @param {boolean} options.useFrame - Specifies whether to wrap the fragment with a DefaultWindow component.
|
||||
* @param {boolean} options.createOrUpdate - Specifies whether to create a new element or update an existing one.
|
||||
* @return {HTMLElement} The created or updated element.
|
||||
*/
|
||||
render(
|
||||
id,
|
||||
fragment,
|
||||
{
|
||||
useFrame = false,
|
||||
createOrUpdate = false,
|
||||
closeOnClickOutside = false,
|
||||
} = {}
|
||||
) {
|
||||
let element = document.createElement("div")
|
||||
let node = null
|
||||
let win = null
|
||||
|
||||
// check if window already exist
|
||||
if (this.root.querySelector(`#${id}`) && !createOrUpdate) {
|
||||
const newId = `${id}_${Date.now()}`
|
||||
|
||||
this.console.warn(`Window ${id} already exist, overwritting id to ${newId}.\nYou can use {createOrUpdate = true} option to force refresh render of window`)
|
||||
|
||||
id = newId
|
||||
}
|
||||
|
||||
if (this.root.querySelector(`#${id}`) && createOrUpdate) {
|
||||
element = document.getElementById(id)
|
||||
|
||||
win = this.windows.find((_node) => {
|
||||
return _node.id === id
|
||||
})
|
||||
|
||||
if (win) {
|
||||
node = win.node
|
||||
}
|
||||
} else {
|
||||
element.setAttribute("id", id)
|
||||
|
||||
this.root.appendChild(element)
|
||||
|
||||
node = createRoot(element)
|
||||
|
||||
win = {
|
||||
id: id,
|
||||
node: node,
|
||||
}
|
||||
|
||||
this.windows.push(win)
|
||||
}
|
||||
|
||||
if (useFrame) {
|
||||
fragment = <DefaultWindow>
|
||||
{fragment}
|
||||
</DefaultWindow>
|
||||
}
|
||||
|
||||
if (closeOnClickOutside) {
|
||||
document.addEventListener("click", (e) => this.handleWrapperClick(id, e))
|
||||
}
|
||||
|
||||
node.render(React.cloneElement(fragment, {
|
||||
close: () => {
|
||||
this.close(id)
|
||||
}
|
||||
}))
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the window with the given ID.
|
||||
*
|
||||
* @param {string} id - The ID of the window to be closed.
|
||||
* @return {boolean} Returns true if the window was successfully closed, false otherwise.
|
||||
*/
|
||||
close(id) {
|
||||
const element = document.getElementById(id)
|
||||
|
||||
const win = this.windows.find((node) => {
|
||||
return node.id === id
|
||||
})
|
||||
|
||||
if (!win) {
|
||||
this.console.warn(`Window ${id} not found`)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
win.node.unmount()
|
||||
this.root.removeChild(element)
|
||||
|
||||
this.windows = this.windows.filter((node) => {
|
||||
return node.id !== id
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
98
packages/app/src/hooks/useChat/index.js
Normal file
98
packages/app/src/hooks/useChat/index.js
Normal file
@ -0,0 +1,98 @@
|
||||
import React from "react"
|
||||
|
||||
export default (to_user_id) => {
|
||||
const [socket, setSocket] = React.useState(null)
|
||||
const [messages, setMessages] = React.useState([])
|
||||
const [scroller, setScroller] = React.useState(null)
|
||||
|
||||
const [isLocalTyping, setIsLocalTyping] = React.useState(false)
|
||||
const [isRemoteTyping, setIsRemoteTyping] = React.useState(false)
|
||||
|
||||
const [timeoutOffTypingEvent, setTimeoutOffTypingEvent] = React.useState(null)
|
||||
|
||||
async function sendMessage(message) {
|
||||
emitTypingEvent(false)
|
||||
|
||||
await socket.emit("chat:send:message", {
|
||||
to_user_id: to_user_id,
|
||||
content: message,
|
||||
})
|
||||
}
|
||||
|
||||
async function emitTypingEvent(to) {
|
||||
if (isLocalTyping === true && to === true) {
|
||||
return debouncedOffTypingEvent()
|
||||
}
|
||||
|
||||
await socket.emit("chat:state:typing", {
|
||||
to_user_id: to_user_id,
|
||||
is_typing: to,
|
||||
})
|
||||
|
||||
setIsLocalTyping(to)
|
||||
}
|
||||
|
||||
async function debouncedOffTypingEvent() {
|
||||
if (timeoutOffTypingEvent) {
|
||||
clearTimeout(timeoutOffTypingEvent)
|
||||
}
|
||||
|
||||
setTimeoutOffTypingEvent(setTimeout(() => {
|
||||
emitTypingEvent(false)
|
||||
}, 5000))
|
||||
}
|
||||
|
||||
const listenEvents = {
|
||||
"chat:receive:message": (message) => {
|
||||
setMessages((messages) => {
|
||||
return [
|
||||
...messages,
|
||||
message
|
||||
]
|
||||
})
|
||||
},
|
||||
"chat:state:typing": (state) => {
|
||||
setIsRemoteTyping(state.is_typing)
|
||||
},
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scroller?.current) {
|
||||
const paddingBottom = scroller.current.style.paddingBottom.replace("px", "")
|
||||
|
||||
scroller.current?.scrollTo({
|
||||
top: scroller.current.scrollHeight + paddingBottom,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
React.useEffect(() => {
|
||||
const targetSocket = app.cores.api.client().sockets.chats
|
||||
|
||||
setSocket(targetSocket)
|
||||
|
||||
for (const [event, handler] of Object.entries(listenEvents)) {
|
||||
targetSocket.on(event, handler)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [event, handler] of Object.entries(listenEvents)) {
|
||||
targetSocket.off(event, handler)
|
||||
}
|
||||
|
||||
if (timeoutOffTypingEvent) {
|
||||
clearTimeout(timeoutOffTypingEvent)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
messages,
|
||||
setMessages,
|
||||
setScroller,
|
||||
emitTypingEvent,
|
||||
isRemoteTyping,
|
||||
}
|
||||
}
|
70
packages/app/src/hooks/useTextRoom/index.jsx
Normal file
70
packages/app/src/hooks/useTextRoom/index.jsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
import { EventEmitter } from "@foxify/events"
|
||||
|
||||
function useTextRoom(route, options = {
|
||||
persistent: false,
|
||||
}) {
|
||||
const eventEmitter = new EventEmitter()
|
||||
|
||||
const [lines, setLines] = React.useState([])
|
||||
|
||||
const socket = app.cores.api.client().sockets.chats
|
||||
|
||||
function pushToLines(line) {
|
||||
setLines((lines) => {
|
||||
return [
|
||||
...lines,
|
||||
line,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function deleteLine(message) {
|
||||
setLines((lines) => {
|
||||
return lines.filter((line) => line._id !== message._id)
|
||||
})
|
||||
}
|
||||
|
||||
function send(payload) {
|
||||
socket.emit("room:send:message", {
|
||||
...payload,
|
||||
route: route,
|
||||
})
|
||||
}
|
||||
|
||||
const socketEvents = {
|
||||
"room:message": (message) => {
|
||||
eventEmitter.emit("room:message", message)
|
||||
pushToLines(message)
|
||||
},
|
||||
"room:delete:message": (message) => {
|
||||
eventEmitter.emit("room:delete:message", message)
|
||||
deleteLine(message)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
socket.emit("join:room", {
|
||||
...options,
|
||||
room: route,
|
||||
})
|
||||
|
||||
for (const [event, handler] of Object.entries(socketEvents)) {
|
||||
socket.on(event, handler)
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.emit("leave:room", {
|
||||
room: route,
|
||||
})
|
||||
|
||||
for (const [event, handler] of Object.entries(socketEvents)) {
|
||||
socket.off(event, handler)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [send, lines, setLines, eventEmitter]
|
||||
}
|
||||
|
||||
export default useTextRoom
|
@ -44,14 +44,7 @@ export default class Layout extends React.PureComponent {
|
||||
}
|
||||
|
||||
transitionLayer.classList.remove("fade-opacity-leave")
|
||||
},
|
||||
"router.navigate": async (path, options) => {
|
||||
this.progressBar.start()
|
||||
|
||||
await this.makePageTransition(options)
|
||||
|
||||
this.progressBar.done()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -82,33 +75,6 @@ export default class Layout extends React.PureComponent {
|
||||
this.setState({ renderError: { info, stack } })
|
||||
}
|
||||
|
||||
async makePageTransition(options = {}) {
|
||||
if (document.startViewTransition) {
|
||||
return document.startViewTransition(async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, options.state?.transitionDelay ?? 250)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const content_layout = document.getElementById("content_layout")
|
||||
|
||||
if (!content_layout) {
|
||||
console.warn("content_layout not found, no animation will be played")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
content_layout.classList.add("fade-transverse-leave")
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
content_layout.classList.remove("fade-transverse-leave")
|
||||
}, options.state?.transitionDelay ?? 250)
|
||||
})
|
||||
}
|
||||
|
||||
layoutInterface = window.app.layout = {
|
||||
set: (layout) => {
|
||||
if (typeof Layouts[layout] !== "function") {
|
||||
|
@ -8,7 +8,7 @@ import { Icons, createIconRender } from "@components/Icons"
|
||||
|
||||
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
|
||||
|
||||
import { QuickNavMenuItems, QuickNavMenu } from "@components/Layout/quickNav"
|
||||
import { QuickNavMenuItems, QuickNavMenu } from "@layouts/components/quickNav"
|
||||
|
||||
import PlayerView from "@pages/@mobile-views/player"
|
||||
import CreatorView from "@pages/@mobile-views/creator"
|
@ -1,132 +0,0 @@
|
||||
import React from "react"
|
||||
import classnames from "classnames"
|
||||
|
||||
import { DOMWindow } from "@components/RenderWindow"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
class FloatingStackItem extends React.PureComponent {
|
||||
state = {
|
||||
renderError: null
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.log(error, info)
|
||||
|
||||
this.setState({
|
||||
renderError: error,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.renderError) {
|
||||
return <div className="floating_stack_item">
|
||||
<h1>Render Error</h1>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="floating_stack_item" key={this.props.id} id={this.props.id}>
|
||||
<React.Fragment>
|
||||
{this.props.children}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default class FloatingStack extends React.Component {
|
||||
state = {
|
||||
renders: [],
|
||||
globalVisibility: true,
|
||||
}
|
||||
|
||||
public = {
|
||||
add: (id, render) => {
|
||||
try {
|
||||
if (!id) {
|
||||
console.error(`FloatingStack: id is required`)
|
||||
return false
|
||||
}
|
||||
if (!render) {
|
||||
console.error(`FloatingStack: render is required`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.state.renders.find((item) => item.id === id)) {
|
||||
console.error(`FloatingStack: id ${id} already exists`)
|
||||
return false
|
||||
}
|
||||
|
||||
this.setState({
|
||||
renders: [
|
||||
...this.state.renders,
|
||||
{
|
||||
id,
|
||||
render: React.createElement(render),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return render
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
remove: (id) => {
|
||||
this.setState({
|
||||
renders: this.state.renders.filter((item) => {
|
||||
return item.id !== id
|
||||
})
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
toggleGlobalVisibility: (to) => {
|
||||
if (typeof to !== "boolean") {
|
||||
to = !this.state.globalVisibility
|
||||
}
|
||||
|
||||
this.setState({
|
||||
globalVisibility: to,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.app.layout.floatingStack = this.public
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.app.layout.floatingStack = null
|
||||
delete window.app.layout.floatingStack
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div
|
||||
className={classnames(
|
||||
"floating_stack",
|
||||
{
|
||||
["hidden"]: !this.state.globalVisibility,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{
|
||||
this.state.renders.map((item) => {
|
||||
return <FloatingStackItem id={item.id}>
|
||||
{item.render}
|
||||
</FloatingStackItem>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export const createWithDom = () => {
|
||||
const dom = new DOMWindow({
|
||||
id: "FloatingStack",
|
||||
})
|
||||
|
||||
dom.render(<FloatingStack />)
|
||||
|
||||
return dom
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
.floating_stack {
|
||||
position: absolute;
|
||||
z-index: 300;
|
||||
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
max-width: 450px;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
margin: 20px;
|
||||
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
&.hidden {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.floating_stack_item {
|
||||
width: 100%;
|
||||
}
|
@ -12,7 +12,9 @@
|
||||
width: 100%;
|
||||
|
||||
.page_header {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin: 10px 0 20px 0;
|
||||
|
||||
padding: 5px;
|
@ -3,7 +3,6 @@ import { Modal as AntdModal } from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
import { DOMWindow } from "@components/RenderWindow"
|
||||
|
||||
import useLayoutInterface from "@hooks/useLayoutInterface"
|
||||
|
||||
@ -118,8 +117,6 @@ export default () => {
|
||||
render,
|
||||
{
|
||||
framed = true,
|
||||
frameContentStyle = null,
|
||||
includeCloseButton = false,
|
||||
|
||||
confirmOnOutsideClick = false,
|
||||
confirmOnClickTitle,
|
||||
@ -129,20 +126,13 @@ export default () => {
|
||||
props,
|
||||
} = {}
|
||||
) {
|
||||
const win = new DOMWindow({
|
||||
id: id,
|
||||
className: className,
|
||||
})
|
||||
|
||||
win.render(<Modal
|
||||
app.cores.window_mng.render(id, <Modal
|
||||
ref={modalRef}
|
||||
win={win}
|
||||
onClose={() => {
|
||||
win.destroy()
|
||||
app.cores.window_mng.close(id)
|
||||
}}
|
||||
includeCloseButton={includeCloseButton}
|
||||
framed={framed}
|
||||
frameContentStyle={frameContentStyle}
|
||||
className={className}
|
||||
confirmOnOutsideClick={confirmOnOutsideClick}
|
||||
confirmOnClickTitle={confirmOnClickTitle}
|
||||
confirmOnClickContent={confirmOnClickContent}
|
||||
|
@ -43,7 +43,7 @@
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(var(--bg_color_6), 0.5);
|
||||
background-color: rgba(var(--bg_color_6), 0.1);
|
||||
|
||||
backdrop-filter: blur(@modal_background_blur);
|
||||
-webkit-backdrop-filter: blur(@modal_background_blur);
|
||||
|
@ -31,6 +31,9 @@ const onClickHandlers = {
|
||||
search: () => {
|
||||
window.app.controls.openSearcher()
|
||||
},
|
||||
messages: () => {
|
||||
window.app.controls.openMessages()
|
||||
},
|
||||
create: () => {
|
||||
window.app.controls.openCreator()
|
||||
},
|
||||
@ -63,6 +66,37 @@ const generateTopItems = (extra = []) => {
|
||||
})
|
||||
}
|
||||
|
||||
const BottomMenuDefaultItems = [
|
||||
{
|
||||
key: "search",
|
||||
label: <Translation>
|
||||
{(t) => t("Search")}
|
||||
</Translation>,
|
||||
icon: <Icons.Search />,
|
||||
},
|
||||
{
|
||||
key: "messages",
|
||||
label: <Translation>
|
||||
{(t) => t("Messages")}
|
||||
</Translation>,
|
||||
icon: <Icons.MessageCircle />,
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
label: <Translation>
|
||||
{(t) => t("Notifications")}
|
||||
</Translation>,
|
||||
icon: <Icons.Bell />,
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
label: <Translation>
|
||||
{(t) => t("Settings")}
|
||||
</Translation>,
|
||||
icon: <Icons.Settings />,
|
||||
}
|
||||
]
|
||||
|
||||
const ActionMenuItems = [
|
||||
{
|
||||
key: "account",
|
||||
@ -268,7 +302,7 @@ export default class Sidebar extends React.Component {
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
if (e.item.props.ignoreClick) {
|
||||
if (e.item.props.ignore_click === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
@ -331,11 +365,48 @@ export default class Sidebar extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
getBottomItems = () => {
|
||||
const items = [
|
||||
...BottomMenuDefaultItems,
|
||||
...this.state.bottomItems,
|
||||
]
|
||||
|
||||
if (app.userData) {
|
||||
items.push({
|
||||
key: "account",
|
||||
ignore_click: "true",
|
||||
className: "user_avatar",
|
||||
label: <Dropdown
|
||||
menu={{
|
||||
items: ActionMenuItems,
|
||||
onClick: this.onClickDropdownItem
|
||||
}}
|
||||
autoFocus
|
||||
placement="top"
|
||||
trigger={["click"]}
|
||||
>
|
||||
<Avatar shape="square" src={app.userData?.avatar} />
|
||||
</Dropdown>,
|
||||
})
|
||||
}
|
||||
|
||||
if (!app.userData) {
|
||||
items.push({
|
||||
key: "login",
|
||||
label: <Translation>
|
||||
{t => t("Login")}
|
||||
</Translation>,
|
||||
icon: <Icons.LogIn />,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
render() {
|
||||
const defaultSelectedKey = window.location.pathname.replace("/", "")
|
||||
|
||||
return <>
|
||||
<Motion style={{
|
||||
return <Motion style={{
|
||||
x: spring(!this.state.visible ? 100 : 0),
|
||||
}}>
|
||||
{({ x }) => {
|
||||
@ -398,77 +469,13 @@ export default class Sidebar extends React.Component {
|
||||
selectable={false}
|
||||
mode="inline"
|
||||
onClick={this.handleClick}
|
||||
>
|
||||
{
|
||||
this.state.bottomItems.map((item) => {
|
||||
if (item.noContainer) {
|
||||
return React.createElement(item.children, item.childrenProps)
|
||||
}
|
||||
items={this.getBottomItems()}
|
||||
|
||||
return <Menu.Item
|
||||
key={item.id}
|
||||
className="extra_bottom_item"
|
||||
icon={createIconRender(item.icon)}
|
||||
disabled={item.disabled ?? false}
|
||||
{...item.containerProps}
|
||||
>
|
||||
{
|
||||
React.createElement(item.children, item.childrenProps)
|
||||
}
|
||||
</Menu.Item>
|
||||
})
|
||||
}
|
||||
<Menu.Item key="search" icon={<Icons.Search />} >
|
||||
<Translation>
|
||||
{(t) => t("Search")}
|
||||
</Translation>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="notifications" icon={<Icons.Bell />}>
|
||||
<Translation>
|
||||
{t => t("Notifications")}
|
||||
</Translation>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="settings" icon={<Icons.Settings />}>
|
||||
<Translation>
|
||||
{t => t("Settings")}
|
||||
</Translation>
|
||||
</Menu.Item>
|
||||
|
||||
{
|
||||
app.userData && <Dropdown
|
||||
menu={{
|
||||
items: ActionMenuItems,
|
||||
onClick: this.onClickDropdownItem
|
||||
}}
|
||||
autoFocus
|
||||
placement="top"
|
||||
trigger={["click"]}
|
||||
//onOpenChange={this.onDropdownOpenChange}
|
||||
>
|
||||
<Menu.Item
|
||||
key="account"
|
||||
className="user_avatar"
|
||||
ignoreClick
|
||||
onDoubleClick={onClickHandlers.account}
|
||||
>
|
||||
<Avatar shape="square" src={app.userData?.avatar} />
|
||||
</Menu.Item>
|
||||
</Dropdown>
|
||||
}
|
||||
|
||||
{
|
||||
!app.userData && <Menu.Item key="login" icon={<Icons.LogIn />}>
|
||||
<Translation>
|
||||
{t => t("Login")}
|
||||
</Translation>
|
||||
</Menu.Item>
|
||||
}
|
||||
</Menu>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}}
|
||||
</Motion>
|
||||
</>
|
||||
}
|
||||
}
|
@ -173,14 +173,26 @@
|
||||
|
||||
&.user_avatar {
|
||||
.ant-menu-title-content {
|
||||
width: 100%;
|
||||
|
||||
display: inline-flex;
|
||||
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
|
||||
opacity: 1;
|
||||
|
||||
.ant-dropdown-trigger {
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
padding: 0 !important;
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
max-width: 20vw;
|
||||
max-width: 420px;
|
||||
min-width: 320px;
|
||||
|
||||
height: 100vh;
|
@ -2,32 +2,20 @@ import React from "react"
|
||||
import classnames from "classnames"
|
||||
import { Layout } from "antd"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
Drawer,
|
||||
Sidedrawer,
|
||||
BottomBar,
|
||||
TopBar,
|
||||
ToolsBar,
|
||||
Header,
|
||||
} from "@components/Layout"
|
||||
import Sidebar from "@layouts/components/sidebar"
|
||||
import Drawer from "@layouts/components/drawer"
|
||||
import Sidedrawer from "@layouts/components/sidedrawer"
|
||||
import BottomBar from "@layouts/components/bottomBar"
|
||||
import TopBar from "@layouts/components/topBar"
|
||||
import ToolsBar from "@layouts/components/toolsBar"
|
||||
import Header from "@layouts/components/header"
|
||||
import InitializeModalsController from "@layouts/components/modals"
|
||||
|
||||
import BackgroundDecorator from "@components/BackgroundDecorator"
|
||||
|
||||
import { createWithDom as FloatingStack } from "../components/floatingStack"
|
||||
import InitializeModalsController from "../components/modals"
|
||||
|
||||
const DesktopLayout = (props) => {
|
||||
InitializeModalsController()
|
||||
|
||||
React.useEffect(() => {
|
||||
const floatingStack = FloatingStack()
|
||||
|
||||
return () => {
|
||||
floatingStack.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <>
|
||||
<BackgroundDecorator />
|
||||
|
||||
|
@ -2,7 +2,8 @@ import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import { Drawer, Sidedrawer } from "@components/Layout"
|
||||
import Drawer from "@layouts/components/drawer"
|
||||
import Sidedrawer from "@layouts/components/sidedrawer"
|
||||
|
||||
export default (props) => {
|
||||
return <antd.Layout className={classnames("app_layout")} style={{ height: "100%" }}>
|
||||
|
@ -94,7 +94,6 @@ export default class Account extends React.Component {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
onClickFollow = async () => {
|
||||
const result = await FollowsModel.toggleFollow({
|
||||
user_id: this.state.user._id,
|
||||
@ -183,6 +182,13 @@ export default class Account extends React.Component {
|
||||
followed={this.state.following}
|
||||
self={this.state.isSelf}
|
||||
/>
|
||||
|
||||
{
|
||||
!this.state.isSelf && <antd.Button
|
||||
icon={<Icons.MdMessage />}
|
||||
onClick={() => app.location.push(`/messages/${user._id}`)}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -108,7 +108,9 @@
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
height: fit-content;
|
||||
width: 20vw;
|
||||
|
196
packages/app/src/pages/lyrics/components/controller/index.jsx
Normal file
196
packages/app/src/pages/lyrics/components/controller/index.jsx
Normal file
@ -0,0 +1,196 @@
|
||||
import React from "react"
|
||||
import { Tag } from "antd"
|
||||
import classnames from "classnames"
|
||||
import Marquee from "react-fast-marquee"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
import Controls from "@components/Player/Controls"
|
||||
|
||||
import { Context } from "@contexts/WithPlayerContext"
|
||||
|
||||
function isOverflown(element) {
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
|
||||
const RenderArtist = (props) => {
|
||||
const { artist } = props
|
||||
|
||||
if (!artist) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(artist)) {
|
||||
return <h3>{artist.join(",")}</h3>
|
||||
}
|
||||
|
||||
return <h3>{artist}</h3>
|
||||
}
|
||||
|
||||
const RenderAlbum = (props) => {
|
||||
const { album } = props
|
||||
|
||||
if (!album) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(album)) {
|
||||
return <h3>{album.join(",")}</h3>
|
||||
}
|
||||
|
||||
return <h3>{album}</h3>
|
||||
}
|
||||
|
||||
const PlayerController = React.forwardRef((props, ref) => {
|
||||
const context = React.useContext(Context)
|
||||
|
||||
const titleRef = React.useRef()
|
||||
|
||||
const [titleIsOverflown, setTitleIsOverflown] = React.useState(false)
|
||||
|
||||
const [currentTime, setCurrentTime] = React.useState(0)
|
||||
const [trackDuration, setTrackDuration] = React.useState(0)
|
||||
const [draggingTime, setDraggingTime] = React.useState(false)
|
||||
const [currentDragWidth, setCurrentDragWidth] = React.useState(0)
|
||||
const [syncInterval, setSyncInterval] = React.useState(null)
|
||||
|
||||
async function onDragEnd(seekTime) {
|
||||
setDraggingTime(false)
|
||||
|
||||
app.cores.player.seek(seekTime)
|
||||
}
|
||||
|
||||
async function syncPlayback() {
|
||||
if (!context.track_manifest) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentTrackTime = app.cores.player.seek()
|
||||
|
||||
setCurrentTime(currentTrackTime)
|
||||
}
|
||||
|
||||
//* Handle when playback status change
|
||||
React.useEffect(() => {
|
||||
if (context.playback_status === "playing") {
|
||||
setSyncInterval(setInterval(syncPlayback, 1000))
|
||||
} else {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
}
|
||||
}, [context.playback_status])
|
||||
|
||||
React.useEffect(() => {
|
||||
setTitleIsOverflown(isOverflown(titleRef.current))
|
||||
setTrackDuration(app.cores.player.duration())
|
||||
}, [context.track_manifest])
|
||||
|
||||
const isStopped = context.playback_status === "stopped"
|
||||
|
||||
return <div
|
||||
className="lyrics-player-controller-wrapper"
|
||||
>
|
||||
<div className="lyrics-player-controller">
|
||||
<div className="lyrics-player-controller-info">
|
||||
<div className="lyrics-player-controller-info-title">
|
||||
{
|
||||
<h4
|
||||
ref={titleRef}
|
||||
className={classnames(
|
||||
"lyrics-player-controller-info-title-text",
|
||||
{
|
||||
["overflown"]: titleIsOverflown,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{
|
||||
context.playback_status === "stopped" ? "Nothing is playing" : <>
|
||||
{context.track_manifest?.title ?? "Nothing is playing"}
|
||||
</>
|
||||
}
|
||||
</h4>
|
||||
}
|
||||
|
||||
{
|
||||
titleIsOverflown && <Marquee
|
||||
//gradient
|
||||
//gradientColor={bgColor}
|
||||
//gradientWidth={20}
|
||||
play={!isStopped}
|
||||
>
|
||||
<h4>
|
||||
{
|
||||
isStopped ?
|
||||
"Nothing is playing" :
|
||||
<>
|
||||
{context.track_manifest?.title ?? "Untitled"}
|
||||
</>
|
||||
}
|
||||
</h4>
|
||||
</Marquee>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="lyrics-player-controller-info-details">
|
||||
<RenderArtist artist={context.track_manifest?.artists} />
|
||||
-
|
||||
<RenderAlbum album={context.track_manifest?.album} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Controls />
|
||||
|
||||
<div className="lyrics-player-controller-progress-wrapper">
|
||||
<div
|
||||
className="lyrics-player-controller-progress"
|
||||
onMouseDown={(e) => {
|
||||
setDraggingTime(true)
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const seekTime = trackDuration * (e.clientX - rect.left) / rect.width
|
||||
|
||||
onDragEnd(seekTime)
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const atWidth = (e.clientX - rect.left) / rect.width * 100
|
||||
|
||||
setCurrentDragWidth(atWidth)
|
||||
}}
|
||||
>
|
||||
<div className="lyrics-player-controller-progress-bar"
|
||||
style={{
|
||||
width: `${draggingTime ? currentDragWidth : ((currentTime / trackDuration) * 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lyrics-player-controller-tags">
|
||||
{
|
||||
context.track_manifest?.metadata.lossless && <Tag
|
||||
color="geekblue"
|
||||
icon={<Icons.TbWaveSine />}
|
||||
bordered={false}
|
||||
>
|
||||
Lossless
|
||||
</Tag>
|
||||
}
|
||||
{
|
||||
context.track_manifest?.explicit && <Tag
|
||||
bordered={false}
|
||||
>
|
||||
Explicit
|
||||
</Tag>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})
|
||||
|
||||
export default PlayerController
|
147
packages/app/src/pages/lyrics/components/text/index.jsx
Normal file
147
packages/app/src/pages/lyrics/components/text/index.jsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React from "react"
|
||||
import classnames from "classnames"
|
||||
import { Motion, spring } from "react-motion"
|
||||
|
||||
import { Context } from "@contexts/WithPlayerContext"
|
||||
|
||||
const LyricsText = React.forwardRef((props, textRef) => {
|
||||
const context = React.useContext(Context)
|
||||
|
||||
const { lyrics } = props
|
||||
|
||||
const [syncInterval, setSyncInterval] = React.useState(null)
|
||||
const [currentLineIndex, setCurrentLineIndex] = React.useState(0)
|
||||
const [visible, setVisible] = React.useState(false)
|
||||
|
||||
function syncPlayback() {
|
||||
if (!lyrics) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentTrackTime = app.cores.player.seek() * 1000
|
||||
|
||||
const lineIndex = lyrics.lrc.findIndex((line) => {
|
||||
return currentTrackTime >= line.startTimeMs && currentTrackTime <= line.endTimeMs
|
||||
})
|
||||
|
||||
if (lineIndex === -1) {
|
||||
if (!visible) {
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const line = lyrics.lrc[lineIndex]
|
||||
|
||||
setCurrentLineIndex(lineIndex)
|
||||
|
||||
if (line.break) {
|
||||
return setVisible(false)
|
||||
}
|
||||
|
||||
if (line.text) {
|
||||
return setVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
function startSyncInterval() {
|
||||
setSyncInterval(setInterval(syncPlayback, 100))
|
||||
}
|
||||
|
||||
//* Handle when current line index change
|
||||
React.useEffect(() => {
|
||||
if (currentLineIndex === 0) {
|
||||
setVisible(false)
|
||||
} else {
|
||||
// find line element by id
|
||||
const lineElement = textRef.current.querySelector(`#lyrics-line-${currentLineIndex}`)
|
||||
|
||||
// center scroll to current line
|
||||
if (lineElement) {
|
||||
lineElement.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [currentLineIndex])
|
||||
|
||||
//* Handle when playback status change
|
||||
React.useEffect(() => {
|
||||
if (lyrics) {
|
||||
if (typeof lyrics?.lrc !== "undefined") {
|
||||
if (context.playback_status === "playing") {
|
||||
startSyncInterval()
|
||||
} else {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
} startSyncInterval()
|
||||
}
|
||||
}
|
||||
|
||||
}, [context.playback_status])
|
||||
|
||||
//* Handle when lyrics object change
|
||||
React.useEffect(() => {
|
||||
clearInterval(syncInterval)
|
||||
|
||||
if (lyrics) {
|
||||
if (typeof lyrics?.lrc !== "undefined") {
|
||||
if (context.playback_status === "playing") {
|
||||
startSyncInterval()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [lyrics])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!lyrics?.lrc) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div
|
||||
className="lyrics-text-wrapper"
|
||||
>
|
||||
<Motion
|
||||
style={{
|
||||
opacity: spring(visible ? 1 : 0),
|
||||
}}
|
||||
>
|
||||
{({ opacity }) => {
|
||||
return <div
|
||||
ref={textRef}
|
||||
className="lyrics-text"
|
||||
style={{
|
||||
opacity
|
||||
}}
|
||||
>
|
||||
{
|
||||
lyrics.lrc.map((line, index) => {
|
||||
return <p
|
||||
key={index}
|
||||
id={`lyrics-line-${index}`}
|
||||
className={classnames(
|
||||
"line",
|
||||
{
|
||||
["current"]: currentLineIndex === index
|
||||
}
|
||||
)}
|
||||
>
|
||||
{line.text}
|
||||
</p>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}}
|
||||
</Motion>
|
||||
</div>
|
||||
})
|
||||
|
||||
export default LyricsText
|
166
packages/app/src/pages/lyrics/components/video/index.jsx
Normal file
166
packages/app/src/pages/lyrics/components/video/index.jsx
Normal file
@ -0,0 +1,166 @@
|
||||
import React from "react"
|
||||
|
||||
import { Context } from "@contexts/WithPlayerContext"
|
||||
|
||||
const maxLatencyInMs = 55
|
||||
|
||||
const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
const context = React.useContext(Context)
|
||||
|
||||
const { lyrics } = props
|
||||
|
||||
const [syncInterval, setSyncInterval] = React.useState(null)
|
||||
const [syncingVideo, setSyncingVideo] = React.useState(false)
|
||||
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
|
||||
|
||||
async function seekVideoToSyncAudio() {
|
||||
if (lyrics) {
|
||||
if (lyrics.video_source && typeof lyrics.sync_audio_at_ms !== "undefined") {
|
||||
const currentTrackTime = app.cores.player.seek()
|
||||
|
||||
setSyncingVideo(true)
|
||||
|
||||
videoRef.current.currentTime = currentTrackTime + (lyrics.sync_audio_at_ms / 1000) + app.cores.player.gradualFadeMs / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPlayback() {
|
||||
if (!lyrics) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if `sync_audio_at_ms` is present, it means the video must be synced with audio
|
||||
if (lyrics.video_source && typeof lyrics.sync_audio_at_ms !== "undefined") {
|
||||
const currentTrackTime = app.cores.player.seek()
|
||||
const currentVideoTime = videoRef.current.currentTime - (lyrics.sync_audio_at_ms / 1000)
|
||||
|
||||
//console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`)
|
||||
|
||||
const maxOffset = maxLatencyInMs / 1000
|
||||
const currentVideoTimeDiff = Math.abs(currentVideoTime - currentTrackTime)
|
||||
|
||||
setCurrentVideoLatency(currentVideoTimeDiff)
|
||||
|
||||
if (syncingVideo === true) {
|
||||
console.log(`Syncing video...`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentVideoTimeDiff > maxOffset) {
|
||||
console.warn(`Video offset exceeds`, maxOffset)
|
||||
seekVideoToSyncAudio()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startSyncInterval() {
|
||||
setSyncInterval(setInterval(syncPlayback, 100))
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
videoRef.current.addEventListener("seeked", (event) => {
|
||||
setSyncingVideo(false)
|
||||
})
|
||||
|
||||
// videoRef.current.addEventListener("error", (event) => {
|
||||
// console.log("Failed to load", event)
|
||||
// })
|
||||
|
||||
// videoRef.current.addEventListener("ended", (event) => {
|
||||
// console.log("Video ended", event)
|
||||
// })
|
||||
|
||||
// videoRef.current.addEventListener("stalled", (event) => {
|
||||
// console.log("Failed to fetch data, but trying")
|
||||
// })
|
||||
|
||||
// videoRef.current.addEventListener("waiting", (event) => {
|
||||
// console.log("Waiting for data...")
|
||||
// })
|
||||
}, [])
|
||||
|
||||
//* Handle when playback status change
|
||||
React.useEffect(() => {
|
||||
if (typeof lyrics?.sync_audio_at_ms !== "undefined") {
|
||||
if (context.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
|
||||
setSyncInterval(setInterval(syncPlayback, 500))
|
||||
} else {
|
||||
videoRef.current.pause()
|
||||
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [context.playback_status])
|
||||
|
||||
//* Handle when lyrics object change
|
||||
React.useEffect(() => {
|
||||
if (lyrics) {
|
||||
clearInterval(syncInterval)
|
||||
setCurrentVideoLatency(0)
|
||||
setSyncingVideo(false)
|
||||
|
||||
if (lyrics.video_source) {
|
||||
videoRef.current.src = lyrics.video_source
|
||||
|
||||
videoRef.current.load()
|
||||
|
||||
if (typeof lyrics.sync_audio_at_ms !== "undefined") {
|
||||
videoRef.current.currentTime = lyrics.sync_audio_at_ms / 1000
|
||||
|
||||
if (context.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
startSyncInterval()
|
||||
} else {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
|
||||
const currentTime = app.cores.player.seek()
|
||||
|
||||
if (currentTime > 0) {
|
||||
seekVideoToSyncAudio()
|
||||
}
|
||||
} else {
|
||||
videoRef.current.loop = true
|
||||
videoRef.current.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [lyrics])
|
||||
|
||||
React.useEffect(() => {
|
||||
clearInterval(syncInterval)
|
||||
|
||||
return () => {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <>
|
||||
<div className="videoDebugOverlay">
|
||||
<div>
|
||||
<p>Maximun latency</p>
|
||||
<p>{maxLatencyInMs}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Video Latency</p>
|
||||
<p>{(currentVideoLatency * 1000).toFixed(2)}ms</p>
|
||||
</div>
|
||||
{syncingVideo ? <p>Syncing video...</p> : null}
|
||||
</div>
|
||||
|
||||
<video
|
||||
className="lyrics-video"
|
||||
ref={videoRef}
|
||||
controls={false}
|
||||
muted
|
||||
preload="auto"
|
||||
/>
|
||||
</>
|
||||
})
|
||||
|
||||
export default LyricsVideo
|
758
packages/app/src/pages/lyrics/index.jsx
Executable file → Normal file
758
packages/app/src/pages/lyrics/index.jsx
Executable file → Normal file
@ -1,750 +1,76 @@
|
||||
import React from "react"
|
||||
import classnames from "classnames"
|
||||
import Marquee from "react-fast-marquee"
|
||||
|
||||
import Image from "@components/Image"
|
||||
import Controls from "@components/Player/Controls"
|
||||
|
||||
import useMaxScreen from "@utils/useMaxScreen"
|
||||
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
|
||||
|
||||
import request from "comty.js/handlers/request"
|
||||
import MusicService from "@models/music"
|
||||
|
||||
import PlayerController from "./components/controller"
|
||||
import LyricsVideo from "./components/video"
|
||||
import LyricsText from "./components/text"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
function composeRgbValues(values) {
|
||||
let value = ""
|
||||
const EnchancedLyrics = (props) => {
|
||||
const context = React.useContext(Context)
|
||||
const [lyrics, setLyrics] = React.useState(null)
|
||||
|
||||
// only get the first 3 values
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// if last value, don't add comma
|
||||
if (i === 2) {
|
||||
value += `${values[i]}`
|
||||
continue
|
||||
}
|
||||
const videoRef = React.useRef()
|
||||
const textRef = React.useRef()
|
||||
|
||||
value += `${values[i]}, `
|
||||
}
|
||||
async function loadLyrics(track_id) {
|
||||
const result = await MusicService.getTrackLyrics(track_id)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function calculateLineTime(line) {
|
||||
if (!line) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return line.endTimeMs - line.startTimeMs
|
||||
}
|
||||
|
||||
function isOverflown(element) {
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
|
||||
class PlayerController extends React.Component {
|
||||
state = {
|
||||
colorAnalysis: null,
|
||||
currentDragWidth: 0,
|
||||
titleOverflown: false,
|
||||
|
||||
currentDuration: 0,
|
||||
currentTime: 0,
|
||||
|
||||
currentPlaying: app.cores.player.state["track_manifest"],
|
||||
loading: app.cores.player.state["loading"] ?? false,
|
||||
playbackStatus: app.cores.player.state["playback_status"] ?? "stopped",
|
||||
|
||||
audioMuted: app.cores.player.state["muted"] ?? false,
|
||||
volume: app.cores.player.state["volume"],
|
||||
|
||||
syncModeLocked: app.cores.player.state["control_locked"] ?? false,
|
||||
syncMode: app.cores.player.state["sync_mode"],
|
||||
}
|
||||
|
||||
events = {
|
||||
"player.seeked": (seekTime) => {
|
||||
this.setState({
|
||||
currentTime: seekTime,
|
||||
})
|
||||
},
|
||||
"player.state.update:playback_status": (data) => {
|
||||
this.setState({ playbackStatus: data })
|
||||
},
|
||||
"player.state.update:track_manifest": (data) => {
|
||||
this.setState({ titleOverflown: false })
|
||||
|
||||
this.setState({ currentPlaying: data })
|
||||
},
|
||||
"player.state.update:control_locked": (to) => {
|
||||
this.setState({ syncModeLocked: to })
|
||||
},
|
||||
"player.state.update:sync_mode": (to) => {
|
||||
this.setState({ syncMode: to })
|
||||
},
|
||||
"player.state.update:muted": (data) => {
|
||||
this.setState({ audioMuted: data })
|
||||
},
|
||||
"player.state.update:volume": (data) => {
|
||||
this.setState({ audioVolume: data })
|
||||
},
|
||||
"player.state.update:loading": (data) => {
|
||||
this.setState({ loading: data })
|
||||
},
|
||||
}
|
||||
|
||||
titleRef = React.createRef()
|
||||
|
||||
startSync() {
|
||||
// create a interval to get state from player
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval)
|
||||
}
|
||||
|
||||
this.syncInterval = setInterval(() => {
|
||||
const time = app.cores.player.seek()
|
||||
const duration = app.cores.player.duration()
|
||||
|
||||
this.setState({
|
||||
currentDuration: duration,
|
||||
currentTime: time,
|
||||
colorAnalysis: app.cores.player.state.track_manifest?.metadata.cover_analysis,
|
||||
})
|
||||
|
||||
const titleOverflown = isOverflown(this.titleRef.current)
|
||||
|
||||
this.setState({ titleOverflown: titleOverflown })
|
||||
}, 800)
|
||||
}
|
||||
|
||||
onClickPreviousButton = () => {
|
||||
app.cores.player.playback.previous()
|
||||
}
|
||||
|
||||
onClickNextButton = () => {
|
||||
app.cores.player.playback.next()
|
||||
}
|
||||
|
||||
onClicktogglePlayButton = () => {
|
||||
if (this.state?.playbackStatus === "playing") {
|
||||
app.cores.player.playback.pause()
|
||||
} else {
|
||||
app.cores.player.playback.play()
|
||||
if (result) {
|
||||
setLyrics(result)
|
||||
}
|
||||
}
|
||||
|
||||
updateVolume = (value) => {
|
||||
app.cores.player.volume(value)
|
||||
useMaxScreen()
|
||||
|
||||
//* Handle when context change track_manifest
|
||||
React.useEffect(() => {
|
||||
if (context.track_manifest) {
|
||||
loadLyrics(context.track_manifest._id)
|
||||
}
|
||||
}, [context.track_manifest])
|
||||
|
||||
toggleMute = () => {
|
||||
app.cores.player.toggleMute()
|
||||
}
|
||||
//* Handle when lyrics data change
|
||||
React.useEffect(() => {
|
||||
console.log(lyrics)
|
||||
}, [lyrics])
|
||||
|
||||
componentDidMount() {
|
||||
for (const event in this.events) {
|
||||
app.eventBus.on(event, this.events[event])
|
||||
}
|
||||
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval)
|
||||
}
|
||||
|
||||
this.startSync()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
for (const event in this.events) {
|
||||
app.eventBus.off(event, this.events[event])
|
||||
}
|
||||
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval)
|
||||
}
|
||||
}
|
||||
|
||||
onDragEnd = (seekTime) => {
|
||||
this.setState({
|
||||
currentDragWidth: 0,
|
||||
dragging: false,
|
||||
})
|
||||
|
||||
app.cores.player.seek(seekTime)
|
||||
}
|
||||
|
||||
render() {
|
||||
//const bgColor = RGBStringToValues(getComputedStyle(document.documentElement).getPropertyValue("--background-color-accent-values"))
|
||||
|
||||
return <div className="player_controller_wrapper">
|
||||
<div
|
||||
return <div
|
||||
className={classnames(
|
||||
"player_controller",
|
||||
)}
|
||||
>
|
||||
<div className="player_controller_cover">
|
||||
<Image
|
||||
src={this.state.currentPlaying?.cover ?? this.state.currentPlaying?.thumbnail ?? "/assets/no_song.png"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="player_controller_left">
|
||||
<div className="player_controller_info">
|
||||
<div className="player_controller_info_title">
|
||||
"lyrics",
|
||||
{
|
||||
<h4
|
||||
ref={this.titleRef}
|
||||
className={classnames(
|
||||
"player_controller_info_title_text",
|
||||
{
|
||||
["overflown"]: this.state.titleOverflown,
|
||||
["stopped"]: context.playback_status !== "playing",
|
||||
}
|
||||
)}
|
||||
>
|
||||
{
|
||||
this.state.plabackState === "stopped" ? "Nothing is playing" : <>
|
||||
{this.state.currentPlaying?.title ?? "Nothing is playing"}
|
||||
</>
|
||||
}
|
||||
</h4>
|
||||
}
|
||||
<LyricsVideo
|
||||
ref={videoRef}
|
||||
lyrics={lyrics}
|
||||
/>
|
||||
|
||||
{this.state.titleOverflown &&
|
||||
<Marquee
|
||||
//gradient
|
||||
//gradientColor={bgColor}
|
||||
//gradientWidth={20}
|
||||
play={this.state.plabackState !== "stopped"}
|
||||
>
|
||||
<h4>
|
||||
{
|
||||
this.state.plabackState === "stopped" ? "Nothing is playing" : <>
|
||||
{this.state.currentPlaying?.title ?? "Nothing is playing"}
|
||||
</>
|
||||
}
|
||||
</h4>
|
||||
</Marquee>}
|
||||
</div>
|
||||
<div className="player_controller_info_artist">
|
||||
{
|
||||
(this.state.currentPlaying?.metadata?.artist ?? this.state.currentPlaying?.artist) && <>
|
||||
<h3>
|
||||
{this.state.currentPlaying?.metadata?.artist ?? this.state.currentPlaying?.artist ?? "Unknown"}
|
||||
</h3>
|
||||
{
|
||||
(this.state.currentPlaying?.metadata?.album ?? this.state.currentPlaying?.album) && <>
|
||||
<span> - </span>
|
||||
<h3>
|
||||
{this.state.currentPlaying?.metadata?.album ?? this.state.currentPlaying?.album ?? "Unknown"}
|
||||
</h3>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<LyricsText
|
||||
ref={textRef}
|
||||
lyrics={lyrics}
|
||||
/>
|
||||
|
||||
<PlayerController
|
||||
|
||||
<Controls
|
||||
className="player_controller_controls"
|
||||
controls={{
|
||||
previous: this.onClickPreviousButton,
|
||||
toggle: this.onClicktogglePlayButton,
|
||||
next: this.onClickNextButton,
|
||||
}}
|
||||
syncModeLocked={this.state.syncModeLocked}
|
||||
playbackStatus={this.state.playbackStatus}
|
||||
loading={this.state.loading}
|
||||
audioVolume={this.state.audioVolume}
|
||||
audioMuted={this.state.audioMuted}
|
||||
onVolumeUpdate={this.updateVolume}
|
||||
onMuteUpdate={this.toggleMute}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="player_controller_progress_wrapper">
|
||||
<div
|
||||
className="player_controller_progress"
|
||||
onMouseDown={(e) => {
|
||||
this.setState({
|
||||
dragging: true,
|
||||
})
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const seekTime = this.state.currentDuration * (e.clientX - rect.left) / rect.width
|
||||
|
||||
this.onDragEnd(seekTime)
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const atWidth = (e.clientX - rect.left) / rect.width * 100
|
||||
|
||||
this.setState({ currentDragWidth: atWidth })
|
||||
}}
|
||||
>
|
||||
<div className="player_controller_progress_bar"
|
||||
style={{
|
||||
width: `${this.state.dragging ? this.state.currentDragWidth : this.state.currentTime / this.state.currentDuration * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default (props) => {
|
||||
const EnchancedLyricsPage = (props) => {
|
||||
return <WithPlayerContext>
|
||||
<SyncLyrics
|
||||
<EnchancedLyrics
|
||||
{...props}
|
||||
/>
|
||||
</WithPlayerContext>
|
||||
}
|
||||
|
||||
class SyncLyrics extends React.Component {
|
||||
static contextType = Context
|
||||
|
||||
state = {
|
||||
loading: true,
|
||||
notAvailable: false,
|
||||
|
||||
currentManifest: null,
|
||||
currentStatus: null,
|
||||
|
||||
canvas_url: null,
|
||||
lyrics: null,
|
||||
currentLine: null,
|
||||
|
||||
colorAnalysis: null,
|
||||
|
||||
classnames: {
|
||||
"cinematic-mode": false,
|
||||
"centered-player": false,
|
||||
"video-canvas-enabled": false,
|
||||
}
|
||||
}
|
||||
|
||||
visualizerRef = React.createRef()
|
||||
|
||||
videoCanvasRef = React.createRef()
|
||||
|
||||
coverCanvasRef = React.createRef()
|
||||
|
||||
events = {
|
||||
"player.state.update:track_manifest": (currentManifest) => {
|
||||
console.log(currentManifest)
|
||||
this.setState({ currentManifest })
|
||||
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(this.loadLyrics)
|
||||
} else {
|
||||
this.loadLyrics()
|
||||
}
|
||||
},
|
||||
"player.state.update:playback_status": (currentStatus) => {
|
||||
this.setState({ currentStatus })
|
||||
}
|
||||
}
|
||||
|
||||
toggleClassName = (className, to) => {
|
||||
if (typeof to === "undefined") {
|
||||
to = !this.state.classnames[className]
|
||||
}
|
||||
|
||||
if (to) {
|
||||
if (this.state.classnames[className] === true) {
|
||||
return false
|
||||
}
|
||||
|
||||
//app.message.info("Toogling on " + className)
|
||||
|
||||
this.setState({
|
||||
classnames: {
|
||||
...this.state.classnames,
|
||||
[className]: true
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
} else {
|
||||
if (this.state.classnames[className] === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
//app.message.info("Toogling off " + className)
|
||||
|
||||
this.setState({
|
||||
classnames: {
|
||||
...this.state.classnames,
|
||||
[className]: false
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
toggleVideoCanvas = (to) => {
|
||||
return this.toggleClassName("video-canvas-enabled", to)
|
||||
}
|
||||
|
||||
toggleCenteredControllerMode = (to) => {
|
||||
return this.toggleClassName("centered-player", to)
|
||||
}
|
||||
|
||||
toggleCinematicMode = (to) => {
|
||||
return this.toggleClassName("cinematic-mode", to)
|
||||
}
|
||||
|
||||
isCurrentLine = (line) => {
|
||||
if (!this.state.currentLine) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.state.currentLine.startTimeMs === line.startTimeMs
|
||||
}
|
||||
|
||||
loadLyrics = async () => {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval)
|
||||
}
|
||||
|
||||
if (!this.context.track_manifest) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
notAvailable: false,
|
||||
lyrics: null,
|
||||
currentLine: null,
|
||||
canvas_url: null,
|
||||
})
|
||||
|
||||
const api = app.cores.api.instance().instances.music
|
||||
|
||||
let response = await request({
|
||||
instance: api,
|
||||
method: "get",
|
||||
url: `/lyrics/${this.state.currentManifest._id}`,
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
|
||||
this.setState({
|
||||
notAvailable: true,
|
||||
})
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
let data = response.data ?? {
|
||||
lines: [],
|
||||
syncType: null,
|
||||
}
|
||||
|
||||
console.log(this.state.currentManifest)
|
||||
console.log(data)
|
||||
|
||||
if (data.lines.length > 0 && data.syncType === "LINE_SYNCED") {
|
||||
data.lines = data.lines.map((line, index) => {
|
||||
const ref = React.createRef()
|
||||
|
||||
line.ref = ref
|
||||
|
||||
line.startTimeMs = Number(line.startTimeMs)
|
||||
|
||||
const nextLine = data.lines[index + 1]
|
||||
|
||||
// calculate end time
|
||||
line.endTimeMs = nextLine ? Number(nextLine.startTimeMs) : Math.floor(app.cores.player.duration() * 1000)
|
||||
|
||||
return line
|
||||
})
|
||||
}
|
||||
|
||||
if (data.canvas_url) {
|
||||
//app.message.info("Video canvas loaded")
|
||||
console.log(`[SyncLyrics] Video canvas loaded`)
|
||||
|
||||
this.toggleVideoCanvas(true)
|
||||
} else {
|
||||
//app.message.info("No video canvas available for this song")
|
||||
console.log(`[SyncLyrics] No video canvas available for this song`)
|
||||
|
||||
this.toggleVideoCanvas(false)
|
||||
}
|
||||
|
||||
// if has no lyrics or are unsynced, toggle cinematic mode off and center controller
|
||||
if (data.lines.length === 0 || data.syncType !== "LINE_SYNCED") {
|
||||
//app.message.info("No lyrics available for this song")
|
||||
|
||||
console.log(`[SyncLyrics] No lyrics available for this song, sync type [${data.syncType}]`)
|
||||
|
||||
this.toggleCinematicMode(false)
|
||||
this.toggleCenteredControllerMode(true)
|
||||
} else {
|
||||
//app.message.info("Lyrics loaded, starting sync...")
|
||||
console.log(`[SyncLyrics] Starting sync with type [${data.syncType}]`)
|
||||
|
||||
this.toggleCenteredControllerMode(false)
|
||||
this.startLyricsSync()
|
||||
}
|
||||
|
||||
// transform times
|
||||
this.setState({
|
||||
loading: false,
|
||||
syncType: data.syncType,
|
||||
canvas_url: data.canvas_url ?? null,
|
||||
lyrics: data.lines,
|
||||
})
|
||||
}
|
||||
|
||||
startLyricsSync = () => {
|
||||
// create interval to sync lyrics
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval)
|
||||
}
|
||||
|
||||
// scroll to top
|
||||
this.visualizerRef.current.scrollTop = 0
|
||||
|
||||
this.syncInterval = setInterval(() => {
|
||||
if (!this.state.lyrics || !Array.isArray(this.state.lyrics) || this.state.lyrics.length === 0 || !this.state.lyrics[0]) {
|
||||
console.warn(`Clearing interval because lyrics is not found or lyrics is empty, probably because memory leak or unmounted component`)
|
||||
clearInterval(this.syncInterval)
|
||||
return false
|
||||
}
|
||||
|
||||
const time = app.cores.player.seek()
|
||||
|
||||
// transform audio seek time to lyrics time (ms from start) // remove decimals
|
||||
const transformedTime = Math.floor(time * 1000)
|
||||
|
||||
const hasStartedFirst = transformedTime >= this.state.lyrics[0].startTimeMs
|
||||
|
||||
if (!hasStartedFirst) {
|
||||
if (this.state.canvas_url) {
|
||||
this.toggleCinematicMode(true)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// find the closest line to the transformed time
|
||||
const line = this.state.lyrics.find((line) => {
|
||||
// match the closest line to the transformed time
|
||||
return transformedTime >= line.startTimeMs && transformedTime <= line.endTimeMs
|
||||
})
|
||||
|
||||
if (!line || !line.ref) {
|
||||
console.warn(`Clearing interval because cannot find line to sync or line REF is not found, probably because memory leak or unmounted component`)
|
||||
clearInterval(this.syncInterval)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (line) {
|
||||
if (this.isCurrentLine(line)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// set current line
|
||||
this.setState({
|
||||
currentLine: line,
|
||||
})
|
||||
|
||||
//console.log(line)
|
||||
|
||||
if (!line.ref.current) {
|
||||
console.log(line)
|
||||
console.warn(`Clearing interval because line CURRENT ref is not found, probably because memory leak or unmounted component`)
|
||||
clearInterval(this.syncInterval)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
this.visualizerRef.current.scrollTo({
|
||||
top: line.ref.current.offsetTop - (this.visualizerRef.current.offsetHeight / 2),
|
||||
behavior: "smooth",
|
||||
})
|
||||
|
||||
if (this.state.canvas_url) {
|
||||
if (line.words === "♪" || line.words === "♫" || line.words === " " || line.words === "") {
|
||||
//console.log(`[SyncLyrics] Toogling cinematic mode on because line is empty`)
|
||||
|
||||
this.toggleCinematicMode(true)
|
||||
} else {
|
||||
//console.log(`[SyncLyrics] Toogling cinematic mode off because line is not empty`)
|
||||
|
||||
this.toggleCinematicMode(false)
|
||||
}
|
||||
} else {
|
||||
if (this.state.classnames["cinematic-mode"] === true) {
|
||||
this.toggleCinematicMode(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
componentDidMount = async () => {
|
||||
// register player events
|
||||
for (const [event, callback] of Object.entries(this.events)) {
|
||||
app.eventBus.on(event, callback)
|
||||
}
|
||||
|
||||
// get current playback status and time
|
||||
const {
|
||||
track_manifest,
|
||||
playback_status,
|
||||
} = app.cores.player.state
|
||||
|
||||
await this.setState({
|
||||
currentManifest: track_manifest,
|
||||
currentStatus: playback_status,
|
||||
colorAnalysis: track_manifest.cover_analysis,
|
||||
})
|
||||
|
||||
if (app.layout.sidebar) {
|
||||
app.controls.toggleUIVisibility(false)
|
||||
}
|
||||
|
||||
app.layout.toggleCenteredContent(false)
|
||||
|
||||
app.cores.style.compactMode(true)
|
||||
app.cores.style.applyVariant("dark")
|
||||
|
||||
// // request full screen to browser
|
||||
// if (document.fullscreenEnabled) {
|
||||
// document.documentElement.requestFullscreen()
|
||||
// }
|
||||
|
||||
// // listen when user exit full screen to exit cinematic mode
|
||||
// document.addEventListener("fullscreenchange", () => {
|
||||
// if (!document.fullscreenElement) {
|
||||
// app.location.back()
|
||||
// }
|
||||
// })
|
||||
|
||||
window._hacks = {
|
||||
toggleVideoCanvas: this.toggleVideoCanvas,
|
||||
toggleCinematicMode: this.toggleCinematicMode,
|
||||
toggleCenteredControllerMode: this.toggleCenteredControllerMode,
|
||||
}
|
||||
|
||||
await this.loadLyrics()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// unregister player events
|
||||
for (const [event, callback] of Object.entries(this.events)) {
|
||||
app.eventBus.off(event, callback)
|
||||
}
|
||||
|
||||
// clear sync interval
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval)
|
||||
}
|
||||
|
||||
delete window._hacks
|
||||
|
||||
if (app.layout.sidebar) {
|
||||
app.controls.toggleUIVisibility(true)
|
||||
}
|
||||
|
||||
app.cores.style.compactMode(false)
|
||||
app.cores.style.applyInitialVariant()
|
||||
|
||||
// // exit full screen
|
||||
// if (document.fullscreenEnabled) {
|
||||
// document.exitFullscreen()
|
||||
// }
|
||||
}
|
||||
|
||||
renderLines() {
|
||||
if (!this.state.lyrics || this.state.notAvailable || this.state.syncType !== "LINE_SYNCED") {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.state.lyrics.map((line, index) => {
|
||||
return <div
|
||||
ref={line.ref}
|
||||
className={classnames(
|
||||
"lyrics_viewer_lines_line",
|
||||
{
|
||||
["current"]: this.isCurrentLine(line)
|
||||
}
|
||||
)}
|
||||
id={line.startTimeMs}
|
||||
key={index}
|
||||
>
|
||||
<h2>
|
||||
{line.words}
|
||||
</h2>
|
||||
</div>
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div
|
||||
ref={this.visualizerRef}
|
||||
className={classnames(
|
||||
"lyrics_viewer",
|
||||
{
|
||||
["text_dark"]: this.state.colorAnalysis?.isDark ?? false,
|
||||
...Object.entries(this.state.classnames).reduce((acc, [key, value]) => {
|
||||
return {
|
||||
...acc,
|
||||
[key]: value,
|
||||
}
|
||||
}, {}),
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
"--predominant-color": this.state.colorAnalysis?.hex ?? "unset",
|
||||
"--predominant-color-rgb-values": this.state.colorAnalysis?.value ? composeRgbValues(this.state.colorAnalysis?.value) : [0, 0, 0],
|
||||
"--line-time": `${calculateLineTime(this.state.currentLine)}ms`,
|
||||
"--line-animation-play-state": this.state.currentStatus === "playing" ? "running" : "paused",
|
||||
}}
|
||||
>
|
||||
|
||||
<div
|
||||
className="lyrics_viewer_mask"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="lyrics_viewer_video_canvas"
|
||||
>
|
||||
<video
|
||||
src={this.state.canvas_url}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
controls={false}
|
||||
ref={this.videoCanvasRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lyrics_viewer_cover"
|
||||
>
|
||||
<Image
|
||||
src={this.state.currentManifest?.cover ?? this.state.currentManifest?.thumbnail ?? "/assets/no_song.png"}
|
||||
ref={this.coverRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PlayerController />
|
||||
|
||||
<div className="lyrics_viewer_content">
|
||||
<div className="lyrics_viewer_lines">
|
||||
{
|
||||
this.renderLines()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
export default EnchancedLyricsPage
|
564
packages/app/src/pages/lyrics/index.less
Executable file → Normal file
564
packages/app/src/pages/lyrics/index.less
Executable file → Normal file
@ -1,405 +1,130 @@
|
||||
@enabled-video-canvas-opacity: 0.4;
|
||||
// in px
|
||||
@cover-width: 150px;
|
||||
@left-panel-width: 300px;
|
||||
.lyrics {
|
||||
position: relative;
|
||||
|
||||
z-index: 100;
|
||||
|
||||
.lyrics_viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
isolation: isolate;
|
||||
&.stopped {
|
||||
.lyrics-video {
|
||||
filter: blur(6px);
|
||||
}
|
||||
}
|
||||
|
||||
//align-items: center;
|
||||
.lyrics-video {
|
||||
z-index: 105;
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
padding: 50px 0;
|
||||
object-fit: cover;
|
||||
|
||||
overflow-y: hidden;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
background-color: rgba(var(--predominant-color-rgb-values), 0.8);
|
||||
background:
|
||||
linear-gradient(20deg, rgba(var(--predominant-color-rgb-values), 0.8), rgba(var(--predominant-color-rgb-values), 0.2)),
|
||||
url(https://grainy-gradients.vercel.app/noise.svg);
|
||||
|
||||
//background-size: 1%;
|
||||
background-position: center;
|
||||
|
||||
&.video-canvas-enabled {
|
||||
background-color: rgba(var(--predominant-color-rgb-values), 1);
|
||||
|
||||
.lyrics_viewer_video_canvas {
|
||||
video {
|
||||
opacity: @enabled-video-canvas-opacity;
|
||||
}
|
||||
}
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
|
||||
&.centered-player {
|
||||
.lyrics_viewer_cover {
|
||||
width: 100vw;
|
||||
.lyrics-text-wrapper {
|
||||
z-index: 110;
|
||||
position: fixed;
|
||||
|
||||
height: 80vh; //fallback
|
||||
height: 80dvh;
|
||||
|
||||
opacity: 1;
|
||||
|
||||
bottom: 20vh;
|
||||
}
|
||||
|
||||
.player_controller_wrapper {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 60px;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.lyrics-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 600px;
|
||||
height: 200px;
|
||||
|
||||
padding: 20px;
|
||||
gap: 30px;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
background-color: rgba(var(--background-color-accent-values), 0.6);
|
||||
border-radius: 12px;
|
||||
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
|
||||
.line {
|
||||
font-size: 2rem;
|
||||
|
||||
opacity: 0.1;
|
||||
|
||||
margin: 0;
|
||||
|
||||
.player_controller {
|
||||
margin-top: 40%;
|
||||
|
||||
max-width: 50vw;
|
||||
max-height: 50vh;
|
||||
|
||||
width: 100%;
|
||||
//height: 100%;
|
||||
|
||||
border-radius: 18px;
|
||||
|
||||
gap: 0;
|
||||
|
||||
padding: 20px 40px;
|
||||
|
||||
.player_controller_left {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.player_controller_cover {
|
||||
width: 0px;
|
||||
|
||||
min-width: 0px;
|
||||
|
||||
img {
|
||||
min-width: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.player_controller_info {
|
||||
.player_controller_info_title {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.cinematic-mode {
|
||||
.lyrics_viewer_mask {
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px)
|
||||
}
|
||||
|
||||
.lyrics_viewer_video_canvas {
|
||||
video {
|
||||
&.current {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics_viewer_content {
|
||||
.lyrics_viewer_lines {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.text_dark {
|
||||
.lyrics_viewer_content {
|
||||
.lyrics_viewer_lines {
|
||||
.lyrics_viewer_lines_line {
|
||||
color: var(--text-color-white);
|
||||
|
||||
h2 {
|
||||
color: var(--text-color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics_viewer_mask {
|
||||
position: absolute;
|
||||
|
||||
z-index: 200;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
backdrop-filter: blur(21px);
|
||||
-webkit-backdrop-filter: blur(21px)
|
||||
}
|
||||
|
||||
.lyrics_viewer_video_canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
width: 100%;
|
||||
//height: 100dvh;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
object-fit: cover;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics_viewer_cover {
|
||||
position: absolute;
|
||||
.lyrics-player-controller-wrapper {
|
||||
position: fixed;
|
||||
z-index: 115;
|
||||
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
z-index: 250;
|
||||
padding: 60px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
.lyrics-player-controller {
|
||||
position: relative;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 25vw;
|
||||
height: 25vw;
|
||||
|
||||
max-width: 500px;
|
||||
max-height: 500px;
|
||||
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics_viewer_content {
|
||||
z-index: 250;
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
.lyrics_viewer_lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 90%;
|
||||
gap: 10px;
|
||||
|
||||
margin: auto;
|
||||
width: 300px;
|
||||
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
//height: 50dvh;
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.lyrics_viewer_lines_line {
|
||||
transition: all 150ms ease-in-out;
|
||||
z-index: 250;
|
||||
|
||||
text-wrap: balance;
|
||||
|
||||
h2 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
&.current {
|
||||
margin: 20px 0;
|
||||
font-size: 2rem;
|
||||
|
||||
animation: spacing-letters var(--line-time) ease-in-out forwards;
|
||||
animation-play-state: var(--line-animation-play-state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spacing-letters {
|
||||
0% {
|
||||
letter-spacing: 0.3rem;
|
||||
}
|
||||
|
||||
100% {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.player_controller_wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
position: absolute;
|
||||
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
margin: 50px;
|
||||
|
||||
z-index: 350;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
.marquee-container {
|
||||
gap: 60px;
|
||||
}
|
||||
|
||||
.player_controller {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
min-width: 350px;
|
||||
max-width: 500px;
|
||||
|
||||
height: 220px;
|
||||
|
||||
background-color: rgba(var(--background-color-accent-values), 0.4);
|
||||
// background:
|
||||
// linear-gradient(20deg, rgba(var(--background-color-accent-values), 0.8), transparent),
|
||||
// url(https://grainy-gradients.vercel.app/noise.svg);
|
||||
|
||||
-webkit-backdrop-filter: blur(21px);
|
||||
backdrop-filter: blur(21px);
|
||||
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2);
|
||||
|
||||
padding: 20px;
|
||||
padding: 30px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
gap: 20px;
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
|
||||
color: var(--text-color);
|
||||
background-color: rgba(var(--background-color-accent-values), 0.8);
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
.player_controller_controls {
|
||||
height: 8vh;
|
||||
max-height: 100px;
|
||||
gap: 20px;
|
||||
|
||||
.player-controls {
|
||||
opacity: 1;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.player_controller_progress_wrapper {
|
||||
bottom: 7px;
|
||||
|
||||
.player_controller_progress {
|
||||
.lyrics-player-controller-tags {
|
||||
opacity: 1;
|
||||
height: 10px;
|
||||
|
||||
width: 90%;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player_controller_cover {
|
||||
.lyrics-player-controller-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: @cover-width;
|
||||
min-width: @cover-width;
|
||||
max-width: @cover-width;
|
||||
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
object-fit: cover;
|
||||
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.player_controller_left {
|
||||
flex: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 100%;
|
||||
width: @left-panel-width;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
.player_controller_info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
//align-items: flex-start;
|
||||
|
||||
width: 100%;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
.player_controller_info_title {
|
||||
.lyrics-player-controller-info-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
|
||||
@ -411,7 +136,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.player_controller_info_title_text {
|
||||
.lyrics-player-controller-title-text {
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
width: 90%;
|
||||
@ -428,7 +153,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.player_controller_info_artist {
|
||||
.lyrics-player-controller-info-details {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@ -437,120 +162,99 @@
|
||||
gap: 7px;
|
||||
|
||||
font-size: 0.6rem;
|
||||
|
||||
font-weight: 400;
|
||||
|
||||
// do not wrap text
|
||||
white-space: nowrap;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
opacity: 0;
|
||||
height: 0px;
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
|
||||
.player_controller_controls {
|
||||
.lyrics-player-controller-progress-wrapper {
|
||||
width: 100%;
|
||||
|
||||
.lyrics-player-controller-progress {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
padding: 10px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
margin: auto;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
background-color: rgba(var(--background-color-accent-values), 0.8);
|
||||
|
||||
&:hover {
|
||||
.lyrics-player-controller-progress-bar {
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-player-controller-progress-bar {
|
||||
height: 5px;
|
||||
|
||||
background-color: white;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-player-controller-tags {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playButton {
|
||||
position: relative;
|
||||
.videoDebugOverlay {
|
||||
position: fixed;
|
||||
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
|
||||
z-index: 115;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.loadCircle {
|
||||
position: absolute;
|
||||
|
||||
z-index: 330;
|
||||
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
margin: auto;
|
||||
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
|
||||
transform: scale(1.5);
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
path {
|
||||
stroke: var(--text-color);
|
||||
stroke-width: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player_controller_progress_wrapper {
|
||||
position: absolute;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
margin: auto;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.player_controller_progress {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
height: 5px;
|
||||
width: 100%;
|
||||
|
||||
margin: auto;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
|
||||
.player_controller_progress_bar {
|
||||
height: 100%;
|
||||
background-color: rgba(var(--background-color-accent-values), 0.8);
|
||||
|
||||
background-color: var(--background-color-contrast);
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bottom-to-top {
|
||||
0% {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 20vh;
|
||||
width: 200px;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
161
packages/app/src/pages/messages/[to_user_id]/index.jsx
Normal file
161
packages/app/src/pages/messages/[to_user_id]/index.jsx
Normal file
@ -0,0 +1,161 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import UserPreview from "@components/UserPreview"
|
||||
|
||||
import useChat from "@hooks/useChat"
|
||||
import ChatsService from "@models/chats"
|
||||
|
||||
import lodash from "lodash"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const ChatPage = (props) => {
|
||||
const { to_user_id } = props.params
|
||||
|
||||
const messagesRef = React.useRef()
|
||||
|
||||
const [isOnBottomView, setIsOnBottomView] = React.useState(true)
|
||||
const [currentText, setCurrentText] = React.useState("")
|
||||
|
||||
const [L_History, R_History, E_History, M_History] = app.cores.api.useRequest(ChatsService.getChatHistory, to_user_id)
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
messages,
|
||||
setMessages,
|
||||
setScroller,
|
||||
emitTypingEvent,
|
||||
isRemoteTyping,
|
||||
} = useChat(to_user_id)
|
||||
|
||||
async function submitMessage() {
|
||||
if (!currentText) {
|
||||
return false
|
||||
}
|
||||
|
||||
await sendMessage(currentText)
|
||||
|
||||
setCurrentText("")
|
||||
}
|
||||
|
||||
async function onInputChange(e) {
|
||||
const value = e.target.value
|
||||
|
||||
setCurrentText(value)
|
||||
|
||||
if (value === "") {
|
||||
emitTypingEvent(false)
|
||||
} {
|
||||
emitTypingEvent(true)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (R_History) {
|
||||
setMessages(R_History.list)
|
||||
// scroll to bottom
|
||||
messagesRef.current?.scrollTo({
|
||||
top: messagesRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [R_History])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOnBottomView === true) {
|
||||
setScroller(messagesRef)
|
||||
} else {
|
||||
setScroller(null)
|
||||
}
|
||||
}, [isOnBottomView])
|
||||
|
||||
if (E_History) {
|
||||
return <antd.Result
|
||||
status="warning"
|
||||
title="Error"
|
||||
subTitle={E_History.message}
|
||||
/>
|
||||
}
|
||||
|
||||
if (L_History) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
return <div
|
||||
className="chat-page"
|
||||
>
|
||||
<div className="chat-page-header">
|
||||
<UserPreview
|
||||
user_id={to_user_id}
|
||||
/>
|
||||
|
||||
{
|
||||
isRemoteTyping && <p>Typing...</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classnames(
|
||||
"chat-page-messages",
|
||||
{
|
||||
["empty"]: messages.length === 0
|
||||
}
|
||||
)}
|
||||
ref={messagesRef}
|
||||
>
|
||||
{
|
||||
messages.length === 0 && <antd.Empty />
|
||||
}
|
||||
|
||||
{
|
||||
messages.map((line, index) => {
|
||||
return <div
|
||||
key={index}
|
||||
className={classnames(
|
||||
"chat-page-line-wrapper",
|
||||
{
|
||||
["self"]: line.user._id === app.userData._id
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="chat-page-line">
|
||||
<div
|
||||
className="chat-page-line-avatar"
|
||||
>
|
||||
<img
|
||||
src={line.user.avatar}
|
||||
/>
|
||||
<span>
|
||||
{line.user.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="chat-page-line-text"
|
||||
>
|
||||
<p>
|
||||
{line.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="chat-page-input-wrapper">
|
||||
<div className="chat-page-input">
|
||||
<antd.Input
|
||||
placeholder="Enter message"
|
||||
value={currentText}
|
||||
onChange={onInputChange}
|
||||
onPressEnter={submitMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ChatPage
|
108
packages/app/src/pages/messages/[to_user_id]/index.less
Normal file
108
packages/app/src/pages/messages/[to_user_id]/index.less
Normal file
@ -0,0 +1,108 @@
|
||||
.chat-page {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
height: 90vh;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.chat-page-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.chat-page-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
height: 80vh;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
padding: 10px;
|
||||
padding-bottom: 20px;
|
||||
|
||||
overflow-y: overlay;
|
||||
|
||||
&.empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-page-line-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
&.self {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-page-line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
width: fit-content;
|
||||
max-width: 300px;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
|
||||
.chat-page-line-avatar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 7px;
|
||||
|
||||
img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-page-line-text {
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
line-break: break-all;
|
||||
word-break: break-all;
|
||||
|
||||
margin: 0;
|
||||
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-page-input-wrapper {
|
||||
position: absolute;
|
||||
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.chat-page-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
13
packages/app/src/pages/messages/index.jsx
Normal file
13
packages/app/src/pages/messages/index.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const MessagesPage = (props) => {
|
||||
return <div
|
||||
className="messages-page"
|
||||
>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
export default MessagesPage
|
0
packages/app/src/pages/messages/index.less
Normal file
0
packages/app/src/pages/messages/index.less
Normal file
@ -7,10 +7,10 @@ import MusicService from "@models/music"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const TrackPage = (props) => {
|
||||
const { id } = props.params
|
||||
const Item = (props) => {
|
||||
const { type, id } = props.params
|
||||
|
||||
const [loading, result, error, makeRequest] = app.cores.api.useRequest(MusicService.getTrackData, id)
|
||||
const [loading, result, error, makeRequest] = app.cores.api.useRequest(MusicService.getReleaseData, id)
|
||||
|
||||
if (error) {
|
||||
return <antd.Result
|
||||
@ -26,15 +26,11 @@ const TrackPage = (props) => {
|
||||
|
||||
return <div className="track-page">
|
||||
<PlaylistView
|
||||
playlist={{
|
||||
title: result.title,
|
||||
cover: result.cover_url,
|
||||
list: [result]
|
||||
}}
|
||||
playlist={result}
|
||||
centered={app.isMobile}
|
||||
hasMore={false}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default TrackPage
|
||||
export default Item
|
3
packages/app/src/pages/music/[type]/[id]/index.less
Normal file
3
packages/app/src/pages/music/[type]/[id]/index.less
Normal file
@ -0,0 +1,3 @@
|
||||
.track-page {
|
||||
width: 100%;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
.track-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
}
|
@ -3,7 +3,6 @@ import * as antd from "antd"
|
||||
import { FloatingPanel } from "antd-mobile"
|
||||
|
||||
import PostCard from "@components/PostCard"
|
||||
import CommentsCard from "@components/CommentsCard"
|
||||
|
||||
import Post from "@models/post"
|
||||
|
||||
@ -44,7 +43,7 @@ export default (props) => {
|
||||
<PostCard data={data} fullmode />
|
||||
|
||||
<FloatingPanel anchors={floatingPanelAnchors}>
|
||||
<CommentsCard post_id={post_id} />
|
||||
|
||||
</FloatingPanel>
|
||||
</div>
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
// Patch global prototypes
|
||||
import { Buffer } from "buffer"
|
||||
|
||||
globalThis.IS_MOBILE_HOST = window.navigator.userAgent === "capacitor"
|
||||
|
||||
window.Buffer = Buffer
|
||||
|
||||
Array.prototype.findAndUpdateObject = function (discriminator, obj) {
|
||||
|
@ -50,6 +50,7 @@ const generateRoutes = () => {
|
||||
.replace(/\/src\/pages|index|\.mobile|\.jsx$/g, "")
|
||||
.replace(/\/src\/pages|index|\.mobile|\.tsx$/g, "")
|
||||
|
||||
path = path.replace(/\[([a-z]+)\]/g, ":$1")
|
||||
path = path.replace(/\[\.{3}.+\]/, "*").replace(/\[(.+)\]/, ":$1")
|
||||
|
||||
return {
|
||||
@ -59,15 +60,8 @@ const generateRoutes = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function generatePageElementWrapper(route, element, bindProps) {
|
||||
return React.createElement((props) => {
|
||||
const params = useParams()
|
||||
const url = new URL(window.location)
|
||||
const query = new Proxy(url, {
|
||||
get: (target, prop) => target.searchParams.get(prop),
|
||||
})
|
||||
|
||||
const routeDeclaration = routesDeclaration.find((layout) => {
|
||||
function findRouteDeclaration(route) {
|
||||
return routesDeclaration.find((layout) => {
|
||||
const routePath = layout.path.replace(/\*/g, ".*").replace(/!/g, "^")
|
||||
|
||||
return new RegExp(routePath).test(route)
|
||||
@ -75,68 +69,79 @@ function generatePageElementWrapper(route, element, bindProps) {
|
||||
path: route,
|
||||
useLayout: "default",
|
||||
}
|
||||
}
|
||||
|
||||
route = route.replace(/\?.+$/, "").replace(/\/{2,}/g, "/")
|
||||
route = route.replace(/\/$/, "")
|
||||
function isAuthenticated() {
|
||||
return !!app.userData
|
||||
}
|
||||
|
||||
if (routeDeclaration) {
|
||||
if (!bindProps.user && (window.location.pathname !== config.app?.authPath)) {
|
||||
if (!routeDeclaration.public) {
|
||||
function handleRouteDeclaration(declaration) {
|
||||
React.useEffect(() => {
|
||||
if (declaration) {
|
||||
// if not authenticated and is not in public route, redirect
|
||||
if (!isAuthenticated() && !declaration.public && (window.location.pathname !== config.app?.authPath)) {
|
||||
if (typeof window.app.location.push === "function") {
|
||||
window.app.location.push(config.app?.authPath ?? "/login")
|
||||
return <div />
|
||||
}
|
||||
|
||||
app.cores.notifications.new({
|
||||
title: "Please login to use this feature.",
|
||||
duration: 15,
|
||||
})
|
||||
} else {
|
||||
window.location.href = config.app?.authPath ?? "/login"
|
||||
|
||||
return <div />
|
||||
}
|
||||
} else {
|
||||
if (declaration.useLayout) {
|
||||
app.layout.set(declaration.useLayout)
|
||||
}
|
||||
|
||||
if (routeDeclaration.useLayout) {
|
||||
app.layout.set(routeDeclaration.useLayout)
|
||||
}
|
||||
|
||||
if (typeof routeDeclaration.centeredContent !== "undefined") {
|
||||
if (typeof declaration.centeredContent !== "undefined") {
|
||||
let finalBool = null
|
||||
|
||||
if (typeof routeDeclaration.centeredContent === "boolean") {
|
||||
finalBool = routeDeclaration.centeredContent
|
||||
if (typeof declaration.centeredContent === "boolean") {
|
||||
finalBool = declaration.centeredContent
|
||||
} else {
|
||||
if (app.isMobile) {
|
||||
finalBool = routeDeclaration.centeredContent?.mobile ?? null
|
||||
finalBool = declaration.centeredContent?.mobile ?? null
|
||||
} else {
|
||||
finalBool = routeDeclaration.centeredContent?.desktop ?? null
|
||||
finalBool = declaration.centeredContent?.desktop ?? null
|
||||
}
|
||||
}
|
||||
|
||||
app.layout.toggleCenteredContent(finalBool)
|
||||
}
|
||||
|
||||
if (typeof routeDeclaration.useTitle !== "undefined") {
|
||||
if (typeof routeDeclaration.useTitle === "function") {
|
||||
routeDeclaration.useTitle = routeDeclaration.useTitle(route, params)
|
||||
if (typeof declaration.useTitle !== "undefined") {
|
||||
if (typeof declaration.useTitle === "function") {
|
||||
declaration.useTitle = declaration.useTitle(path, params)
|
||||
}
|
||||
|
||||
document.title = `${routeDeclaration.useTitle} - ${config.app.siteName}`
|
||||
document.title = `${declaration.useTitle} - ${config.app.siteName}`
|
||||
} else {
|
||||
document.title = config.app.siteName
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof routeDeclaration?.mobileTopBarSpacer === "boolean" && app.isMobile) {
|
||||
app.layout.toggleTopBarSpacer(routeDeclaration.mobileTopBarSpacer)
|
||||
} else {
|
||||
app.layout.toggleTopBarSpacer(false)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
function generatePageElementWrapper(path, element, props, declaration) {
|
||||
return React.createElement((props) => {
|
||||
const params = useParams()
|
||||
const url = new URL(window.location)
|
||||
const query = new Proxy(url, {
|
||||
get: (target, prop) => target.searchParams.get(prop),
|
||||
})
|
||||
|
||||
handleRouteDeclaration(declaration)
|
||||
|
||||
return React.createElement(
|
||||
loadable(element, {
|
||||
fallback: React.createElement(bindProps.staticRenders?.PageLoad || DefaultLoadingRender),
|
||||
fallback: React.createElement(props.staticRenders?.PageLoad || DefaultLoadingRender),
|
||||
}),
|
||||
{
|
||||
...props,
|
||||
...bindProps,
|
||||
...props,
|
||||
url: url,
|
||||
params: params,
|
||||
query: query,
|
||||
@ -160,36 +165,22 @@ const NavigationController = (props) => {
|
||||
state = {}
|
||||
}
|
||||
|
||||
const transitionDuration = app.cores.style.getValue("page-transition-duration") ?? "250ms"
|
||||
|
||||
state.transitionDelay = Number(transitionDuration.replace("ms", ""))
|
||||
|
||||
app.eventBus.emit("router.navigate", to, {
|
||||
state,
|
||||
})
|
||||
|
||||
app.location.last = window.location
|
||||
|
||||
if (state.transitionDelay >= 100) {
|
||||
await new Promise((resolve) => setTimeout(resolve, state.transitionDelay))
|
||||
}
|
||||
|
||||
return navigate(to, {
|
||||
state
|
||||
})
|
||||
}
|
||||
|
||||
async function backLocation() {
|
||||
const transitionDuration = app.cores.style.getValue("page-transition-duration") ?? "250ms"
|
||||
|
||||
app.eventBus.emit("router.navigate")
|
||||
|
||||
app.location.last = window.location
|
||||
|
||||
if (transitionDuration >= 100) {
|
||||
await new Promise((resolve) => setTimeout(resolve, transitionDuration))
|
||||
}
|
||||
|
||||
return window.history.back()
|
||||
}
|
||||
|
||||
@ -220,10 +211,12 @@ export const PageRender = React.memo((props) => {
|
||||
return <Routes>
|
||||
{
|
||||
routes.map((route, index) => {
|
||||
const declaration = findRouteDeclaration(route.path)
|
||||
|
||||
return <Route
|
||||
key={index}
|
||||
path={route.path}
|
||||
element={generatePageElementWrapper(route.path, route.element, props)}
|
||||
element={generatePageElementWrapper(route.path, route.element, props, declaration)}
|
||||
exact
|
||||
/>
|
||||
})
|
||||
|
@ -106,7 +106,8 @@ export default {
|
||||
id: "style.uiFontScale",
|
||||
group: "aspect",
|
||||
component: "Slider",
|
||||
title: "UI font scale",
|
||||
icon: "MdFormatSize",
|
||||
title: "Font scale",
|
||||
description: "Change the font scale of the application.",
|
||||
props: {
|
||||
min: 1,
|
||||
@ -133,7 +134,7 @@ export default {
|
||||
group: "aspect",
|
||||
component: "Select",
|
||||
icon: "MdOutlineFontDownload",
|
||||
title: "UI font",
|
||||
title: "Font family",
|
||||
description: "Change the font of the application.",
|
||||
props: {
|
||||
style: {
|
||||
@ -178,18 +179,7 @@ export default {
|
||||
},
|
||||
storaged: false,
|
||||
},
|
||||
// {
|
||||
// id: "style.parallaxBackground",
|
||||
// group: "aspect",
|
||||
// component: "Switch",
|
||||
// icon: "MdOutline3DRotation",
|
||||
// title: "Parallax background",
|
||||
// description: "Create a parallax effect on the background.",
|
||||
// dependsOn: {
|
||||
// "style.backgroundImage": true
|
||||
// },
|
||||
// storaged: true,
|
||||
// },
|
||||
|
||||
{
|
||||
id: "style.backgroundImage",
|
||||
group: "aspect",
|
||||
@ -225,27 +215,6 @@ export default {
|
||||
},
|
||||
storaged: false,
|
||||
},
|
||||
{
|
||||
id: "style.backgroundPattern",
|
||||
group: "aspect",
|
||||
icon: "MdGrid4X4",
|
||||
component: loadable(() => import("../components/backgroundSelector")),
|
||||
title: "Background pattern",
|
||||
description: "Change background pattern of the application.",
|
||||
extraActions: [
|
||||
{
|
||||
id: "remove",
|
||||
icon: "Delete",
|
||||
title: "Remove",
|
||||
onClick: () => {
|
||||
app.cores.style.modify({
|
||||
backgroundSVG: "unset"
|
||||
})
|
||||
}
|
||||
}
|
||||
],
|
||||
storaged: false,
|
||||
},
|
||||
{
|
||||
id: "style.backgroundBlur",
|
||||
group: "aspect",
|
||||
|
@ -1,4 +1,4 @@
|
||||
@buttonsBorderRadius: 9px;
|
||||
@import "./vars.less";
|
||||
|
||||
:root {
|
||||
--adm-color-background: var(--background-color-primary);
|
||||
|
@ -1,17 +0,0 @@
|
||||
/* Default */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap');
|
||||
|
||||
/* PostMessage */
|
||||
@import url('https://fonts.googleapis.com/css?family=Poppins:300,300i,500,500i,700');
|
||||
|
||||
@import url('https://fonts.googleapis.com/css?family=Alata&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css?family=Source+Sans+Pro&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css?family=Kulim+Park&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Recursive:wght@300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
@import url('https://fonts.cdnfonts.com/css/mona-sans');
|
||||
@import url('https://fonts.cdnfonts.com/css/hubot-sans');
|
10
packages/app/src/styles/fonts.less
Executable file
10
packages/app/src/styles/fonts.less
Executable file
@ -0,0 +1,10 @@
|
||||
/* Selectable fonts for users */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap');
|
||||
|
||||
/* Required secondary fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;600&display=swap');
|
||||
|
||||
/* Disabled fonts */
|
||||
/* @import url('https://fonts.googleapis.com/css2?family=Recursive:wght@300;400;500;600;700;800;900&display=swap'); */
|
@ -1,6 +1,6 @@
|
||||
@import "@styles/animations.less";
|
||||
@import "@styles/vars.less";
|
||||
@import "@styles/fonts.css";
|
||||
@import "@styles/fonts.less";
|
||||
@import "@styles/fixments.less";
|
||||
@import "@styles/mobile.less";
|
||||
@import "@styles/splash.less";
|
||||
|
@ -20,5 +20,4 @@
|
||||
@bottomBar_iconSize: 45px;
|
||||
@topBar_height: 52px;
|
||||
|
||||
@modal_background_blur: 10px;
|
||||
|
||||
@modal_background_blur: 2px;
|
@ -1,35 +1,53 @@
|
||||
export default (uri, filename) => {
|
||||
app.message.info("Downloading media...")
|
||||
import axios from "axios"
|
||||
import mime from "mime"
|
||||
|
||||
fetch(uri, {
|
||||
method: "GET",
|
||||
export default async (uri) => {
|
||||
const key = `download-${uri}`
|
||||
console.log(`[UTIL] Downloading ${uri}`)
|
||||
|
||||
try {
|
||||
app.cores.notifications.new({
|
||||
key: key,
|
||||
title: "Downloading",
|
||||
duration: 0,
|
||||
type: "loading",
|
||||
closable: false,
|
||||
feedback: false,
|
||||
})
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => {
|
||||
if (!filename) {
|
||||
filename = uri.split("/").pop()
|
||||
}
|
||||
|
||||
// Create blob link to download
|
||||
const url = window.URL.createObjectURL(new Blob([blob]))
|
||||
const metadata = await axios({
|
||||
method: "HEAD",
|
||||
url: uri,
|
||||
})
|
||||
|
||||
const extension = mime.getExtension(metadata.headers["content-type"])
|
||||
const filename = `${metadata.headers["x-amz-meta-file-hash"]}.${extension}`
|
||||
|
||||
const content = await axios({
|
||||
method: "GET",
|
||||
url: uri,
|
||||
responseType: "blob",
|
||||
})
|
||||
|
||||
const file = new File([content.data], filename, {
|
||||
name: filename,
|
||||
type: metadata.headers["content-type"],
|
||||
})
|
||||
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
const link = document.createElement("a")
|
||||
|
||||
link.href = url
|
||||
|
||||
link.setAttribute("download", filename)
|
||||
|
||||
// Append to html link element page
|
||||
document.body.appendChild(link)
|
||||
|
||||
// Start download
|
||||
link.download = file.name
|
||||
link.click()
|
||||
|
||||
// Clean up and remove the link
|
||||
link.parentNode.removeChild(link)
|
||||
})
|
||||
.catch((error) => {
|
||||
setTimeout(() => {
|
||||
app.cores.notifications.close(key)
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.message.error("Failed to download media")
|
||||
})
|
||||
|
||||
app.cores.notifications.close(key)
|
||||
}
|
||||
}
|
24
packages/app/src/utils/useMaxScreen/index.js
Normal file
24
packages/app/src/utils/useMaxScreen/index.js
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react"
|
||||
|
||||
export default () => {
|
||||
const enterPlayerAnimation = () => {
|
||||
app.cores.style.applyVariant("dark")
|
||||
app.cores.style.compactMode(true)
|
||||
app.layout.toggleCenteredContent(false)
|
||||
app.controls.toggleUIVisibility(false)
|
||||
}
|
||||
|
||||
const exitPlayerAnimation = () => {
|
||||
app.cores.style.applyInitialVariant()
|
||||
app.cores.style.compactMode(false)
|
||||
app.controls.toggleUIVisibility(true)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
enterPlayerAnimation()
|
||||
|
||||
return () => {
|
||||
exitPlayerAnimation()
|
||||
}
|
||||
}, [])
|
||||
}
|
11
packages/server/db_models/chatMessage/index.js
Normal file
11
packages/server/db_models/chatMessage/index.js
Normal file
@ -0,0 +1,11 @@
|
||||
export default {
|
||||
name: "ChatMessage",
|
||||
collection: "chats_messages",
|
||||
schema: {
|
||||
type: { type: String, required: true },
|
||||
from_user_id: { type: String, required: true },
|
||||
to_user_id: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
created_at: { type: Date, required: true },
|
||||
}
|
||||
}
|
13
packages/server/db_models/musicLyrics/index.js
Normal file
13
packages/server/db_models/musicLyrics/index.js
Normal file
@ -0,0 +1,13 @@
|
||||
export default {
|
||||
name: "TrackLyric",
|
||||
collection: "tracks_lyrics",
|
||||
schema: {
|
||||
track_id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
lrc: {
|
||||
type: String,
|
||||
}
|
||||
}
|
||||
}
|
9
packages/server/db_models/recentChat/index.js
Normal file
9
packages/server/db_models/recentChat/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
export default {
|
||||
name: "RecentChat",
|
||||
collection: "recent_chats",
|
||||
schema: {
|
||||
type: { type: String, required: true },
|
||||
user_id: { type: String, required: true },
|
||||
chat_id: { type: String, required: true },
|
||||
}
|
||||
}
|
@ -189,6 +189,7 @@ export default class Gateway {
|
||||
},
|
||||
onReload: async ({ id, service, cwd, }) => {
|
||||
console.log(`[onReload] ${id} ${service}`)
|
||||
|
||||
let instance = this.instancePool.find((instance) => instance.id === id)
|
||||
|
||||
if (!instance) {
|
||||
@ -209,7 +210,7 @@ export default class Gateway {
|
||||
// try to unregister from proxy
|
||||
this.proxy.unregisterAllFromService(id)
|
||||
|
||||
instance.instance.kill()
|
||||
await instance.instance.kill("SIGINT")
|
||||
|
||||
instance.instance = await spawnService({
|
||||
id,
|
||||
|
@ -13,7 +13,7 @@ export default async (socket, token, err) => {
|
||||
return err(`auth:token_invalid`)
|
||||
}
|
||||
|
||||
const userData = await User.findById(validation.data.user_id).catch((err) => {
|
||||
let userData = await User.findById(validation.data.user_id).catch((err) => {
|
||||
console.error(`[${socket.id}] failed to get user data caused by server error`, err)
|
||||
|
||||
return null
|
||||
@ -23,6 +23,9 @@ export default async (socket, token, err) => {
|
||||
return err(`auth:user_failed`)
|
||||
}
|
||||
|
||||
userData = userData.toObject()
|
||||
userData._id = userData._id.toString()
|
||||
|
||||
socket.userData = userData
|
||||
socket.token = token
|
||||
socket.session = validation.data
|
||||
|
@ -20,6 +20,7 @@
|
||||
"http-proxy": "^1.18.1",
|
||||
"linebridge": "^0.18.1",
|
||||
"module-alias": "^2.2.3",
|
||||
"nodejs-snowflake": "^2.0.1",
|
||||
"signal-exit": "^4.1.0",
|
||||
"spinnies": "^0.5.1",
|
||||
"tree-kill": "^1.2.2"
|
||||
|
@ -1,259 +0,0 @@
|
||||
import socketio from "socket.io"
|
||||
|
||||
import withWsAuth from "@middlewares/withWsAuth"
|
||||
|
||||
function generateFnHandler(fn, socket) {
|
||||
return async (...args) => {
|
||||
if (typeof socket === "undefined") {
|
||||
socket = arguments[0]
|
||||
}
|
||||
|
||||
try {
|
||||
fn(socket, ...args)
|
||||
} catch (error) {
|
||||
console.error(`[HANDLER_ERROR] ${error.message} >`, error.stack)
|
||||
|
||||
if (typeof socket.emit !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
return socket.emit("error", {
|
||||
message: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Room {
|
||||
constructor(io, roomName) {
|
||||
if (!io) {
|
||||
throw new Error("io is required")
|
||||
}
|
||||
|
||||
this.io = io
|
||||
this.roomName = roomName
|
||||
}
|
||||
|
||||
connections = []
|
||||
|
||||
limitations = {
|
||||
maxMessageLength: 540,
|
||||
}
|
||||
|
||||
events = {
|
||||
"room:send:message": (socket, payload) => {
|
||||
let { message } = payload
|
||||
|
||||
if (!message || typeof message !== "string") {
|
||||
return socket.emit("error", {
|
||||
message: "Invalid message",
|
||||
})
|
||||
}
|
||||
|
||||
if (message.length > this.limitations.maxMessageLength) {
|
||||
message = message.substring(0, this.limitations.maxMessageLength)
|
||||
}
|
||||
|
||||
this.io.to(this.roomName).emit("room:recive:message", {
|
||||
timestamp: payload.timestamp ?? Date.now(),
|
||||
content: String(message),
|
||||
user: {
|
||||
user_id: socket.userData._id,
|
||||
username: socket.userData.username,
|
||||
fullName: socket.userData.fullName,
|
||||
avatar: socket.userData.avatar,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
join = (socket) => {
|
||||
if (socket.connectedRoom) {
|
||||
console.warn(`[${socket.id}][@${socket.userData.username}] already connected to room ${socket.connectedRoom}`)
|
||||
|
||||
this.leave(socket)
|
||||
}
|
||||
|
||||
socket.connectedRoom = this.roomName
|
||||
|
||||
// join room
|
||||
socket.join(this.roomName)
|
||||
|
||||
// add to connections
|
||||
this.connections.push(socket)
|
||||
|
||||
// emit to self
|
||||
socket.emit("room:joined", {
|
||||
room: this.roomName,
|
||||
limitations: this.limitations,
|
||||
connectedUsers: this.connections.map((socket_conn) => {
|
||||
return socket_conn.userData._id
|
||||
}),
|
||||
})
|
||||
|
||||
// emit to others
|
||||
this.io.to(this.roomName).emit("room:user:joined", {
|
||||
user: {
|
||||
user_id: socket.userData._id,
|
||||
username: socket.userData.username,
|
||||
fullName: socket.userData.fullName,
|
||||
avatar: socket.userData.avatar,
|
||||
}
|
||||
})
|
||||
|
||||
for (const [event, fn] of Object.entries(this.events)) {
|
||||
const handler = generateFnHandler(fn, socket)
|
||||
|
||||
if (!Array.isArray(socket.handlers)) {
|
||||
socket.handlers = []
|
||||
}
|
||||
|
||||
socket.handlers.push([event, handler])
|
||||
|
||||
socket.on(event, handler)
|
||||
}
|
||||
|
||||
console.log(`[${socket.id}][@${socket.userData.username}] joined room ${this.roomName}`)
|
||||
}
|
||||
|
||||
leave = (socket) => {
|
||||
if (!socket.connectedRoom) {
|
||||
console.warn(`[${socket.id}][@${socket.userData.username}] not connected to any room`)
|
||||
return
|
||||
}
|
||||
|
||||
if (socket.connectedRoom !== this.roomName) {
|
||||
console.warn(`[${socket.id}][@${socket.userData.username}] not connected to room ${this.roomName}, cannot leave`)
|
||||
return false
|
||||
}
|
||||
|
||||
socket.leave(this.roomName)
|
||||
|
||||
this.connections.splice(this.connections.indexOf(socket), 1)
|
||||
|
||||
socket.emit("room:left", {
|
||||
room: this.roomName,
|
||||
})
|
||||
|
||||
this.io.to(this.roomName).emit("room:user:left", {
|
||||
user: {
|
||||
user_id: socket.userData._id,
|
||||
username: socket.userData.username,
|
||||
fullName: socket.userData.fullName,
|
||||
avatar: socket.userData.avatar,
|
||||
}
|
||||
})
|
||||
|
||||
for (const [event, handler] of socket.handlers) {
|
||||
socket.off(event, handler)
|
||||
}
|
||||
|
||||
console.log(`[${socket.id}][@${socket.userData.username}] left room ${this.roomName}`)
|
||||
}
|
||||
}
|
||||
|
||||
class RoomsController {
|
||||
constructor(io) {
|
||||
if (!io) {
|
||||
throw new Error("io is required")
|
||||
}
|
||||
|
||||
this.io = io
|
||||
}
|
||||
|
||||
rooms = []
|
||||
|
||||
checkRoomExists = (roomName) => {
|
||||
return this.rooms.some((room) => room.roomName === roomName)
|
||||
}
|
||||
|
||||
createRoom = async (roomName) => {
|
||||
if (this.checkRoomExists(roomName)) {
|
||||
throw new Error(`Room ${roomName} already exists`)
|
||||
}
|
||||
|
||||
const room = new Room(this.io, roomName)
|
||||
|
||||
this.rooms.push(room)
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
connectSocketToRoom = async (socket, roomName) => {
|
||||
if (!this.checkRoomExists(roomName)) {
|
||||
await this.createRoom(roomName)
|
||||
}
|
||||
|
||||
const room = this.rooms.find((room) => room.roomName === roomName)
|
||||
|
||||
return room.join(socket)
|
||||
}
|
||||
|
||||
disconnectSocketFromRoom = async (socket, roomName) => {
|
||||
if (!this.checkRoomExists(roomName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const room = this.rooms.find((room) => room.roomName === roomName)
|
||||
|
||||
return room.leave(socket)
|
||||
}
|
||||
}
|
||||
|
||||
export default class ChatServer {
|
||||
constructor(server) {
|
||||
this.io = socketio(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (global.ioAdapter) {
|
||||
this.io.adapter(global.ioAdapter)
|
||||
}
|
||||
|
||||
this.RoomsController = new RoomsController(this.io)
|
||||
}
|
||||
|
||||
connectionPool = []
|
||||
|
||||
events = {
|
||||
"connection": (socket) => {
|
||||
console.log(`[${socket.id}][${socket.userData.username}] connected to hub.`)
|
||||
|
||||
this.connectionPool.push(socket)
|
||||
|
||||
socket.on("disconnect", () => this.events.disconnect)
|
||||
|
||||
// Rooms
|
||||
socket.on("join:room", (data) => this.RoomsController.connectSocketToRoom(socket, data.room))
|
||||
socket.on("leave:room", (data) => this.RoomsController.disconnectSocketFromRoom(socket, data?.room ?? socket.connectedRoom))
|
||||
},
|
||||
"disconnect": (socket) => {
|
||||
console.log(`[${socket.id}][@${socket.userData.username}] disconnected to hub.`)
|
||||
|
||||
if (socket.connectedRoom) {
|
||||
this.Rooms.leave(socket)
|
||||
}
|
||||
|
||||
// remove from connection pool
|
||||
this.connectionPool = this.connectionPool.filter((client) => client.id !== socket.id)
|
||||
},
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
this.io.use(withWsAuth)
|
||||
|
||||
Object.entries(this.events).forEach(([event, handler]) => {
|
||||
this.io.on(event, (socket) => {
|
||||
try {
|
||||
handler(socket)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -9,8 +9,8 @@ import SharedMiddlewares from "@shared-middlewares"
|
||||
class API extends Server {
|
||||
static refName = "chats"
|
||||
static useEngine = "hyper-express"
|
||||
static wsRoutesPath = `${__dirname}/ws_routes`
|
||||
static routesPath = `${__dirname}/routes`
|
||||
static wsRoutesPath = `${__dirname}/routes_ws`
|
||||
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3004
|
||||
|
||||
middlewares = {
|
||||
@ -44,7 +44,11 @@ class API extends Server {
|
||||
}
|
||||
|
||||
async onInitialize() {
|
||||
this.contexts.rooms = new RoomsController(this.engine.io)
|
||||
if (!this.engine.ws) {
|
||||
throw new Error(`Engine WS not found!`)
|
||||
}
|
||||
|
||||
this.contexts.rooms = new RoomsController(this.engine.ws.io)
|
||||
|
||||
await this.contexts.db.initialize()
|
||||
await this.contexts.redis.initialize()
|
||||
|
@ -1,4 +1,7 @@
|
||||
import buildFunctionHandler from "@utils/buildFunctionHandler"
|
||||
import { Snowflake } from "nodejs-snowflake"
|
||||
|
||||
import { ChatMessage } from "@db_models"
|
||||
|
||||
export default class Room {
|
||||
constructor(io, roomID, options) {
|
||||
@ -27,10 +30,10 @@ export default class Room {
|
||||
}
|
||||
|
||||
roomEvents = {
|
||||
"room:change:owner": (client, payload) => {
|
||||
"room:change:owner": async (client, payload) => {
|
||||
throw new OperationError(500, "Not implemented")
|
||||
},
|
||||
"room:send:message": (client, payload) => {
|
||||
"room:send:message": async (client, payload) => {
|
||||
console.log(`[${this.roomID}] [@${client.userData.username}] sent message >`, payload)
|
||||
|
||||
let { message } = payload
|
||||
@ -43,7 +46,12 @@ export default class Room {
|
||||
message = message.substring(0, this.limitations.maxMessageLength)
|
||||
}
|
||||
|
||||
const created_at = new Date().getTime()
|
||||
|
||||
const id = `msg:${client.userData._id}:${created_at}`
|
||||
|
||||
this.handlers.broadcastToMembers("room:message", {
|
||||
_id: id,
|
||||
timestamp: payload.timestamp ?? Date.now(),
|
||||
content: String(message),
|
||||
user: {
|
||||
@ -53,6 +61,33 @@ export default class Room {
|
||||
avatar: client.userData.avatar,
|
||||
},
|
||||
})
|
||||
|
||||
if (payload.route) {
|
||||
const routeValues = payload.route.split(":")
|
||||
|
||||
console.log(routeValues)
|
||||
|
||||
if (routeValues.length > 0) {
|
||||
const [type, to_id] = routeValues
|
||||
|
||||
switch (type) {
|
||||
case "user": {
|
||||
const doc = await ChatMessage.create({
|
||||
type: type,
|
||||
from_user_id: client.userData._id,
|
||||
to_user_id: to_id,
|
||||
content: message,
|
||||
created_at: created_at,
|
||||
})
|
||||
|
||||
console.log(doc)
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,74 @@
|
||||
import { User, ChatMessage } from "@db_models"
|
||||
|
||||
export default {
|
||||
middlewares: ["withAuthentication"],
|
||||
fn: async (req) => {
|
||||
const { limit = 50, offset = 0, order = "asc" } = req.query
|
||||
|
||||
const id = req.params.chat_id
|
||||
|
||||
const [from_user_id, to_user_id] = [req.auth.session.user_id, id]
|
||||
|
||||
const query = {
|
||||
from_user_id: {
|
||||
$in: [
|
||||
from_user_id,
|
||||
to_user_id
|
||||
]
|
||||
},
|
||||
to_user_id: {
|
||||
$in: [
|
||||
from_user_id,
|
||||
to_user_id
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
let user_datas = await User.find({
|
||||
_id: [
|
||||
from_user_id,
|
||||
to_user_id
|
||||
]
|
||||
})
|
||||
|
||||
user_datas = user_datas.map((user) => {
|
||||
user = user.toObject()
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
_id: 0,
|
||||
username: "Deleted User",
|
||||
}
|
||||
}
|
||||
|
||||
user._id = user._id.toString()
|
||||
|
||||
return user
|
||||
})
|
||||
|
||||
let history = await ChatMessage.find(query)
|
||||
.sort({ created_at: order === "desc" ? -1 : 1 })
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
|
||||
history = history.map(async (item) => {
|
||||
item = item.toObject()
|
||||
|
||||
item.user = user_datas.find((user) => {
|
||||
return user._id === item.from_user_id
|
||||
})
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
history = await Promise.all(history)
|
||||
|
||||
return {
|
||||
total: await ChatMessage.count(query),
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
order: order,
|
||||
list: history
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import { ChatMessage } from "@db_models"
|
||||
|
||||
export default async (socket, payload, engine) => {
|
||||
const created_at = new Date().getTime()
|
||||
|
||||
const [from_user_id, to_user_id] = [socket.userData._id, payload.to_user_id]
|
||||
|
||||
const targetSocket = await engine.find.socketByUserId(payload.to_user_id)
|
||||
|
||||
const wsMessageObj = {
|
||||
...payload,
|
||||
created_at: created_at,
|
||||
user: socket.userData,
|
||||
_id: `msg:${from_user_id}:${created_at}`,
|
||||
}
|
||||
|
||||
const doc = await ChatMessage.create({
|
||||
type: "user",
|
||||
from_user_id: from_user_id,
|
||||
to_user_id: to_user_id,
|
||||
content: payload.content,
|
||||
created_at: created_at,
|
||||
})
|
||||
|
||||
socket.emit("chat:receive:message", wsMessageObj)
|
||||
|
||||
if (targetSocket.emit) {
|
||||
await targetSocket.emit("chat:receive:message", wsMessageObj)
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
export default async (socket, payload, engine) => {
|
||||
const from_user_id = socket.userData._id
|
||||
const { to_user_id, is_typing } = payload
|
||||
|
||||
const targetSocket = await engine.find.socketByUserId(to_user_id)
|
||||
|
||||
if (targetSocket) {
|
||||
await targetSocket.emit("chat:state:typing", {
|
||||
is_typing: is_typing
|
||||
})
|
||||
|
||||
// socket.pendingFunctions.push("chats:state:typing")
|
||||
|
||||
// setTimeout(() => {
|
||||
// socket.emit("chats:state:typing", {
|
||||
// is_typing: false,
|
||||
// })
|
||||
// }, 5000)
|
||||
}
|
||||
}
|
@ -11,16 +11,32 @@ export default {
|
||||
"withAuthentication",
|
||||
],
|
||||
fn: async (req, res) => {
|
||||
const providerType = req.headers["provider-type"]
|
||||
|
||||
const userPath = path.join(this.default.contexts.cache.constructor.cachePath, req.auth.session.user_id)
|
||||
|
||||
const tmpPath = path.resolve(userPath)
|
||||
|
||||
let build = await ChunkFileUpload(req, {
|
||||
tmpDir: tmpPath,
|
||||
const limits = {
|
||||
maxFileSize: parseInt(this.default.contexts.limits.maxFileSizeInMB) * 1024 * 1024,
|
||||
maxChunkSize: parseInt(this.default.contexts.limits.maxChunkSizeInMB) * 1024 * 1024,
|
||||
useCompression: true,
|
||||
useProvider: "standard",
|
||||
}
|
||||
|
||||
const user = await req.auth.user()
|
||||
|
||||
if (user.roles.includes("admin")) {
|
||||
// maxFileSize for admins 100GB
|
||||
limits.maxFileSize = 100 * 1024 * 1024 * 1024
|
||||
|
||||
// optional compression for admins
|
||||
limits.useCompression = req.headers["use-compression"] ?? false
|
||||
|
||||
limits.useProvider = req.headers["provider-type"] ?? "b2"
|
||||
}
|
||||
|
||||
let build = await ChunkFileUpload(req, {
|
||||
tmpDir: tmpPath,
|
||||
...limits,
|
||||
}).catch((err) => {
|
||||
throw new OperationError(err.code, err.message)
|
||||
})
|
||||
@ -32,8 +48,8 @@ export default {
|
||||
const result = await RemoteUpload({
|
||||
parentDir: req.auth.session.user_id,
|
||||
source: build.filePath,
|
||||
service: providerType,
|
||||
useCompression: req.headers["use-compression"] ?? true,
|
||||
service: limits.useProvider,
|
||||
useCompression: limits.useCompression,
|
||||
cachePath: tmpPath,
|
||||
})
|
||||
|
||||
|
@ -7,6 +7,7 @@ const AllowedUpdateFields = [
|
||||
"artist",
|
||||
"type",
|
||||
"public",
|
||||
"list",
|
||||
]
|
||||
|
||||
export default class Release {
|
||||
@ -19,7 +20,7 @@ export default class Release {
|
||||
explicit: payload.explicit,
|
||||
type: payload.type,
|
||||
public: payload.public,
|
||||
items: payload.items,
|
||||
list: payload.list,
|
||||
public: payload.public,
|
||||
})
|
||||
|
||||
|
@ -6,12 +6,22 @@ import axios from "axios"
|
||||
export default async (payload = {}) => {
|
||||
requiredFields(["title", "source", "user_id"], payload)
|
||||
|
||||
const { data: stream, headers } = await axios({
|
||||
let stream = null
|
||||
let headers = null
|
||||
|
||||
try {
|
||||
const sourceStream = await axios({
|
||||
url: payload.source,
|
||||
method: "GET",
|
||||
responseType: "stream",
|
||||
})
|
||||
|
||||
stream = sourceStream.data
|
||||
headers = sourceStream.headers
|
||||
} catch (error) {
|
||||
throw new OperationError(500, `Failed to process fetching source: ${error.message}`)
|
||||
}
|
||||
|
||||
const fileMetadata = await MusicMetadata.parseStream(stream, {
|
||||
mimeType: headers["content-type"],
|
||||
})
|
||||
|
@ -20,7 +20,9 @@ export default async (track_id, { limit = 50, offset = 0 } = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const track = await Track.findById(track_id).catch(() => null)
|
||||
const track = await Track.findOne({
|
||||
_id: track_id
|
||||
})
|
||||
|
||||
if (!track) {
|
||||
throw new OperationError(404, "Track not found")
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user