fix depecrated deps

This commit is contained in:
SrGooglo 2025-02-11 16:13:13 +00:00
parent 79f64eaec2
commit 3cf055c72c
24 changed files with 2708 additions and 2638 deletions

View File

@ -1,17 +1,18 @@
import path from "path" import path from "path"
export default { export default {
"@": path.join(__dirname, "src"), "@": path.join(__dirname, "src"),
"@config": path.join(__dirname, "config"), "@config": path.join(__dirname, "config"),
"@cores": path.join(__dirname, "src/cores"), "@cores": path.join(__dirname, "src/cores"),
"@pages": path.join(__dirname, "src/pages"), "@pages": path.join(__dirname, "src/pages"),
"@styles": path.join(__dirname, "src/styles"), "@styles": path.join(__dirname, "src/styles"),
"@components": path.join(__dirname, "src/components"), "@components": path.join(__dirname, "src/components"),
"@contexts": path.join(__dirname, "src/contexts"), "@contexts": path.join(__dirname, "src/contexts"),
"@utils": path.join(__dirname, "src/utils"), "@utils": path.join(__dirname, "src/utils"),
"@layouts": path.join(__dirname, "src/layouts"), "@layouts": path.join(__dirname, "src/layouts"),
"@hooks": path.join(__dirname, "src/hooks"), "@hooks": path.join(__dirname, "src/hooks"),
"@classes": path.join(__dirname, "src/classes"), "@classes": path.join(__dirname, "src/classes"),
"@models": path.join(__dirname, "../../", "comty.js/src/models"), "@models": path.join(__dirname, "../../", "comty.js/src/models"),
"comty.js": path.join(__dirname, "../../", "comty.js", "src"), "comty.js": path.join(__dirname, "../../", "comty.js", "src"),
"@ragestudio/vessel": path.join(__dirname, "../../", "vessel", "src"),
} }

View File

@ -3,6 +3,7 @@
"version": "1.25.0-a", "version": "1.25.0-a",
"license": "ComtyLicense", "license": "ComtyLicense",
"main": "electron/main", "main": "electron/main",
"type": "module",
"author": "RageStudio", "author": "RageStudio",
"description": "A prototype of a social network.", "description": "A prototype of a social network.",
"scripts": { "scripts": {
@ -13,78 +14,61 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.4.0", "@ant-design/icons": "^5.4.0",
"@capacitor/android": "^5.0.5",
"@capacitor/app": "^5.0.3",
"@capacitor/assets": "^2.0.4",
"@capacitor/cli": "^5.0.5",
"@capacitor/core": "^5.0.5",
"@capacitor/haptics": "1.1.4",
"@capacitor/splash-screen": "^5.0.4",
"@capacitor/status-bar": "^5.0.4",
"@capacitor/storage": "^1.2.5",
"@capgo/capacitor-updater": "^5.0.1",
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@emotion/react": "^11.13.0", "@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1", "@ffmpeg/util": "^0.12.1",
"@loadable/component": "5.15.2",
"@mui/material": "^5.11.9", "@mui/material": "^5.11.9",
"@ragestudio/cordova-nfc": "^1.2.0", "@ragestudio/cordova-nfc": "^1.2.0",
"@ragestudio/vessel": "^0.18.1", "@ragestudio/vessel": "^0.19.0",
"@sentry/browser": "^7.64.0", "@sentry/browser": "^7.64.0",
"@tauri-apps/api": "^1.5.4", "@tauri-apps/api": "^1.5.4",
"@tsmx/human-readable": "^1.0.7", "@tsmx/human-readable": "^1.0.7",
"antd": "^5.20.6", "antd": "^5.20.6",
"axios": "^1.7.7", "axios": "^1.7.7",
"bear-react-carousel": "^4.0.10-alpha.0", "bear-react-carousel": "^4.0.10-alpha.0",
"capacitor-music-controls-plugin-v3": "^1.1.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"dashjs": "^4.7.4", "dashjs": "^4.7.4",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"fast-average-color": "^9.2.0", "fast-average-color": "^9.2.0",
"framer-motion": "^10.12.17",
"fuse.js": "6.5.3", "fuse.js": "6.5.3",
"hls.js": "^1.5.17", "hls.js": "^1.5.17",
"howler": "2.2.3", "howler": "2.2.3",
"i18next": "21.6.6", "i18next": "21.6.6",
"js-cookie": "3.0.1", "js-cookie": "3.0.1",
"jsmediatags": "^3.9.7", "jsmediatags": "^3.9.7",
"less": "4.1.2",
"lottie-react": "^2.4.0", "lottie-react": "^2.4.0",
"luxon": "^3.0.4", "luxon": "^3.0.4",
"million": "^2.6.4",
"mime": "^3.0.0", "mime": "^3.0.0",
"moment": "2.29.4", "moment": "2.29.4",
"motion": "^12.4.2",
"mpegts.js": "^1.6.10", "mpegts.js": "^1.6.10",
"nprogress": "^0.2.0", "plyr": "^3.7.8",
"plyr": "^3.6.12",
"plyr-react": "^3.2.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-color": "2.19.3", "react-color": "2.19.3",
"react-countup": "^6.4.1", "react-countup": "^6.4.1",
"react-dom": "18.2.0", "react-dom": "18.3.1",
"react-fast-marquee": "^1.3.5", "react-fast-marquee": "^1.3.5",
"react-helmet": "6.1.0",
"react-i18next": "11.15.3", "react-i18next": "11.15.3",
"react-icons": "^4.8.0", "react-icons": "^5.4.0",
"react-lazy-load-image-component": "^1.5.4", "react-lazy-load-image-component": "^1.5.4",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-modal-image": "^2.6.0", "react-modal-image": "^2.6.0",
"react-motion": "0.5.2", "react-player": "^2.16.0",
"react-rnd": "10.3.5", "react-rnd": "^10.4.14",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-useanimations": "^2.10.0", "react-useanimations": "^2.10.0",
"realtime-bpm-analyzer": "^3.2.1",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"rxjs": "^7.5.5", "rxjs": "^7.5.5",
"store": "^2.0.12", "store": "^2.0.12",
"swapy": "^1.0.5",
"ua-parser-js": "^1.0.36", "ua-parser-js": "^1.0.36",
"vaul": "^0.9.2", "vaul": "^1.1.2",
"vite": "^5.4.4" "vite": "^5.4.4"
} }
} }

View File

@ -2,6 +2,7 @@ import "./patches"
import config from "@config" import config from "@config"
import React from "react" import React from "react"
import { Runtime } from "@ragestudio/vessel" import { Runtime } from "@ragestudio/vessel"
import { Helmet } from "react-helmet" import { Helmet } from "react-helmet"
import { Translation } from "react-i18next" import { Translation } from "react-i18next"
@ -10,10 +11,6 @@ import { invoke } from "@tauri-apps/api/tauri"
import { Lightbox } from "react-modal-image" import { Lightbox } from "react-modal-image"
import * as antd from "antd" import * as antd from "antd"
import { StatusBar, Style } from "@capacitor/status-bar"
import { App as CapacitorApp } from "@capacitor/app"
import { CapacitorUpdater } from "@capgo/capacitor-updater"
import AppsMenu from "@components/AppMenu" import AppsMenu from "@components/AppMenu"
import AuthModel from "@models/auth" import AuthModel from "@models/auth"
@ -41,9 +38,9 @@ import Splash from "./splash"
import "@styles/index.less" import "@styles/index.less"
if (IS_MOBILE_HOST) { // if (IS_MOBILE_HOST) {
CapacitorUpdater.notifyAppReady() // CapacitorUpdater.notifyAppReady()
} // }
class ComtyApp extends React.Component { class ComtyApp extends React.Component {
constructor(props) { constructor(props) {
@ -66,8 +63,12 @@ class ComtyApp extends React.Component {
window.app.message = antd.message window.app.message = antd.message
window.app.isCapacitor = IS_MOBILE_HOST window.app.isCapacitor = IS_MOBILE_HOST
if (window.app.version !== window.localStorage.getItem("last_version")) { if (
app.message.info(`Comty has been updated to version ${window.app.version}!`) window.app.version !== window.localStorage.getItem("last_version")
) {
app.message.info(
`Comty has been updated to version ${window.app.version}!`,
)
window.localStorage.setItem("last_version", window.app.version) window.localStorage.setItem("last_version", window.app.version)
} }
@ -113,7 +114,7 @@ class ComtyApp extends React.Component {
sessionController: this.sessionController, sessionController: this.sessionController,
onDone: () => { onDone: () => {
app.layout.draggable.destroy("login") app.layout.draggable.destroy("login")
} },
}, },
}) })
}, },
@ -129,19 +130,23 @@ class ComtyApp extends React.Component {
props: { props: {
bodyStyle: { bodyStyle: {
height: "100%", height: "100%",
} },
}, },
}) })
}, },
// Opens the notification window and sets up the UI for the notification to be displayed // Opens the notification window and sets up the UI for the notification to be displayed
openNotifications: () => { openNotifications: () => {
window.app.layout.drawer.open("notifications", NotificationsCenter, { window.app.layout.drawer.open(
props: { "notifications",
width: "fit-content", NotificationsCenter,
{
props: {
width: "fit-content",
},
allowMultiples: false,
escClosable: true,
}, },
allowMultiples: false, )
escClosable: true,
})
}, },
openSearcher: (options) => { openSearcher: (options) => {
if (app.isMobile) { if (app.isMobile) {
@ -150,34 +155,44 @@ class ComtyApp extends React.Component {
componentProps: { componentProps: {
renderResults: true, renderResults: true,
autoFocus: true, autoFocus: true,
} },
}) })
} }
return app.layout.modal.open("searcher", (props) => <Searcher autoFocus renderResults {...props} />, { return app.layout.modal.open(
framed: false "searcher",
}) (props) => <Searcher autoFocus renderResults {...props} />,
{
framed: false,
},
)
}, },
openMessages: () => { openMessages: () => {
app.location.push("/messages") app.location.push("/messages")
}, },
openFullImageViewer: (src) => { openFullImageViewer: (src) => {
app.cores.window_mng.render("image_lightbox", <Lightbox app.cores.window_mng.render(
small={src} "image_lightbox",
large={src} <Lightbox
onClose={() => app.cores.window_mng.close("image_lightbox")} small={src}
hideDownload large={src}
showRotate onClose={() =>
/>) app.cores.window_mng.close("image_lightbox")
}
hideDownload
showRotate
/>,
)
}, },
openPostCreator: (params) => { openPostCreator: (params) => {
app.layout.modal.open("post_creator", (props) => <PostCreator app.layout.modal.open(
{...props} "post_creator",
{...params} (props) => <PostCreator {...props} {...params} />,
/>, { {
framed: false framed: false,
}) },
} )
},
}, },
navigation: { navigation: {
reload: () => { reload: () => {
@ -198,14 +213,16 @@ class ComtyApp extends React.Component {
goToSettings: (setting_id) => { goToSettings: (setting_id) => {
return app.location.push(`/settings`, { return app.location.push(`/settings`, {
query: { query: {
setting: setting_id setting: setting_id,
} },
}) })
}, },
goToAccount: (username) => { goToAccount: (username) => {
if (!username) { if (!username) {
if (!app.userData) { if (!app.userData) {
console.error("Cannot go to account, no username provided and no user logged in") console.error(
"Cannot go to account, no username provided and no user logged in",
)
return false return false
} }
@ -219,27 +236,33 @@ class ComtyApp extends React.Component {
}, },
goToPlaylist: (playlist_id) => { goToPlaylist: (playlist_id) => {
return app.location.push(`/play/${playlist_id}`) return app.location.push(`/play/${playlist_id}`)
} },
}, },
capacitor: { capacitor: {
isAppCapacitor: () => window.navigator.userAgent === "capacitor", isAppCapacitor: () => window.navigator.userAgent === "capacitor",
setStatusBarStyleDark: async () => { setStatusBarStyleDark: async () => {
if (!window.app.capacitor.isAppCapacitor()) { if (!window.app.capacitor.isAppCapacitor()) {
console.warn("[App] setStatusBarStyleDark is only available on capacitor") console.warn(
"[App] setStatusBarStyleDark is only available on capacitor",
)
return false return false
} }
return await StatusBar.setStyle({ style: Style.Dark }) return await StatusBar.setStyle({ style: Style.Dark })
}, },
setStatusBarStyleLight: async () => { setStatusBarStyleLight: async () => {
if (!window.app.capacitor.isAppCapacitor()) { if (!window.app.capacitor.isAppCapacitor()) {
console.warn("[App] setStatusBarStyleLight is not supported on this platform") console.warn(
"[App] setStatusBarStyleLight is not supported on this platform",
)
return false return false
} }
return await StatusBar.setStyle({ style: Style.Light }) return await StatusBar.setStyle({ style: Style.Light })
}, },
hideStatusBar: async () => { hideStatusBar: async () => {
if (!window.app.capacitor.isAppCapacitor()) { if (!window.app.capacitor.isAppCapacitor()) {
console.warn("[App] hideStatusBar is not supported on this platform") console.warn(
"[App] hideStatusBar is not supported on this platform",
)
return false return false
} }
@ -247,7 +270,9 @@ class ComtyApp extends React.Component {
}, },
showStatusBar: async () => { showStatusBar: async () => {
if (!window.app.capacitor.isAppCapacitor()) { if (!window.app.capacitor.isAppCapacitor()) {
console.warn("[App] showStatusBar is not supported on this platform") console.warn(
"[App] showStatusBar is not supported on this platform",
)
return false return false
} }
return await StatusBar.show() return await StatusBar.show()
@ -257,13 +282,14 @@ class ComtyApp extends React.Component {
clearInternalStorage: async () => { clearInternalStorage: async () => {
antd.Modal.confirm({ antd.Modal.confirm({
title: "Clear internal storage", title: "Clear internal storage",
content: "Are you sure you want to clear all internal storage? This will remove all your data from the app, including your session.", content:
"Are you sure you want to clear all internal storage? This will remove all your data from the app, including your session.",
onOk: async () => { onOk: async () => {
Utils.deleteInternalStorage() Utils.deleteInternalStorage()
} },
}) })
}, },
} },
} }
static staticRenders = { static staticRenders = {
@ -309,12 +335,10 @@ class ComtyApp extends React.Component {
app.navigation.goAuth() app.navigation.goAuth()
antd.notification.open({ antd.notification.open({
message: <Translation> message: (
{(t) => t("Invalid Session")} <Translation>{(t) => t("Invalid Session")}</Translation>
</Translation>, ),
description: <Translation> description: <Translation>{(t) => t(error)}</Translation>,
{(t) => t(error)}
</Translation>,
icon: <Icons.MdOutlineAccessTimeFilled />, icon: <Icons.MdOutlineAccessTimeFilled />,
}) })
}, },
@ -339,7 +363,7 @@ class ComtyApp extends React.Component {
"auth:disabled_account": async () => { "auth:disabled_account": async () => {
await SessionModel.removeToken() await SessionModel.removeToken()
app.navigation.goAuth() app.navigation.goAuth()
} },
} }
flushState = async () => { flushState = async () => {
@ -355,13 +379,13 @@ class ComtyApp extends React.Component {
StatusBar.setOverlaysWebView({ overlay: false }) StatusBar.setOverlaysWebView({ overlay: false })
CapacitorApp.addListener("backButton", ({ canGoBack }) => { // CapacitorApp.addListener("backButton", ({ canGoBack }) => {
if (!canGoBack) { // if (!canGoBack) {
CapacitorApp.exitApp() // CapacitorApp.exitApp()
} else { // } else {
app.location.back() // app.location.back()
} // }
}) // })
} }
await this.initialization() await this.initialization()
@ -389,7 +413,10 @@ class ComtyApp extends React.Component {
try { try {
await this.__SessionInit() await this.__SessionInit()
} catch (error) { } catch (error) {
console.error(`[App] Error while initializing session`, error) console.error(
`[App] Error while initializing session`,
error,
)
throw { throw {
cause: "Cannot initialize session", cause: "Cannot initialize session",
@ -436,31 +463,32 @@ class ComtyApp extends React.Component {
} }
render() { render() {
return <React.Fragment> return (
<Helmet> <React.Fragment>
<title>{config.app.siteName}</title> <Helmet>
<meta name="og:description" content={config.app.siteDescription} /> <title>{config.app.siteName}</title>
<meta property="og:title" content={config.app.siteName} /> <meta
</Helmet> name="og:description"
<Router.InternalRouter> content={config.app.siteDescription}
<ThemeProvider> />
{ <meta property="og:title" content={config.app.siteName} />
window.__TAURI__ && <DesktopTopBar /> </Helmet>
} <Router.InternalRouter>
<Layout <ThemeProvider>
user={this.state.user} {window.__TAURI__ && <DesktopTopBar />}
staticRenders={ComtyApp.staticRenders} <Layout
bindProps={{ user={this.state.user}
user: this.state.user, staticRenders={ComtyApp.staticRenders}
}} bindProps={{
> user: this.state.user,
{ }}
this.state.initialized && <Router.PageRender /> >
} {this.state.initialized && <Router.PageRender />}
</Layout> </Layout>
</ThemeProvider> </ThemeProvider>
</Router.InternalRouter> </Router.InternalRouter>
</React.Fragment> </React.Fragment>
)
} }
} }

View File

@ -1,19 +1,21 @@
import React from "react" import React from "react"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "motion/react"
const PageTransition = (props) => { const PageTransition = (props) => {
return <AnimatePresence> return (
<motion.div <AnimatePresence>
layout <motion.div
initial={{ y: 10, opacity: 0 }} layout
animate={{ y: 0, opacity: 1 }} initial={{ y: 10, opacity: 0 }}
exit={{ y: -10, opacity: 0 }} animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.2 }} exit={{ y: -10, opacity: 0 }}
{...props} transition={{ duration: 0.2 }}
> {...props}
{props.children} >
</motion.div> {props.children}
</AnimatePresence> </motion.div>
</AnimatePresence>
)
} }
export default PageTransition export default PageTransition

View File

@ -2,7 +2,7 @@ import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import { Icons, createIconRender } from "@components/Icons" import { Icons, createIconRender } from "@components/Icons"
import { motion } from "framer-motion" import { motion } from "motion/react"
import useWsEvents from "@hooks/useWsEvents" import useWsEvents from "@hooks/useWsEvents"
@ -11,214 +11,220 @@ import PostModel from "@models/post"
import "./index.less" import "./index.less"
const PollOption = (props) => { const PollOption = (props) => {
async function onClick() { async function onClick() {
if (typeof props.onClick === "function") { if (typeof props.onClick === "function") {
await props.onClick(props.option.id) await props.onClick(props.option.id)
} }
} }
return <div return (
className={classnames( <div
"poll-option", className={classnames("poll-option", {
{ ["checked"]: props.checked,
["checked"]: props.checked, })}
style={{
"--percentage": `${props.percentage}%`,
}}
onClick={onClick}
>
{props.checked && (
<motion.div
className="percentage-indicator"
animate={{ width: `${props.percentage}%` }}
initial={{ width: 0 }}
transition={{ ease: "easeOut" }}
/>
)}
} <div className="poll-option-content">
)} {props.checked && createIconRender("FaCheck")}
style={{
"--percentage": `${props.percentage}%`
}}
onClick={onClick}
>
{
props.checked && <motion.div
className="percentage-indicator"
animate={{ width: `${props.percentage}%` }}
initial={{ width: 0 }}
transition={{ ease: "easeOut" }}
/>
}
<div className="poll-option-content"> {props.showPercentage && (
{ <span>{Math.floor(props.percentage)}%</span>
props.checked && createIconRender("FaCheck") )}
}
{ <span>{props.option.label}</span>
props.showPercentage && <span> </div>
{Math.floor(props.percentage)}% </div>
</span> )
}
<span>
{props.option.label}
</span>
</div>
</div>
} }
const Poll = (props) => { const Poll = (props) => {
const { editMode, onClose, formRef } = props const { editMode, onClose, formRef } = props
const [options, setOptions] = React.useState(props.options ?? []) const [options, setOptions] = React.useState(props.options ?? [])
const [hasVoted, setHasVoted] = React.useState(false) const [hasVoted, setHasVoted] = React.useState(false)
const [totalVotes, setTotalVotes] = React.useState(0) const [totalVotes, setTotalVotes] = React.useState(0)
useWsEvents({ useWsEvents(
"post.poll.vote": (data) => { {
const { post_id, option_id, user_id, previous_option_id } = data "post.poll.vote": (data) => {
const { post_id, option_id, user_id, previous_option_id } = data
if (post_id !== props.post_id) { if (post_id !== props.post_id) {
return false return false
} }
console.debug(`U[${user_id}] vote to option [${option_id}]`) console.debug(`U[${user_id}] vote to option [${option_id}]`)
setOptions((prev) => { setOptions((prev) => {
prev = prev.map((option) => { prev = prev.map((option) => {
return option return option
}) })
if (user_id === app.userData._id) { if (user_id === app.userData._id) {
// remove all `voted` properties // remove all `voted` properties
prev = prev.map((option) => { prev = prev.map((option) => {
delete option.voted delete option.voted
option.voted = option.id === option_id option.voted = option.id === option_id
return option return option
}) })
} }
if (previous_option_id) { if (previous_option_id) {
const previousOptionIndex = prev.findIndex((option) => option.id === previous_option_id) const previousOptionIndex = prev.findIndex(
(option) => option.id === previous_option_id,
)
if (previousOptionIndex !== -1) { if (previousOptionIndex !== -1) {
prev[previousOptionIndex].count = prev[previousOptionIndex].count - 1 prev[previousOptionIndex].count =
} prev[previousOptionIndex].count - 1
} }
}
if (option_id) { if (option_id) {
const newOptionIndex = prev.findIndex((option) => option.id === option_id) const newOptionIndex = prev.findIndex(
(option) => option.id === option_id,
)
if (newOptionIndex !== -1) { if (newOptionIndex !== -1) {
prev[newOptionIndex].count += 1 prev[newOptionIndex].count += 1
} }
} }
return prev return prev
}) })
} },
}, { },
socketName: "posts" {
}) socketName: "posts",
},
)
async function onVote(id) { async function onVote(id) {
console.debug(`Voting poll option`, { console.debug(`Voting poll option`, {
option_id: id, option_id: id,
post_id: props.post_id, post_id: props.post_id,
}) })
await PostModel.votePoll({ await PostModel.votePoll({
post_id: props.post_id, post_id: props.post_id,
option_id: id, option_id: id,
}) })
} }
React.useEffect(() => { React.useEffect(() => {
if (options) { if (options) {
const totalVotes = options.reduce((sum, option) => { const totalVotes = options.reduce((sum, option) => {
return sum + option.count return sum + option.count
}, 0) }, 0)
setTotalVotes(totalVotes) setTotalVotes(totalVotes)
const hasVoted = options.some((option) => { const hasVoted = options.some((option) => {
return option.voted return option.voted
}) })
setHasVoted(hasVoted) setHasVoted(hasVoted)
} }
}, [options]) }, [options])
return <div className="poll"> return (
{ <div className="poll">
!editMode && options.map((option, index) => { {!editMode &&
const percentage = totalVotes > 0 ? (option.count / totalVotes) * 100 : 0 options.map((option, index) => {
const percentage =
totalVotes > 0 ? (option.count / totalVotes) * 100 : 0
return <PollOption return (
key={index} <PollOption
option={option} key={index}
onClick={onVote} option={option}
checked={option.voted} onClick={onVote}
percentage={percentage} checked={option.voted}
showPercentage={hasVoted} percentage={percentage}
/> showPercentage={hasVoted}
}) />
} )
})}
{ {editMode && (
editMode && <antd.Form <antd.Form
name="post-poll" name="post-poll"
className="post-poll-edit" className="post-poll-edit"
ref={formRef} ref={formRef}
initialValues={{ initialValues={{
options: options options: options,
}} }}
> >
<antd.Form.List <antd.Form.List name="options">
name="options" {(fields, { add, remove }) => {
> return (
{(fields, { add, remove }) => { <>
return <> {fields.map((field, index) => {
{ return (
fields.map((field, index) => { <div
return <div key={field.key}
key={field.key} className="post-poll-edit-option"
className="post-poll-edit-option" >
> <antd.Form.Item
<antd.Form.Item {...field}
{...field} name={[field.name, "label"]}
name={[field.name, "label"]} >
> <antd.Input placeholder="Type a option" />
<antd.Input </antd.Form.Item>
placeholder="Type a option"
/>
</antd.Form.Item>
{ {fields.length > 1 && (
fields.length > 1 && <antd.Button <antd.Button
onClick={() => remove(field.name)} onClick={() =>
icon={createIconRender("MdRemove")} remove(field.name)
/> }
} icon={createIconRender(
</div> "MdRemove",
}) )}
} />
)}
</div>
)
})}
<antd.Button <antd.Button
onClick={() => add()} onClick={() => add()}
icon={createIconRender("PlusOutlined")} icon={createIconRender("PlusOutlined")}
> >
Add Option Add Option
</antd.Button> </antd.Button>
</> </>
}} )
</antd.Form.List> }}
</antd.Form> </antd.Form.List>
} </antd.Form>
)}
{ {editMode && (
editMode && <div className="poll-edit-actions"> <div className="poll-edit-actions">
<antd.Button <antd.Button
onClick={onClose} onClick={onClose}
icon={createIconRender("CloseOutlined")} icon={createIconRender("CloseOutlined")}
size="small" size="small"
type="text" type="text"
/> />
</div> </div>
} )}
</div> </div>
)
} }
export default Poll export default Poll

View File

@ -1,6 +1,5 @@
import React from "react" import React from "react"
import { Skeleton, Button } from "antd" import { Skeleton, Button } from "antd"
import Plyr from "plyr-react"
import mimetypes from "mime" import mimetypes from "mime"
import BearCarousel from "bear-react-carousel" import BearCarousel from "bear-react-carousel"
@ -9,167 +8,176 @@ import ImageViewer from "@components/ImageViewer"
import ContentFailed from "../contentFailed" import ContentFailed from "../contentFailed"
import "bear-react-carousel/dist/index.css" import "bear-react-carousel/dist/index.css"
import "plyr-react/dist/plyr.css"
import "./index.less" import "./index.less"
const renderDebug = localStorage.getItem("render_debug") === "true" const renderDebug = localStorage.getItem("render_debug") === "true"
const Attachment = React.memo((props) => { const Attachment = React.memo((props) => {
const [loaded, setLoaded] = React.useState(false) const [loaded, setLoaded] = React.useState(false)
const [mimeType, setMimeType] = React.useState(null) const [mimeType, setMimeType] = React.useState(null)
try { try {
const { url, id } = props.attachment const { url, id } = props.attachment
const onDoubleClickAttachment = (e) => { const onDoubleClickAttachment = (e) => {
if (mimeType.split("/")[0] === "image") { if (mimeType.split("/")[0] === "image") {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
app.controls.openFullImageViewer(url) app.controls.openFullImageViewer(url)
} }
} }
const getMediaType = async () => { const getMediaType = async () => {
let extension = null let extension = null
// get media type by parsing the url // get media type by parsing the url
const mediaExtname = /\.([a-zA-Z0-9]+)$/.exec(url) const mediaExtname = /\.([a-zA-Z0-9]+)$/.exec(url)
if (mediaExtname) { if (mediaExtname) {
extension = mediaExtname[1] extension = mediaExtname[1]
} else { } else {
// try to get media by creating requesting the url // try to get media by creating requesting the url
const response = await fetch(url, { const response = await fetch(url, {
method: "HEAD", method: "HEAD",
}) })
extension = response.headers.get("content-type").split("/")[1] extension = response.headers.get("content-type").split("/")[1]
} }
extension = extension.toLowerCase() extension = extension.toLowerCase()
if (!extension) { if (!extension) {
setLoaded(true) setLoaded(true)
console.error("Failed to get media type", url, extension) console.error("Failed to get media type", url, extension)
return return
} }
const mimeType = mimetypes.getType(extension) const mimeType = mimetypes.getType(extension)
setMimeType(mimeType) setMimeType(mimeType)
setLoaded(true) setLoaded(true)
} }
const renderMedia = () => { const renderMedia = () => {
if (!mimeType) { if (!mimeType) {
return null return null
} }
switch (mimeType.split("/")[0]) { switch (mimeType.split("/")[0]) {
case "image": { case "image": {
return <ImageViewer src={url} /> return <ImageViewer src={url} />
} }
case "video": { case "video": {
return <video controls> return (
<source src={url} type={mimeType} /> <video controls>
</video> <source src={url} type={mimeType} />
} </video>
case "audio": { )
return <audio controls> }
<source src={url} type={mimeType} /> case "audio": {
</audio> return (
} <audio controls>
default: { <source src={url} type={mimeType} />
return <h4> </audio>
Unsupported media type [{mimeType}] )
</h4> }
} default: {
} return <h4>Unsupported media type [{mimeType}]</h4>
} }
}
}
React.useEffect(() => { React.useEffect(() => {
getMediaType() getMediaType()
}, []) }, [])
if (!loaded) { if (!loaded) {
return <Skeleton active /> return <Skeleton active />
} }
if (loaded && !mimeType) { if (loaded && !mimeType) {
return <ContentFailed /> return <ContentFailed />
} }
return <div return (
key={props.index} <div
id={id} key={props.index}
className="attachment" id={id}
onDoubleClick={onDoubleClickAttachment} className="attachment"
> onDoubleClick={onDoubleClickAttachment}
{renderMedia()} >
</div> {renderMedia()}
} catch (error) { </div>
console.error(error) )
} catch (error) {
console.error(error)
return <ContentFailed /> return <ContentFailed />
} }
}) })
export default React.memo((props) => { export default React.memo((props) => {
const [controller, setController] = React.useState() const [controller, setController] = React.useState()
const [carouselState, setCarouselState] = React.useState() const [carouselState, setCarouselState] = React.useState()
const [nsfwAccepted, setNsfwAccepted] = React.useState(false) const [nsfwAccepted, setNsfwAccepted] = React.useState(false)
React.useEffect(() => { React.useEffect(() => {
// get attachment index from query string // get attachment index from query string
const attachmentIndex = parseInt(new URLSearchParams(window.location.search).get("attachment")) const attachmentIndex = parseInt(
new URLSearchParams(window.location.search).get("attachment"),
)
if (attachmentIndex) { if (attachmentIndex) {
controller?.slideToPage(attachmentIndex) controller?.slideToPage(attachmentIndex)
} }
}, []) }, [])
return <div className="post_attachments"> return (
{ <div className="post_attachments">
props.flags && props.flags.includes("nsfw") && !nsfwAccepted && {props.flags && props.flags.includes("nsfw") && !nsfwAccepted && (
<div className="nsfw_alert"> <div className="nsfw_alert">
<h2> <h2>This post may contain sensitive content.</h2>
This post may contain sensitive content.
</h2>
<Button onClick={() => setNsfwAccepted(true)}> <Button onClick={() => setNsfwAccepted(true)}>
Show anyways Show anyways
</Button> </Button>
</div> </div>
} )}
{ {props.attachments?.length > 0 && (
props.attachments?.length > 0 && <BearCarousel <BearCarousel
data={props.attachments.map((attachment, index) => { data={props.attachments.map((attachment, index) => {
if (typeof attachment !== "object") { if (typeof attachment !== "object") {
attachment = { attachment = {
url: attachment, url: attachment,
} }
} }
return { return {
key: index, key: index,
children: <React.Fragment key={index}> children: (
<Attachment index={index} attachment={attachment} /> <React.Fragment key={index}>
</React.Fragment> <Attachment
} index={index}
})} attachment={attachment}
isEnableNavButton />
isEnableMouseMove </React.Fragment>
isEnablePagination ),
setController={setController} }
onSlideChange={setCarouselState} })}
isDebug={renderDebug} isEnableNavButton
/> isEnableMouseMove
} isEnablePagination
</div> setController={setController}
onSlideChange={setCarouselState}
isDebug={renderDebug}
/>
)}
</div>
)
}) })

View File

@ -1,7 +1,7 @@
import React from "react" import React from "react"
import classnames from "classnames" import classnames from "classnames"
import Plyr from "plyr-react" import ReactPlayer from "react-player/lazy"
import { motion } from "framer-motion" import { motion } from "motion/react"
import Poll from "@components/Poll" import Poll from "@components/Poll"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
@ -14,249 +14,288 @@ import PostAttachments from "./components/attachments"
import "./index.less" import "./index.less"
const articleAnimationProps = { const articleAnimationProps = {
layout: true, layout: true,
initial: { y: -100, opacity: 0 }, initial: { y: -100, opacity: 0 },
animate: { y: 0, opacity: 1, }, animate: { y: 0, opacity: 1 },
exit: { scale: 0, opacity: 0 }, exit: { scale: 0, opacity: 0 },
transition: { duration: 0.1, }, transition: { duration: 0.1 },
} }
const messageRegexs = [ const messageRegexs = [
{ {
regex: /https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})(&[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+)*/g, regex: /https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})(&[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+)*/g,
fn: (key, result) => { fn: (key, result) => {
return <Plyr source={{ return <ReactPlayer url={result[1]} controls />
type: "video", },
sources: [{ },
src: result[1], {
provider: "youtube", regex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi,
}], fn: (key, result) => {
}} /> return (
} <a
}, key={key}
{ href={result[1]}
regex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi, target="_blank"
fn: (key, result) => { rel="noopener noreferrer"
return <a key={key} href={result[1]} target="_blank" rel="noopener noreferrer">{result[1]}</a> >
} {result[1]}
}, </a>
{ )
regex: /(@[a-zA-Z0-9_]+)/gi, },
fn: (key, result) => { },
return <a key={key} onClick={() => window.app.location.push(`/@${result[1].substr(1)}`)}>{result[1]}</a> {
} regex: /(@[a-zA-Z0-9_]+)/gi,
}, fn: (key, result) => {
{ return (
regex: /#[a-zA-Z0-9_]+/gi, <a
fn: (key, result) => { key={key}
return <a key={key} onClick={() => window.app.location.push(`/trending/${result[0].substr(1)}`)}>{result[0]}</a> onClick={() =>
} window.app.location.push(`/@${result[1].substr(1)}`)
} }
>
{result[1]}
</a>
)
},
},
{
regex: /#[a-zA-Z0-9_]+/gi,
fn: (key, result) => {
return (
<a
key={key}
onClick={() =>
window.app.location.push(
`/trending/${result[0].substr(1)}`,
)
}
>
{result[0]}
</a>
)
},
},
] ]
export default class PostCard extends React.PureComponent { export default class PostCard extends React.PureComponent {
state = { state = {
data: this.props.data, data: this.props.data,
countLikes: this.props.data.countLikes ?? 0, countLikes: this.props.data.countLikes ?? 0,
countReplies: this.props.data.countComments ?? 0, countReplies: this.props.data.countComments ?? 0,
hasLiked: this.props.data.isLiked ?? false, hasLiked: this.props.data.isLiked ?? false,
hasSaved: this.props.data.isSaved ?? false, hasSaved: this.props.data.isSaved ?? false,
hasReplies: this.props.data.hasReplies ?? false, hasReplies: this.props.data.hasReplies ?? false,
open: this.props.defaultOpened ?? false, open: this.props.defaultOpened ?? false,
isNsfw: this.props.data.flags?.includes("nsfw") ?? false, isNsfw: this.props.data.flags?.includes("nsfw") ?? false,
nsfwAccepted: false, nsfwAccepted: false,
} }
handleDataUpdate = (data) => { handleDataUpdate = (data) => {
this.setState({ this.setState({
data: data, data: data,
}) })
} }
onDoubleClick = async () => { onDoubleClick = async () => {
if (typeof this.props.events.onDoubleClick !== "function") { if (typeof this.props.events.onDoubleClick !== "function") {
console.warn("onDoubleClick event is not a function") console.warn("onDoubleClick event is not a function")
return return
} }
return await this.props.events.onDoubleClick(this.state.data) return await this.props.events.onDoubleClick(this.state.data)
} }
onClickDelete = async () => { onClickDelete = async () => {
if (typeof this.props.events.onClickDelete !== "function") { if (typeof this.props.events.onClickDelete !== "function") {
console.warn("onClickDelete event is not a function") console.warn("onClickDelete event is not a function")
return return
} }
return await this.props.events.onClickDelete(this.state.data) return await this.props.events.onClickDelete(this.state.data)
} }
onClickLike = async () => { onClickLike = async () => {
if (typeof this.props.events.onClickLike !== "function") { if (typeof this.props.events.onClickLike !== "function") {
console.warn("onClickLike event is not a function") console.warn("onClickLike event is not a function")
return return
} }
const actionResult = await this.props.events.onClickLike(this.state.data) const actionResult = await this.props.events.onClickLike(
this.state.data,
)
if (actionResult) { if (actionResult) {
this.setState({ this.setState({
hasLiked: actionResult.liked, hasLiked: actionResult.liked,
countLikes: actionResult.count, countLikes: actionResult.count,
}) })
} }
return actionResult return actionResult
} }
onClickSave = async () => { onClickSave = async () => {
if (typeof this.props.events.onClickSave !== "function") { if (typeof this.props.events.onClickSave !== "function") {
console.warn("onClickSave event is not a function") console.warn("onClickSave event is not a function")
return return
} }
const actionResult = await this.props.events.onClickSave(this.state.data) const actionResult = await this.props.events.onClickSave(
this.state.data,
)
if (actionResult) { if (actionResult) {
this.setState({ this.setState({
hasSaved: actionResult.saved, hasSaved: actionResult.saved,
}) })
} }
return actionResult return actionResult
} }
onClickEdit = async () => { onClickEdit = async () => {
if (typeof this.props.events.onClickEdit !== "function") { if (typeof this.props.events.onClickEdit !== "function") {
console.warn("onClickEdit event is not a function") console.warn("onClickEdit event is not a function")
return return
} }
return await this.props.events.onClickEdit(this.state.data) return await this.props.events.onClickEdit(this.state.data)
} }
onClickReply = async () => { onClickReply = async () => {
if (typeof this.props.events.onClickReply !== "function") { if (typeof this.props.events.onClickReply !== "function") {
console.warn("onClickReply event is not a function") console.warn("onClickReply event is not a function")
return return
} }
return await this.props.events.onClickReply(this.state.data) return await this.props.events.onClickReply(this.state.data)
} }
componentDidUpdate = (prevProps) => { componentDidUpdate = (prevProps) => {
if (prevProps.data !== this.props.data) { if (prevProps.data !== this.props.data) {
this.setState({ this.setState({
data: this.props.data, data: this.props.data,
}) })
} }
} }
componentDidCatch = (error, info) => { componentDidCatch = (error, info) => {
console.error(error) console.error(error)
return <div className="postCard error"> return (
<h1> <div className="postCard error">
<Icons.FiAlertTriangle /> <h1>
<span>Cannot render this post</span> <Icons.FiAlertTriangle />
<span> <span>Cannot render this post</span>
Maybe this version of the app is outdated or is not supported yet <span>
</span> Maybe this version of the app is outdated or is not
</h1> supported yet
</div> </span>
} </h1>
</div>
)
}
componentDidMount = () => { componentDidMount = () => {
app.cores.api.listenEvent(`post.update.${this.state.data._id}`, this.handleDataUpdate, "posts") app.cores.api.listenEvent(
} `post.update.${this.state.data._id}`,
this.handleDataUpdate,
"posts",
)
}
componentWillUnmount = () => { componentWillUnmount = () => {
app.cores.api.unlistenEvent(`post.update.${this.state.data._id}`, this.handleDataUpdate, "posts") app.cores.api.unlistenEvent(
} `post.update.${this.state.data._id}`,
this.handleDataUpdate,
"posts",
)
}
render() { render() {
return <motion.article return (
className={classnames( <motion.article
"post_card", className={classnames("post_card", {
{ ["open"]: this.state.open,
["open"]: this.state.open, })}
} id={this.state.data._id}
)} style={this.props.style}
id={this.state.data._id} {...articleAnimationProps}
style={this.props.style} >
{...articleAnimationProps} <div
> className="post_card_content"
<div context-menu={"post-card"}
className="post_card_content" user-id={this.state.data.user_id}
context-menu={"post-card"} >
user-id={this.state.data.user_id} <PostHeader
> postData={this.state.data}
<PostHeader onDoubleClick={this.onDoubleClick}
postData={this.state.data} disableReplyTag={this.props.disableReplyTag}
onDoubleClick={this.onDoubleClick} />
disableReplyTag={this.props.disableReplyTag}
/>
<div <div
id="post_content" id="post_content"
className={classnames( className={classnames("post_content")}
"post_content", >
)} <div className="message">
> {processString(messageRegexs)(
<div className="message"> this.state.data.message ?? "",
{ )}
processString(messageRegexs)(this.state.data.message ?? "") </div>
}
</div>
{ {!this.props.disableAttachments &&
!this.props.disableAttachments && this.state.data.attachments && this.state.data.attachments.length > 0 && <PostAttachments this.state.data.attachments &&
attachments={this.state.data.attachments} this.state.data.attachments.length > 0 && (
flags={this.state.data.flags} <PostAttachments
/> attachments={this.state.data.attachments}
} flags={this.state.data.flags}
/>
)}
{ {this.state.data.poll_options && (
this.state.data.poll_options && <Poll <Poll
post_id={this.state.data._id} post_id={this.state.data._id}
options={this.state.data.poll_options} options={this.state.data.poll_options}
/> />
} )}
</div> </div>
<PostActions <PostActions
post={this.state.data} post={this.state.data}
user_id={this.state.data.user_id} user_id={this.state.data.user_id}
likesCount={this.state.countLikes}
repliesCount={this.state.countReplies}
defaultLiked={this.state.hasLiked}
defaultSaved={this.state.hasSaved}
PP
actions={{
onClickLike: this.onClickLike,
onClickEdit: this.onClickEdit,
onClickDelete: this.onClickDelete,
onClickSave: this.onClickSave,
onClickReply: this.onClickReply,
}}
/>
likesCount={this.state.countLikes} {!this.props.disableHasReplies &&
repliesCount={this.state.countReplies} !!this.state.hasReplies && (
<div
defaultLiked={this.state.hasLiked} className="post-card-has_replies"
defaultSaved={this.state.hasSaved} onClick={() =>
PP app.navigation.goToPost(this.state.data._id)
actions={{ }
onClickLike: this.onClickLike, >
onClickEdit: this.onClickEdit, <span>
onClickDelete: this.onClickDelete, View {this.state.hasReplies} replies
onClickSave: this.onClickSave, </span>
onClickReply: this.onClickReply, </div>
}} )}
/> </div>
</motion.article>
{ )
!this.props.disableHasReplies && !!this.state.hasReplies && <div }
className="post-card-has_replies"
onClick={() => app.navigation.goToPost(this.state.data._id)}
>
<span>View {this.state.hasReplies} replies</span>
</div>
}
</div>
</motion.article>
}
} }

View File

@ -1,6 +1,6 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import { AnimatePresence } from "framer-motion" import { AnimatePresence } from "motion/react"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import PostCard from "@components/PostCard" import PostCard from "@components/PostCard"
@ -11,474 +11,499 @@ import PostModel from "@models/post"
import "./index.less" import "./index.less"
const LoadingComponent = () => { const LoadingComponent = () => {
return <div className="post_card"> return (
<antd.Skeleton <div className="post_card">
avatar <antd.Skeleton
style={{ avatar
width: "100%" style={{
}} width: "100%",
/> }}
</div> />
</div>
)
} }
const NoResultComponent = () => { const NoResultComponent = () => {
return <antd.Empty return (
description="No more post here" <antd.Empty
style={{ description="No more post here"
width: "100%" style={{
}} width: "100%",
/> }}
/>
)
} }
const typeToComponent = { const typeToComponent = {
"post": (args) => <PostCard {...args} />, post: (args) => <PostCard {...args} />,
//"playlist": (args) => <PlaylistTimelineEntry {...args} />, //"playlist": (args) => <PlaylistTimelineEntry {...args} />,
} }
const Entry = React.memo((props) => { const Entry = React.memo((props) => {
const { data } = props const { data } = props
return React.createElement(typeToComponent[data.type ?? "post"] ?? PostCard, { return React.createElement(
key: data._id, typeToComponent[data.type ?? "post"] ?? PostCard,
data: data, {
disableReplyTag: props.disableReplyTag, key: data._id,
disableHasReplies: props.disableHasReplies, data: data,
events: { disableReplyTag: props.disableReplyTag,
onClickLike: props.onLikePost, disableHasReplies: props.disableHasReplies,
onClickSave: props.onSavePost, events: {
onClickDelete: props.onDeletePost, onClickLike: props.onLikePost,
onClickEdit: props.onEditPost, onClickSave: props.onSavePost,
onClickReply: props.onReplyPost, onClickDelete: props.onDeletePost,
onDoubleClick: props.onDoubleClick, onClickEdit: props.onEditPost,
}, onClickReply: props.onReplyPost,
}) onDoubleClick: props.onDoubleClick,
},
},
)
}) })
const PostList = React.forwardRef((props, ref) => { const PostList = React.forwardRef((props, ref) => {
return <LoadMore return (
ref={ref} <LoadMore
className="post-list" ref={ref}
loadingComponent={LoadingComponent} className="post-list"
noResultComponent={NoResultComponent} loadingComponent={LoadingComponent}
hasMore={props.hasMore} noResultComponent={NoResultComponent}
fetching={props.loading} hasMore={props.hasMore}
onBottom={props.onLoadMore} fetching={props.loading}
> onBottom={props.onLoadMore}
{ >
!props.realtimeUpdates && !app.isMobile && <div className="resume_btn_wrapper"> {!props.realtimeUpdates && !app.isMobile && (
<antd.Button <div className="resume_btn_wrapper">
type="primary" <antd.Button
shape="round" type="primary"
onClick={props.onResumeRealtimeUpdates} shape="round"
loading={props.resumingLoading} onClick={props.onResumeRealtimeUpdates}
icon={<Icons.FiSyncOutlined />} loading={props.resumingLoading}
> icon={<Icons.FiSyncOutlined />}
Resume >
</antd.Button> Resume
</div> </antd.Button>
} </div>
)}
<AnimatePresence>
{
props.list.map((data) => {
return <Entry
key={data._id}
data={data}
{...props}
/>
})
}
</AnimatePresence>
</LoadMore>
<AnimatePresence>
{props.list.map((data) => {
return <Entry key={data._id} data={data} {...props} />
})}
</AnimatePresence>
</LoadMore>
)
}) })
export class PostsListsComponent extends React.Component { export class PostsListsComponent extends React.Component {
state = { state = {
openPost: null, openPost: null,
loading: false, loading: false,
resumingLoading: false, resumingLoading: false,
initialLoading: true, initialLoading: true,
scrollingToTop: false, scrollingToTop: false,
topVisible: true, topVisible: true,
realtimeUpdates: true, realtimeUpdates: true,
hasMore: true, hasMore: true,
list: this.props.list ?? [], list: this.props.list ?? [],
} }
parentRef = this.props.innerRef parentRef = this.props.innerRef
listRef = React.createRef() listRef = React.createRef()
timelineWsEvents = { timelineWsEvents = {
"feed.new": (data) => { "feed.new": (data) => {
console.log("New feed => ", data) console.log("New feed => ", data)
if (!this.state.realtimeUpdates) { if (!this.state.realtimeUpdates) {
return return
} }
this.setState({ this.setState({
list: [data, ...this.state.list], list: [data, ...this.state.list],
}) })
}, },
"post.new": (data) => { "post.new": (data) => {
console.log("New post => ", data) console.log("New post => ", data)
if (!this.state.realtimeUpdates) { if (!this.state.realtimeUpdates) {
return return
} }
this.setState({ this.setState({
list: [data, ...this.state.list], list: [data, ...this.state.list],
}) })
}, },
"post.delete": (id) => { "post.delete": (id) => {
console.log("Deleted post => ", id) console.log("Deleted post => ", id)
this.setState({ this.setState({
list: this.state.list.filter((post) => { list: this.state.list.filter((post) => {
return post._id !== id return post._id !== id
}), }),
}) })
} },
} }
handleLoad = async (fn, params = {}) => { handleLoad = async (fn, params = {}) => {
this.setState({ this.setState({
loading: true, loading: true,
}) })
let payload = { let payload = {
trim: this.state.list.length, trim: this.state.list.length,
limit: app.cores.settings.get("feed_max_fetch"), limit: app.cores.settings.get("feed_max_fetch"),
} }
if (this.props.loadFromModelProps) { if (this.props.loadFromModelProps) {
payload = { payload = {
...payload, ...payload,
...this.props.loadFromModelProps, ...this.props.loadFromModelProps,
} }
} }
if (params.replace) { if (params.replace) {
payload.trim = 0 payload.trim = 0
} }
const result = await fn(payload).catch((err) => { const result = await fn(payload).catch((err) => {
console.error(err) console.error(err)
app.message.error("Failed to load more posts") app.message.error("Failed to load more posts")
return null return null
}) })
console.log("Loaded posts => ", result) console.log("Loaded posts => ", result)
if (result) { if (result) {
if (result.length === 0) { if (result.length === 0) {
return this.setState({ return this.setState({
hasMore: false, hasMore: false,
}) })
} }
if (params.replace) { if (params.replace) {
this.setState({ this.setState({
list: result, list: result,
}) })
} else { } else {
this.setState({ this.setState({
list: [...this.state.list, ...result], list: [...this.state.list, ...result],
}) })
} }
} }
this.setState({ this.setState({
loading: false, loading: false,
}) })
} }
addPost = (post) => { addPost = (post) => {
this.setState({ this.setState({
list: [post, ...this.state.list], list: [post, ...this.state.list],
}) })
} }
removePost = (id) => { removePost = (id) => {
this.setState({ this.setState({
list: this.state.list.filter((post) => { list: this.state.list.filter((post) => {
return post._id !== id return post._id !== id
}), }),
}) })
} }
_hacks = { _hacks = {
addPost: this.addPost, addPost: this.addPost,
removePost: this.removePost, removePost: this.removePost,
addRandomPost: () => { addRandomPost: () => {
const randomId = Math.random().toString(36).substring(7) const randomId = Math.random().toString(36).substring(7)
this.addPost({ this.addPost({
_id: randomId, _id: randomId,
message: `Random post ${randomId}`, message: `Random post ${randomId}`,
user: { user: {
_id: randomId, _id: randomId,
username: "random user", username: "random user",
avatar: `https://api.dicebear.com/7.x/thumbs/svg?seed=${randomId}`, avatar: `https://api.dicebear.com/7.x/thumbs/svg?seed=${randomId}`,
} },
}) })
}, },
listRef: this.listRef, listRef: this.listRef,
} }
onResumeRealtimeUpdates = async () => { onResumeRealtimeUpdates = async () => {
console.log("Resuming realtime updates") console.log("Resuming realtime updates")
this.setState({ this.setState({
resumingLoading: true, resumingLoading: true,
scrollingToTop: true, scrollingToTop: true,
}) })
this.listRef.current.scrollTo({ this.listRef.current.scrollTo({
top: 0, top: 0,
behavior: "smooth", behavior: "smooth",
}) })
// reload posts // reload posts
await this.handleLoad(this.props.loadFromModel, { await this.handleLoad(this.props.loadFromModel, {
replace: true, replace: true,
}) })
this.setState({ this.setState({
realtimeUpdates: true, realtimeUpdates: true,
resumingLoading: false, resumingLoading: false,
}) })
} }
onScrollList = (e) => { onScrollList = (e) => {
const { scrollTop } = e.target const { scrollTop } = e.target
if (this.state.scrollingToTop && scrollTop === 0) { if (this.state.scrollingToTop && scrollTop === 0) {
this.setState({ this.setState({
scrollingToTop: false, scrollingToTop: false,
}) })
} }
if (scrollTop > 200) { if (scrollTop > 200) {
if (this.state.topVisible) { if (this.state.topVisible) {
this.setState({ this.setState({
topVisible: false, topVisible: false,
}) })
if (typeof this.props.onTopVisibility === "function") { if (typeof this.props.onTopVisibility === "function") {
this.props.onTopVisibility(false) this.props.onTopVisibility(false)
} }
} }
if (!this.props.realtime || this.state.resumingLoading || this.state.scrollingToTop) { if (
return null !this.props.realtime ||
} this.state.resumingLoading ||
this.state.scrollingToTop
this.setState({ ) {
realtimeUpdates: false, return null
}) }
} else {
if (!this.state.topVisible) { this.setState({
this.setState({ realtimeUpdates: false,
topVisible: true, })
}) } else {
if (!this.state.topVisible) {
if (typeof this.props.onTopVisibility === "function") { this.setState({
this.props.onTopVisibility(true) topVisible: true,
} })
// if (this.props.realtime || !this.state.realtimeUpdates && !this.state.resumingLoading && scrollTop < 5) { if (typeof this.props.onTopVisibility === "function") {
// this.onResumeRealtimeUpdates() this.props.onTopVisibility(true)
// } }
}
} // if (this.props.realtime || !this.state.realtimeUpdates && !this.state.resumingLoading && scrollTop < 5) {
} // this.onResumeRealtimeUpdates()
// }
componentDidMount = async () => { }
if (typeof this.props.loadFromModel === "function") { }
await this.handleLoad(this.props.loadFromModel) }
}
componentDidMount = async () => {
this.setState({ if (typeof this.props.loadFromModel === "function") {
initialLoading: false, await this.handleLoad(this.props.loadFromModel)
}) }
if (this.props.watchTimeline) { this.setState({
if (!Array.isArray(this.props.watchTimeline)) { initialLoading: false,
console.error("watchTimeline prop must be an array") })
} else {
this.props.watchTimeline.forEach((event) => { if (this.props.watchTimeline) {
if (typeof this.timelineWsEvents[event] !== "function") { if (!Array.isArray(this.props.watchTimeline)) {
console.error(`The event "${event}" is not defined in the timelineWsEvents object`) console.error("watchTimeline prop must be an array")
} } else {
this.props.watchTimeline.forEach((event) => {
app.cores.api.listenEvent(event, this.timelineWsEvents[event], "posts") if (typeof this.timelineWsEvents[event] !== "function") {
}) console.error(
} `The event "${event}" is not defined in the timelineWsEvents object`,
} )
}
if (this.listRef && this.listRef.current) {
this.listRef.current.addEventListener("scroll", this.onScrollList) app.cores.api.listenEvent(
} event,
this.timelineWsEvents[event],
window._hacks = this._hacks "posts",
} )
})
componentWillUnmount = async () => { }
if (this.props.watchTimeline) { }
if (!Array.isArray(this.props.watchTimeline)) {
console.error("watchTimeline prop must be an array") if (this.listRef && this.listRef.current) {
} else { this.listRef.current.addEventListener("scroll", this.onScrollList)
this.props.watchTimeline.forEach((event) => { }
if (typeof this.timelineWsEvents[event] !== "function") {
console.error(`The event "${event}" is not defined in the timelineWsEvents object`) window._hacks = this._hacks
} }
app.cores.api.unlistenEvent(event, this.timelineWsEvents[event], "posts") componentWillUnmount = async () => {
}) if (this.props.watchTimeline) {
} if (!Array.isArray(this.props.watchTimeline)) {
} console.error("watchTimeline prop must be an array")
} else {
if (this.listRef && this.listRef.current) { this.props.watchTimeline.forEach((event) => {
this.listRef.current.removeEventListener("scroll", this.onScrollList) if (typeof this.timelineWsEvents[event] !== "function") {
} console.error(
`The event "${event}" is not defined in the timelineWsEvents object`,
window._hacks = null )
} }
componentDidUpdate = async (prevProps, prevState) => { app.cores.api.unlistenEvent(
if (prevProps.list !== this.props.list) { event,
this.setState({ this.timelineWsEvents[event],
list: this.props.list, "posts",
}) )
} })
} }
}
onLikePost = async (data) => {
let result = await PostModel.toggleLike({ post_id: data._id }).catch(() => { if (this.listRef && this.listRef.current) {
antd.message.error("Failed to like post") this.listRef.current.removeEventListener(
"scroll",
return false this.onScrollList,
}) )
}
return result
} window._hacks = null
}
onSavePost = async (data) => {
let result = await PostModel.toggleSave({ post_id: data._id }).catch(() => { componentDidUpdate = async (prevProps, prevState) => {
antd.message.error("Failed to save post") if (prevProps.list !== this.props.list) {
this.setState({
return false list: this.props.list,
}) })
}
return result }
}
onLikePost = async (data) => {
onEditPost = (data) => { let result = await PostModel.toggleLike({ post_id: data._id }).catch(
app.controls.openPostCreator({ () => {
edit_post: data._id, antd.message.error("Failed to like post")
})
} return false
},
onReplyPost = (data) => { )
app.controls.openPostCreator({
reply_to: data._id, return result
}) }
}
onSavePost = async (data) => {
onDoubleClickPost = (data) => { let result = await PostModel.toggleSave({ post_id: data._id }).catch(
app.navigation.goToPost(data._id) () => {
} antd.message.error("Failed to save post")
onDeletePost = async (data) => { return false
antd.Modal.confirm({ },
title: "Are you sure you want to delete this post?", )
content: "This action is irreversible",
okText: "Yes", return result
okType: "danger", }
cancelText: "No",
onOk: async () => { onEditPost = (data) => {
await PostModel.deletePost({ post_id: data._id }).catch(() => { app.controls.openPostCreator({
antd.message.error("Failed to delete post") edit_post: data._id,
}) })
}, }
})
} onReplyPost = (data) => {
app.controls.openPostCreator({
ontoggleOpen = (to, data) => { reply_to: data._id,
if (typeof this.props.onOpenPost === "function") { })
this.props.onOpenPost(to, data) }
}
} onDoubleClickPost = (data) => {
app.navigation.goToPost(data._id)
onLoadMore = async () => { }
if (typeof this.props.onLoadMore === "function") {
return this.handleLoad(this.props.onLoadMore) onDeletePost = async (data) => {
} else if (this.props.loadFromModel) { antd.Modal.confirm({
return this.handleLoad(this.props.loadFromModel) title: "Are you sure you want to delete this post?",
} content: "This action is irreversible",
} okText: "Yes",
okType: "danger",
render() { cancelText: "No",
if (this.state.initialLoading) { onOk: async () => {
return <antd.Skeleton active /> await PostModel.deletePost({ post_id: data._id }).catch(() => {
} antd.message.error("Failed to delete post")
})
if (this.state.list.length === 0) { },
if (typeof this.props.emptyListRender === "function") { })
return React.createElement(this.props.emptyListRender) }
}
ontoggleOpen = (to, data) => {
return <div className="no_more_posts"> if (typeof this.props.onOpenPost === "function") {
<antd.Empty /> this.props.onOpenPost(to, data)
<h1>Whoa, nothing on here...</h1> }
</div> }
}
onLoadMore = async () => {
const PostListProps = { if (typeof this.props.onLoadMore === "function") {
list: this.state.list, return this.handleLoad(this.props.onLoadMore)
} else if (this.props.loadFromModel) {
disableReplyTag: this.props.disableReplyTag, return this.handleLoad(this.props.loadFromModel)
disableHasReplies: this.props.disableHasReplies, }
}
onLikePost: this.onLikePost,
onSavePost: this.onSavePost, render() {
onDeletePost: this.onDeletePost, if (this.state.initialLoading) {
onEditPost: this.onEditPost, return <antd.Skeleton active />
onReplyPost: this.onReplyPost, }
onDoubleClick: this.onDoubleClickPost,
if (this.state.list.length === 0) {
onLoadMore: this.onLoadMore, if (typeof this.props.emptyListRender === "function") {
hasMore: this.state.hasMore, return React.createElement(this.props.emptyListRender)
loading: this.state.loading, }
realtimeUpdates: this.state.realtimeUpdates, return (
resumingLoading: this.state.resumingLoading, <div className="no_more_posts">
onResumeRealtimeUpdates: this.onResumeRealtimeUpdates, <antd.Empty />
} <h1>Whoa, nothing on here...</h1>
</div>
if (app.isMobile) { )
return <PostList }
ref={this.listRef}
{...PostListProps} const PostListProps = {
/> list: this.state.list,
}
disableReplyTag: this.props.disableReplyTag,
return <div className="post-list_wrapper"> disableHasReplies: this.props.disableHasReplies,
<PostList
ref={this.listRef} onLikePost: this.onLikePost,
{...PostListProps} onSavePost: this.onSavePost,
/> onDeletePost: this.onDeletePost,
</div> onEditPost: this.onEditPost,
} onReplyPost: this.onReplyPost,
onDoubleClick: this.onDoubleClickPost,
onLoadMore: this.onLoadMore,
hasMore: this.state.hasMore,
loading: this.state.loading,
realtimeUpdates: this.state.realtimeUpdates,
resumingLoading: this.state.resumingLoading,
onResumeRealtimeUpdates: this.onResumeRealtimeUpdates,
}
if (app.isMobile) {
return <PostList ref={this.listRef} {...PostListProps} />
}
return (
<div className="post-list_wrapper">
<PostList ref={this.listRef} {...PostListProps} />
</div>
)
}
} }
export default React.forwardRef((props, ref) => <PostsListsComponent innerRef={ref} {...props} />) export default React.forwardRef((props, ref) => (
<PostsListsComponent innerRef={ref} {...props} />
))

View File

@ -1,86 +1,84 @@
import React from "react" import React from "react"
import { createIconRender } from "@components/Icons" import { createIconRender } from "@components/Icons"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "motion/react"
import "./index.less" import "./index.less"
const ContextMenu = (props) => { const ContextMenu = (props) => {
const [visible, setVisible] = React.useState(true) const [visible, setVisible] = React.useState(true)
const { items = [], cords, clickedComponent, ctx } = props const { items = [], cords, clickedComponent, ctx } = props
async function onClose() { async function onClose() {
setVisible(false) setVisible(false)
props.unregisterOnClose(onClose) props.unregisterOnClose(onClose)
} }
React.useEffect(() => { React.useEffect(() => {
props.registerOnClose(onClose) props.registerOnClose(onClose)
}, []) }, [])
const handleItemClick = async (item) => { const handleItemClick = async (item) => {
if (typeof item.action === "function") { if (typeof item.action === "function") {
await item.action(clickedComponent, ctx) await item.action(clickedComponent, ctx)
} }
} }
const renderItems = () => { const renderItems = () => {
if (items.length === 0) { if (items.length === 0) {
return <div> return (
<p>No items</p> <div>
</div> <p>No items</p>
} </div>
)
}
return items.map((item, index) => { return items.map((item, index) => {
if (item.type === "separator") { if (item.type === "separator") {
return <div key={index} className="context-menu-separator" /> return <div key={index} className="context-menu-separator" />
} }
return <div return (
key={index} <div
onClick={() => handleItemClick(item)} key={index}
className="item" onClick={() => handleItemClick(item)}
> className="item"
<p className="label"> >
{item.label} <p className="label">{item.label}</p>
</p>
{ {item.description && (
item.description && <p className="description"> <p className="description">{item.description}</p>
{item.description} )}
</p>
}
{ {createIconRender(item.icon)}
createIconRender(item.icon) </div>
} )
</div> })
}) }
}
return <AnimatePresence> return (
{ <AnimatePresence>
visible && <div {visible && (
className="context-menu-wrapper" <div
style={{ className="context-menu-wrapper"
top: cords.y, style={{
left: cords.x, top: cords.y,
}} left: cords.x,
> }}
<motion.div >
className="context-menu" <motion.div
initial={{ opacity: 0, scale: 0.8 }} className="context-menu"
animate={{ opacity: 1, scale: 1 }} initial={{ opacity: 0, scale: 0.8 }}
exit={{ opacity: 0, scale: 0.3 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.05, ease: "easeInOut" }} exit={{ opacity: 0, scale: 0.3 }}
> transition={{ duration: 0.05, ease: "easeInOut" }}
{ >
renderItems() {renderItems()}
} </motion.div>
</motion.div> </div>
</div> )}
} </AnimatePresence>
</AnimatePresence> )
} }
export default ContextMenu export default ContextMenu

View File

@ -1,68 +1,67 @@
import { Core } from "@ragestudio/vessel" import { Core } from "@ragestudio/vessel"
import { Haptics } from "@capacitor/haptics" // import { Haptics } from "@capacitor/haptics"
const vibrationPatterns = { const vibrationPatterns = {
light: [10], light: [10],
medium: [50], medium: [50],
heavy: [80], heavy: [80],
error: [100, 30, 100, 30, 100], error: [100, 30, 100, 30, 100],
} }
export default class HapticsCore extends Core { export default class HapticsCore extends Core {
static namespace = "haptics" static namespace = "haptics"
static dependencies = [ static dependencies = ["settings"]
"settings"
]
static isGlobalDisabled() { static isGlobalDisabled() {
return app.cores.settings.is("haptics:enabled", false) return app.cores.settings.is("haptics:enabled", false)
} }
async onInitialize() { async onInitialize() {
if (window.navigator.userAgent === "capacitor") { if (window.navigator.userAgent === "capacitor") {
navigator.vibrate = this.nativeVibrate navigator.vibrate = this.nativeVibrate
} }
document.addEventListener("click", this.handleClickEvent) document.addEventListener("click", this.handleClickEvent)
} }
public = { public = {
isGlobalDisabled: HapticsCore.isGlobalDisabled, //isGlobalDisabled: HapticsCore.isGlobalDisabled,
vibrate: this.vibrate.bind(this), vibrate: this.vibrate.bind(this),
} }
nativeVibrate = (pattern) => { // nativeVibrate = (pattern) => {
if (!Array.isArray(pattern)) { // if (!Array.isArray(pattern)) {
pattern = [pattern] // pattern = [pattern]
} // }
for (let i = 0; i < pattern.length; i++) { // for (let i = 0; i < pattern.length; i++) {
Haptics.vibrate({ // Haptics.vibrate({
duration: pattern[i], // duration: pattern[i],
}) // })
} // }
} // }
handleClickEvent = (event) => { handleClickEvent = (event) => {
const button = event.target.closest("button") || event.target.closest(".ant-btn") const button =
event.target.closest("button") || event.target.closest(".ant-btn")
if (button) { if (button) {
this.vibrate("light") this.vibrate("light")
} }
} }
vibrate(pattern = "light") { vibrate(pattern = "light") {
const disabled = HapticsCore.isGlobalDisabled() const disabled = HapticsCore.isGlobalDisabled()
if (disabled) { if (disabled) {
return false return false
} }
if (typeof pattern === "string") { if (typeof pattern === "string") {
pattern = vibrationPatterns[pattern] pattern = vibrationPatterns[pattern]
} }
return navigator.vibrate(pattern) return navigator.vibrate(pattern)
} }
} }

View File

@ -1,41 +1,48 @@
import { Haptics } from "@capacitor/haptics" //import { Haptics } from "@capacitor/haptics"
const NotfTypeToAudio = { const NotfTypeToAudio = {
info: "notification", info: "notification",
success: "notification", success: "notification",
warning: "warn", warning: "warn",
error: "error", error: "error",
} }
class NotificationFeedback { class NotificationFeedback {
static getSoundVolume = () => { static getSoundVolume = () => {
return (window.app.cores.settings.get("sfx:notifications_volume") ?? 50) / 100 return (
} (window.app.cores.settings.get("sfx:notifications_volume") ?? 50) /
100
)
}
static playHaptic = async () => { static playHaptic = async () => {
if (app.cores.settings.get("haptics:notifications_feedback")) { if (app.cores.settings.get("haptics:notifications_feedback")) {
await Haptics.vibrate() //await Haptics.vibrate()
} //use navigator.vibrate
} }
}
static playAudio = (type) => { static playAudio = (type) => {
if (app.cores.settings.get("sfx:notifications_feedback")) { if (app.cores.settings.get("sfx:notifications_feedback")) {
if (typeof window.app.cores.sfx?.play === "function") { if (typeof window.app.cores.sfx?.play === "function") {
window.app.cores.sfx.play(NotfTypeToAudio[type] ?? "notification", { window.app.cores.sfx.play(
volume: NotificationFeedback.getSoundVolume(), NotfTypeToAudio[type] ?? "notification",
}) {
} volume: NotificationFeedback.getSoundVolume(),
} },
} )
}
}
}
static async feedback({ type = "notification", feedback = true }) { static async feedback({ type = "notification", feedback = true }) {
if (!feedback) { if (!feedback) {
return false return false
} }
NotificationFeedback.playHaptic(type) NotificationFeedback.playHaptic(type)
NotificationFeedback.playAudio(type) NotificationFeedback.playAudio(type)
} }
} }
export default NotificationFeedback export default NotificationFeedback

View File

@ -1,131 +0,0 @@
import { CapacitorMusicControls } from "capacitor-music-controls-plugin-v3"
export default class MediaSession {
initialize() {
CapacitorMusicControls.addListener("controlsNotification", (info) => {
console.log(info)
this.handleControlsEvent(info)
})
document.addEventListener("controlsNotification", (event) => {
console.log(event)
const info = { message: event.message, position: 0 }
this.handleControlsEvent(info)
})
}
update(manifest) {
try {
if ("mediaSession" in navigator) {
return navigator.mediaSession.metadata = new MediaMetadata({
title: manifest.title,
artist: manifest.artist,
album: manifest.album,
artwork: [
{
src: manifest.cover ?? manifest.thumbnail,
sizes: "512x512",
type: "image/jpeg",
}
],
})
}
return CapacitorMusicControls.create({
track: manifest.title,
artist: manifest.artist,
album: manifest.album,
cover: manifest.cover,
hasPrev: false,
hasNext: false,
hasClose: true,
isPlaying: true,
dismissable: false,
playIcon: "media_play",
pauseIcon: "media_pause",
prevIcon: "media_prev",
nextIcon: "media_next",
closeIcon: "media_close",
notificationIcon: "notification"
})
} catch (error) {
console.error(error)
}
}
updateIsPlaying(to, timeElapsed = 0) {
try {
if ("mediaSession" in navigator) {
return navigator.mediaSession.playbackState = to ? "playing" : "paused"
}
return CapacitorMusicControls.updateIsPlaying({
isPlaying: to,
elapsed: timeElapsed,
})
} catch {
console.error(error)
}
}
destroy() {
if ("mediaSession" in navigator) {
navigator.mediaSession.playbackState = "none"
}
this.active = false
return CapacitorMusicControls.destroy()
}
handleControlsEvent(action) {
try {
const message = action.message
switch (message) {
case "music-controls-next": {
return app.cores.player.playback.next()
}
case "music-controls-previous": {
return app.cores.player.playback.previous()
}
case "music-controls-pause": {
return app.cores.player.playback.pause()
}
case "music-controls-play": {
return app.cores.player.playback.play()
}
case "music-controls-destroy": {
return app.cores.player.playback.stop()
}
// External controls (iOS only)
case "music-controls-toggle-play-pause": {
return app.cores.player.playback.toggle()
}
// Headset events (Android only)
// All media button events are listed below
case "music-controls-media-button": {
return app.cores.player.playback.toggle()
}
case "music-controls-headset-unplugged": {
return app.cores.player.playback.pause()
}
case "music-controls-headset-plugged": {
return app.cores.player.playback.play()
}
default:
break;
}
} catch (error) {
console.error(error)
}
}
}

View File

@ -1,11 +1,8 @@
import React from "react" import React from "react"
import progressBar from "nprogress"
import Layouts from "@layouts" import Layouts from "@layouts"
export default class Layout extends React.PureComponent { export default class Layout extends React.PureComponent {
progressBar = progressBar.configure({ parent: "html", showSpinner: false })
state = { state = {
layoutType: "default", layoutType: "default",
renderError: null, renderError: null,
@ -17,14 +14,18 @@ export default class Layout extends React.PureComponent {
}, },
"layout.animations.fadeOut": () => { "layout.animations.fadeOut": () => {
if (app.cores.settings.get("reduceAnimations")) { if (app.cores.settings.get("reduceAnimations")) {
console.warn("Skipping fadeIn animation due to `reduceAnimations` setting") console.warn(
"Skipping fadeIn animation due to `reduceAnimations` setting",
)
return false return false
} }
const transitionLayer = document.getElementById("transitionLayer") const transitionLayer = document.getElementById("transitionLayer")
if (!transitionLayer) { if (!transitionLayer) {
console.warn("transitionLayer not found, no animation will be played") console.warn(
"transitionLayer not found, no animation will be played",
)
return false return false
} }
@ -32,19 +33,23 @@ export default class Layout extends React.PureComponent {
}, },
"layout.animations.fadeIn": () => { "layout.animations.fadeIn": () => {
if (app.cores.settings.get("reduceAnimations")) { if (app.cores.settings.get("reduceAnimations")) {
console.warn("Skipping fadeOut animation due to `reduceAnimations` setting") console.warn(
"Skipping fadeOut animation due to `reduceAnimations` setting",
)
return false return false
} }
const transitionLayer = document.getElementById("transitionLayer") const transitionLayer = document.getElementById("transitionLayer")
if (!transitionLayer) { if (!transitionLayer) {
console.warn("transitionLayer not found, no animation will be played") console.warn(
"transitionLayer not found, no animation will be played",
)
return false return false
} }
transitionLayer.classList.remove("fade-opacity-leave") transitionLayer.classList.remove("fade-opacity-leave")
} },
} }
componentDidMount() { componentDidMount() {
@ -58,7 +63,10 @@ export default class Layout extends React.PureComponent {
} }
if (app.cores.settings.get("reduceAnimations")) { if (app.cores.settings.get("reduceAnimations")) {
this.layoutInterface.toggleRootContainerClassname("reduce-animations", true) this.layoutInterface.toggleRootContainerClassname(
"reduce-animations",
true,
)
} }
this.layoutInterface.toggleCenteredContent(true) this.layoutInterface.toggleCenteredContent(true)
@ -75,7 +83,7 @@ export default class Layout extends React.PureComponent {
this.setState({ renderError: { info, stack } }) this.setState({ renderError: { info, stack } })
} }
layoutInterface = window.app.layout = { layoutInterface = (window.app.layout = {
set: (layout) => { set: (layout) => {
if (typeof Layouts[layout] !== "function") { if (typeof Layouts[layout] !== "function") {
return console.error("Layout not found") return console.error("Layout not found")
@ -88,28 +96,52 @@ export default class Layout extends React.PureComponent {
}) })
}, },
toggleCenteredContent: (to) => { toggleCenteredContent: (to) => {
return this.layoutInterface.toggleRootContainerClassname("centered-content", to) return this.layoutInterface.toggleRootContainerClassname(
"centered-content",
to,
)
}, },
toggleRootScaleEffect: (to) => { toggleRootScaleEffect: (to) => {
return this.layoutInterface.toggleRootContainerClassname("root-scale-effect", to) return this.layoutInterface.toggleRootContainerClassname(
"root-scale-effect",
to,
)
}, },
toggleMobileStyle: (to) => { toggleMobileStyle: (to) => {
return this.layoutInterface.toggleRootContainerClassname("mobile", to) return this.layoutInterface.toggleRootContainerClassname(
"mobile",
to,
)
}, },
toggleReducedAnimations: (to) => { toggleReducedAnimations: (to) => {
return this.layoutInterface.toggleRootContainerClassname("reduce-animations", to) return this.layoutInterface.toggleRootContainerClassname(
"reduce-animations",
to,
)
}, },
toggleTopBarSpacer: (to) => { toggleTopBarSpacer: (to) => {
return this.layoutInterface.toggleRootContainerClassname("top-bar-spacer", to) return this.layoutInterface.toggleRootContainerClassname(
"top-bar-spacer",
to,
)
}, },
toggleDisableTopLayoutPadding: (to) => { toggleDisableTopLayoutPadding: (to) => {
return this.layoutInterface.toggleRootContainerClassname("disable-top-layout-padding", to) return this.layoutInterface.toggleRootContainerClassname(
"disable-top-layout-padding",
to,
)
}, },
togglePagePanelSpacer: (to) => { togglePagePanelSpacer: (to) => {
return this.layoutInterface.toggleRootContainerClassname("page-panel-spacer", to) return this.layoutInterface.toggleRootContainerClassname(
"page-panel-spacer",
to,
)
}, },
toggleCompactMode: (to) => { toggleCompactMode: (to) => {
return this.layoutInterface.toggleRootContainerClassname("compact-mode", to) return this.layoutInterface.toggleRootContainerClassname(
"compact-mode",
to,
)
}, },
toggleRootContainerClassname: (classname, to) => { toggleRootContainerClassname: (classname, to) => {
const root = document.documentElement const root = document.documentElement
@ -119,7 +151,10 @@ export default class Layout extends React.PureComponent {
return false return false
} }
to = typeof to === "boolean" ? to : !root.classList.contains(classname) to =
typeof to === "boolean"
? to
: !root.classList.contains(classname)
if (root.classList.contains(classname) === to) { if (root.classList.contains(classname) === to) {
// ignore // ignore
@ -154,8 +189,8 @@ export default class Layout extends React.PureComponent {
...to, ...to,
behavior: "smooth", behavior: "smooth",
}) })
} },
} })
render() { render() {
let layoutType = this.state.layoutType let layoutType = this.state.layoutType
@ -167,7 +202,10 @@ export default class Layout extends React.PureComponent {
if (this.state.renderError) { if (this.state.renderError) {
if (this.props.staticRenders?.RenderError) { if (this.props.staticRenders?.RenderError) {
return React.createElement(this.props.staticRenders?.RenderError, { error: this.state.renderError }) return React.createElement(
this.props.staticRenders?.RenderError,
{ error: this.state.renderError },
)
} }
return JSON.stringify(this.state.renderError) return JSON.stringify(this.state.renderError)
@ -176,11 +214,12 @@ export default class Layout extends React.PureComponent {
const Layout = Layouts[layoutType] const Layout = Layouts[layoutType]
if (!Layout) { if (!Layout) {
return app.eventBus.emit("runtime.crash", new Error(`Layout type [${layoutType}] not found`)) return app.eventBus.emit(
"runtime.crash",
new Error(`Layout type [${layoutType}] not found`),
)
} }
return <Layout {...layoutComponentProps}> return <Layout {...layoutComponentProps}>{this.props.children}</Layout>
{this.props.children}
</Layout>
} }
} }

View File

@ -1,13 +1,16 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import { Motion, spring } from "react-motion" import { motion, AnimatePresence } from "motion/react"
import { Icons, createIconRender } from "@components/Icons" import { Icons, createIconRender } from "@components/Icons"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext" import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
import { QuickNavMenuItems, QuickNavMenu } from "@layouts/components/@mobile/quickNav" import {
QuickNavMenuItems,
QuickNavMenu,
} from "@layouts/components/@mobile/quickNav"
import PlayerView from "@pages/@mobile-views/player" import PlayerView from "@pages/@mobile-views/player"
import CreatorView from "@pages/@mobile-views/creator" import CreatorView from "@pages/@mobile-views/creator"
@ -15,407 +18,434 @@ import CreatorView from "@pages/@mobile-views/creator"
import "./index.less" import "./index.less"
const tourSteps = [ const tourSteps = [
{ {
title: "Quick nav", title: "Quick nav",
description: "Tap & hold on the icon to open the navigation menu.", description: "Tap & hold on the icon to open the navigation menu.",
placement: "top", placement: "top",
refName: "navBtnRef", refName: "navBtnRef",
}, },
{ {
title: "Account button", title: "Account button",
description: "Tap & hold on the account icon to open miscellaneous options.", description:
placement: "top", "Tap & hold on the account icon to open miscellaneous options.",
refName: "accountBtnRef", placement: "top",
} refName: "accountBtnRef",
},
] ]
const openPlayerView = () => { const openPlayerView = () => {
app.layout.draggable.open("player", PlayerView) app.layout.draggable.open("player", PlayerView)
} }
const openCreator = () => { const openCreator = () => {
app.layout.draggable.open("creator", CreatorView) app.layout.draggable.open("creator", CreatorView)
} }
const PlayerButton = (props) => { const PlayerButton = (props) => {
React.useEffect(() => { React.useEffect(() => {
openPlayerView() openPlayerView()
}, []) }, [])
return <div return (
className={classnames( <div
"player_btn", className={classnames("player_btn", {
{ bounce: props.playback === "playing",
"bounce": props.playback === "playing" })}
} style={{
)} "--average-color": props.colorAnalysis?.rgba,
style={{ "--color": props.colorAnalysis?.isDark
"--average-color": props.colorAnalysis?.rgba, ? "var(--text-color-white)"
"--color": props.colorAnalysis?.isDark ? "var(--text-color-white)" : "var(--text-color-black)", : "var(--text-color-black)",
}} }}
onClick={openPlayerView} onClick={openPlayerView}
> >
{ {props.playback === "playing" ? (
props.playback === "playing" ? <Icons.MdMusicNote /> : <Icons.MdPause /> <Icons.MdMusicNote />
} ) : (
</div> <Icons.MdPause />
)}
</div>
)
} }
const AccountButton = React.forwardRef((props, ref) => { const AccountButton = React.forwardRef((props, ref) => {
const user = app.userData const user = app.userData
const handleClick = () => { const handleClick = () => {
if (!user) { if (!user) {
return app.navigation.goAuth() return app.navigation.goAuth()
} }
return app.navigation.goToAccount() return app.navigation.goToAccount()
} }
const handleHold = () => { const handleHold = () => {
app.layout.draggable.actions({ app.layout.draggable.actions({
list: [ list: [
{ {
key: "settings", key: "settings",
icon: "FiSettings", icon: "FiSettings",
label: "Settings", label: "Settings",
onClick: () => { onClick: () => {
app.navigation.goToSettings() app.navigation.goToSettings()
}, },
}, },
{ {
key: "account", key: "account",
icon: "FiUser", icon: "FiUser",
label: "Account", label: "Account",
onClick: () => { onClick: () => {
app.navigation.goToAccount() app.navigation.goToAccount()
}, },
}, },
{ {
key: "logout", key: "logout",
icon: "FiLogOut", icon: "FiLogOut",
label: "Logout", label: "Logout",
danger: true, danger: true,
onClick: () => { onClick: () => {
app.eventBus.emit("app.logout_request") app.eventBus.emit("app.logout_request")
}, },
} },
] ],
}) })
} }
return <div return (
key="account" <div
id="account" key="account"
className="item" id="account"
ref={ref} className="item"
onClick={handleClick} ref={ref}
onContextMenu={handleHold} onClick={handleClick}
context-menu="ignore" onContextMenu={handleHold}
> context-menu="ignore"
<div className="icon"> >
{ <div className="icon">
user ? <antd.Avatar shape="square" src={app.userData.avatar} /> : createIconRender("FiLogin") {user ? (
} <antd.Avatar shape="square" src={app.userData.avatar} />
</div> ) : (
</div> createIconRender("FiLogin")
)}
</div>
</div>
)
}) })
export class BottomBar extends React.Component { export class BottomBar extends React.Component {
static contextType = Context static contextType = Context
state = { state = {
visible: false, visible: false,
quickNavVisible: false, quickNavVisible: false,
render: null, render: null,
tourOpen: false, tourOpen: false,
} }
busEvents = { busEvents = {
"runtime.crash": () => { "runtime.crash": () => {
this.toggleVisibility(false) this.toggleVisibility(false)
} },
} }
navBtnRef = React.createRef() navBtnRef = React.createRef()
accountBtnRef = React.createRef() accountBtnRef = React.createRef()
componentDidMount = () => { componentDidMount = () => {
this.interface = app.layout.bottom_bar = { this.interface = app.layout.bottom_bar = {
toggleVisible: this.toggleVisibility, toggleVisible: this.toggleVisibility,
isVisible: () => this.state.visible, isVisible: () => this.state.visible,
render: (fragment) => { render: (fragment) => {
this.setState({ render: fragment }) this.setState({ render: fragment })
}, },
clear: () => { clear: () => {
this.setState({ render: null }) this.setState({ render: null })
}, },
toggleTour: () => { toggleTour: () => {
this.setState({ tourOpen: !this.state.tourOpen }) this.setState({ tourOpen: !this.state.tourOpen })
}, },
} }
setTimeout(() => { setTimeout(() => {
this.setState({ visible: true }) this.setState({ visible: true })
}, 10) }, 10)
// Register bus events // Register bus events
Object.keys(this.busEvents).forEach((key) => { Object.keys(this.busEvents).forEach((key) => {
app.eventBus.on(key, this.busEvents[key]) app.eventBus.on(key, this.busEvents[key])
}) })
setTimeout(() => { setTimeout(() => {
const isTourFinished = localStorage.getItem("mobile_tour") const isTourFinished = localStorage.getItem("mobile_tour")
if (!isTourFinished) { if (!isTourFinished) {
this.toggleTour(true) this.toggleTour(true)
localStorage.setItem("mobile_tour", true) localStorage.setItem("mobile_tour", true)
} }
}, 500) }, 500)
} }
componentWillUnmount = () => { componentWillUnmount = () => {
delete window.app.layout.bottom_bar delete window.app.layout.bottom_bar
// Unregister bus events // Unregister bus events
Object.keys(this.busEvents).forEach((key) => { Object.keys(this.busEvents).forEach((key) => {
app.eventBus.off(key, this.busEvents[key]) app.eventBus.off(key, this.busEvents[key])
}) })
} }
getTourSteps = () => { getTourSteps = () => {
return tourSteps.map((step) => { return tourSteps.map((step) => {
step.target = () => this[step.refName].current step.target = () => this[step.refName].current
return step return step
}) })
} }
toggleVisibility = (to) => { toggleVisibility = (to) => {
if (typeof to === "undefined") { if (typeof to === "undefined") {
to = !this.state.visible to = !this.state.visible
} }
this.setState({ visible: to }) this.setState({ visible: to })
} }
handleItemClick = (item) => { handleItemClick = (item) => {
if (item.dispatchEvent) { if (item.dispatchEvent) {
app.eventBus.emit(item.dispatchEvent) app.eventBus.emit(item.dispatchEvent)
} else if (item.location) { } else if (item.location) {
app.location.push(item.location) app.location.push(item.location)
} }
} }
handleNavTouchStart = (e) => { handleNavTouchStart = (e) => {
this._navTouchStart = setTimeout(() => { this._navTouchStart = setTimeout(() => {
this.setState({ quickNavVisible: true }) this.setState({ quickNavVisible: true })
if (app.cores.haptics?.vibrate) { if (app.cores.haptics?.vibrate) {
app.cores.haptics.vibrate(80) app.cores.haptics.vibrate(80)
} }
// remove the timeout // remove the timeout
this._navTouchStart = null this._navTouchStart = null
}, 400) }, 400)
} }
handleNavTouchEnd = (event) => { handleNavTouchEnd = (event) => {
if (this._lastHovered) { if (this._lastHovered) {
this._lastHovered.classList.remove("hover") this._lastHovered.classList.remove("hover")
} }
if (this._navTouchStart) { if (this._navTouchStart) {
clearTimeout(this._navTouchStart) clearTimeout(this._navTouchStart)
this._navTouchStart = null this._navTouchStart = null
return false return false
} }
this.setState({ quickNavVisible: false }) this.setState({ quickNavVisible: false })
// get cords of the touch // get cords of the touch
const x = event.changedTouches[0].clientX const x = event.changedTouches[0].clientX
const y = event.changedTouches[0].clientY const y = event.changedTouches[0].clientY
// get the element at the touch // get the element at the touch
const element = document.elementFromPoint(x, y) const element = document.elementFromPoint(x, y)
// get the closest element with the attribute // get the closest element with the attribute
const closest = element.closest(".quick-nav_item") const closest = element.closest(".quick-nav_item")
if (!closest) { if (!closest) {
return false return false
} }
const item = QuickNavMenuItems.find((item) => { const item = QuickNavMenuItems.find((item) => {
return item.id === closest.getAttribute("quicknav-item") return item.id === closest.getAttribute("quicknav-item")
}) })
if (!item) { if (!item) {
return false return false
} }
if (item.location) { if (item.location) {
app.location.push(item.location) app.location.push(item.location)
if (app.cores.haptics?.vibrate) { if (app.cores.haptics?.vibrate) {
app.cores.haptics.vibrate([40, 80]) app.cores.haptics.vibrate([40, 80])
} }
} }
} }
handleNavTouchMove = (event) => { handleNavTouchMove = (event) => {
// check if the touch is hovering a quicknav item // check if the touch is hovering a quicknav item
const x = event.changedTouches[0].clientX const x = event.changedTouches[0].clientX
const y = event.changedTouches[0].clientY const y = event.changedTouches[0].clientY
// get the element at the touch // get the element at the touch
const element = document.elementFromPoint(x, y) const element = document.elementFromPoint(x, y)
// get the closest element with the attribute // get the closest element with the attribute
const closest = element.closest("[quicknav-item]") const closest = element.closest("[quicknav-item]")
if (!closest) { if (!closest) {
if (this._lastHovered) { if (this._lastHovered) {
this._lastHovered.classList.remove("hover") this._lastHovered.classList.remove("hover")
} }
this._lastHovered = null this._lastHovered = null
return false return false
} }
if (this._lastHovered !== closest) { if (this._lastHovered !== closest) {
if (this._lastHovered) { if (this._lastHovered) {
this._lastHovered.classList.remove("hover") this._lastHovered.classList.remove("hover")
} }
this._lastHovered = closest this._lastHovered = closest
closest.classList.add("hover") closest.classList.add("hover")
if (app.cores.haptics?.vibrate) { if (app.cores.haptics?.vibrate) {
app.cores.haptics.vibrate(40) app.cores.haptics.vibrate(40)
} }
} }
} }
toggleTour = (to) => { toggleTour = (to) => {
if (typeof to === "undefined") { if (typeof to === "undefined") {
to = !this.state.tourOpen to = !this.state.tourOpen
} }
this.setState({ this.setState({
tourOpen: to tourOpen: to,
}) })
} }
render() { render() {
if (this.state.render) { if (this.state.render) {
return <div className="bottomBar"> return <div className="bottomBar">{this.state.render}</div>
{this.state.render} }
</div>
}
const heightValue = this.state.visible ? Number(app.cores.style.getDefaultVar("bottom-bar-height").replace("px", "")) : 0 const heightValue = Number(
app.cores.style
.getDefaultVar("bottom-bar-height")
.replace("px", ""),
)
return <> return (
{ <>
this.state.tourOpen && <antd.Tour {this.state.tourOpen && (
open <antd.Tour
steps={this.getTourSteps()} open
onClose={() => this.setState({ tourOpen: false })} steps={this.getTourSteps()}
/> onClose={() => this.setState({ tourOpen: false })}
} />
<QuickNavMenu )}
visible={this.state.quickNavVisible} <QuickNavMenu visible={this.state.quickNavVisible} />
/>
<Motion style={{ <AnimatePresence>
y: spring(this.state.visible ? 0 : 300), {this.state.visible && (
height: spring(heightValue) <motion.div
}}> className="bottomBar_wrapper"
{({ y, height }) => initial={{
<div className="bottomBar_wrapper"> height: 0,
<div y: 300,
className="bottomBar" }}
style={{ animate={{
WebkitTransform: `translateY(${y}px)`, height: heightValue,
transform: `translateY(${y}px)`, y: 0,
height: `${height}px`, }}
}} exit={{
> height: 0,
<div className="items"> y: 300,
<div }}
key="creator" transition={{
id="creator" type: "spring",
className={classnames("item", "primary")} stiffness: 100,
onClick={openCreator} damping: 20,
> }}
<div className="icon"> >
{createIconRender("FiPlusCircle")} <div className="bottomBar">
</div> <div className="items">
</div> <div
key="creator"
id="creator"
className={classnames(
"item",
"primary",
)}
onClick={openCreator}
>
<div className="icon">
{createIconRender("FiPlusCircle")}
</div>
</div>
{ {this.context.track_manifest && (
this.context.track_manifest && <div <div className="item">
className="item" <PlayerButton
> manifest={
<PlayerButton this.context.track_manifest
manifest={this.context.track_manifest} }
playback={this.context.playback_status} playback={
colorAnalysis={this.context.track_manifest?.cover_analysis} this.context.playback_status
/> }
</div> colorAnalysis={
} this.context.track_manifest
?.cover_analysis
}
/>
</div>
)}
<div <div
key="navigator" key="navigator"
id="navigator" id="navigator"
className="item" className="item"
ref={this.navBtnRef} ref={this.navBtnRef}
onClick={() => app.location.push("/")} onClick={() => app.location.push("/")}
onTouchMove={this.handleNavTouchMove} onTouchMove={this.handleNavTouchMove}
onTouchStart={this.handleNavTouchStart} onTouchStart={this.handleNavTouchStart}
onTouchEnd={this.handleNavTouchEnd} onTouchEnd={this.handleNavTouchEnd}
onTouchCancel={() => { onTouchCancel={() => {
this.setState({ quickNavVisible: false }) this.setState({
}} quickNavVisible: false,
> })
<div className="icon"> }}
{createIconRender("FiHome")} >
</div> <div className="icon">
</div> {createIconRender("FiHome")}
</div>
</div>
<div <div
key="searcher" key="searcher"
id="searcher" id="searcher"
className="item" className="item"
onClick={app.controls.openSearcher} onClick={app.controls.openSearcher}
> >
<div className="icon"> <div className="icon">
{createIconRender("FiSearch")} {createIconRender("FiSearch")}
</div> </div>
</div> </div>
<AccountButton <AccountButton ref={this.accountBtnRef} />
ref={this.accountBtnRef} </div>
/> </div>
</div> </motion.div>
</div> )}
</div> </AnimatePresence>
} </>
</Motion> )
</> }
}
} }
export default (props) => { export default (props) => {
return <WithPlayerContext> return (
<BottomBar <WithPlayerContext>
{...props} <BottomBar {...props} />
/> </WithPlayerContext>
</WithPlayerContext> )
} }

View File

@ -1,6 +1,6 @@
import React from "react" import React from "react"
import classnames from "classnames" import classnames from "classnames"
import { Motion, spring } from "react-motion" import { motion, AnimatePresence } from "motion/react"
import useLayoutInterface from "@hooks/useLayoutInterface" import useLayoutInterface from "@hooks/useLayoutInterface"
import useDefaultVisibility from "@hooks/useDefaultVisibility" import useDefaultVisibility from "@hooks/useDefaultVisibility"
@ -8,111 +8,125 @@ import useDefaultVisibility from "@hooks/useDefaultVisibility"
import "./index.less" import "./index.less"
export default (props) => { export default (props) => {
const [visible, setVisible] = useDefaultVisibility() const [visible, setVisible] = useDefaultVisibility()
const [shouldUseTopBarSpacer, setShouldUseTopBarSpacer] = React.useState(true) const [shouldUseTopBarSpacer, setShouldUseTopBarSpacer] =
const [render, setRender] = React.useState(null) React.useState(true)
const [render, setRender] = React.useState(null)
useLayoutInterface("top_bar", { useLayoutInterface("top_bar", {
toggleVisibility: (to) => { toggleVisibility: (to) => {
setVisible((prev) => { setVisible((prev) => {
if (typeof to === undefined) { if (typeof to === undefined) {
to = !prev to = !prev
} }
return to return to
}) })
}, },
render: (component, options) => { render: (component, options) => {
handleUpdateRender(component, options) handleUpdateRender(component, options)
}, },
renderDefault: () => { renderDefault: () => {
setRender(null) setRender(null)
}, },
shouldUseTopBarSpacer: (to) => { shouldUseTopBarSpacer: (to) => {
app.layout.toggleTopBarSpacer(to) app.layout.toggleTopBarSpacer(to)
setShouldUseTopBarSpacer(to) setShouldUseTopBarSpacer(to)
} },
}) })
const handleUpdateRender = (...args) => { const handleUpdateRender = (...args) => {
if (document.startViewTransition) { if (document.startViewTransition) {
return document.startViewTransition(() => { return document.startViewTransition(() => {
updateRender(...args) updateRender(...args)
}) })
} }
return updateRender(...args) return updateRender(...args)
} }
const updateRender = (component, options = {}) => { const updateRender = (component, options = {}) => {
setRender({ setRender({
component, component,
options options,
}) })
} }
React.useEffect(() => { React.useEffect(() => {
if (!shouldUseTopBarSpacer) { if (!shouldUseTopBarSpacer) {
app.layout.togglePagePanelSpacer(true) app.layout.togglePagePanelSpacer(true)
} else { } else {
app.layout.togglePagePanelSpacer(false) app.layout.togglePagePanelSpacer(false)
} }
}, [shouldUseTopBarSpacer]) }, [shouldUseTopBarSpacer])
React.useEffect(() => { React.useEffect(() => {
if (shouldUseTopBarSpacer) { if (shouldUseTopBarSpacer) {
if (visible) { if (visible) {
app.layout.toggleTopBarSpacer(true) app.layout.toggleTopBarSpacer(true)
} else { } else {
app.layout.toggleTopBarSpacer(false) app.layout.toggleTopBarSpacer(false)
} }
} else { } else {
if (visible) { if (visible) {
app.layout.togglePagePanelSpacer(true) app.layout.togglePagePanelSpacer(true)
} else { } else {
app.layout.togglePagePanelSpacer(false) app.layout.togglePagePanelSpacer(false)
} }
app.layout.toggleTopBarSpacer(false) app.layout.toggleTopBarSpacer(false)
} }
}, [visible]) }, [visible])
React.useEffect(() => { React.useEffect(() => {
if (render) { if (render) {
setVisible(true) setVisible(true)
} else { } else {
setVisible(false) setVisible(false)
} }
}, [render]) }, [render])
const heightValue = visible ? Number(app.cores.style.getDefaultVar("top-bar-height").replace("px", "")) : 0 const heightValue = Number(
app.cores.style.getDefaultVar("top-bar-height").replace("px", ""),
)
return <Motion style={{ return (
y: spring(visible ? 0 : 300,), <AnimatePresence>
height: spring(heightValue,), {visible && (
}}> <motion.div
{({ y, height }) => { className="top-bar_wrapper"
return <> animate={{
<div y: 0,
className="top-bar_wrapper" height: heightValue,
style={{ }}
WebkitTransform: `translateY(-${y}px)`, initial={{
transform: `translateY(-${y}px)`, y: -300,
height: `${height}px`, height: 0,
}} }}
> exit={{
<div y: -300,
className={classnames( height: 0,
"top-bar", }}
render?.options?.className, transition={{
)} type: "spring",
> stiffness: 100,
{ damping: 20,
render?.component && React.cloneElement(render?.component, render?.options?.props ?? {}) }}
} >
</div> <div
</div> className={classnames(
</> "top-bar",
}} render?.options?.className,
</Motion> )}
>
{render?.component &&
React.cloneElement(
render?.component,
render?.options?.props ?? {},
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
} }

View File

@ -1,7 +1,6 @@
import React from "react" import React from "react"
import classnames from "classnames" import classnames from "classnames"
import * as antd from "antd" import { AnimatePresence, motion } from "motion/react"
import { AnimatePresence, motion } from "framer-motion"
import "./index.less" import "./index.less"
@ -30,7 +29,7 @@ export class Drawer extends React.Component {
this.toggleVisibility(false) this.toggleVisibility(false)
this.props.controller.close(this.props.id, { this.props.controller.close(this.props.id, {
transition: 150 transition: 150,
}) })
} }
@ -65,31 +64,34 @@ export class Drawer extends React.Component {
handleDone: this.handleDone, handleDone: this.handleDone,
handleFail: this.handleFail, handleFail: this.handleFail,
} }
return <AnimatePresence> return (
{ <AnimatePresence>
this.state.visible && <motion.div {this.state.visible && (
key={this.props.id} <motion.div
id={this.props.id} key={this.props.id}
className="drawer" id={this.props.id}
style={{ className="drawer"
...this.options.style, style={{
}} ...this.options.style,
transformTemplate={transformTemplate} }}
animate={{ transformTemplate={transformTemplate}
x: [-100, 0], animate={{
opacity: [0, 1], x: [-100, 0],
}} opacity: [0, 1],
exit={{ }}
x: [0, -100], exit={{
opacity: [1, 0], x: [0, -100],
}} opacity: [1, 0],
> }}
{ >
React.createElement(this.props.children, componentProps) {React.createElement(
} this.props.children,
</motion.div> componentProps,
} )}
</AnimatePresence> </motion.div>
)}
</AnimatePresence>
)
} }
} }
@ -171,10 +173,12 @@ export default class DrawerController extends React.Component {
if (lastDrawer) { if (lastDrawer) {
if (app.layout.modal && lastDrawer.options.confirmOnOutsideClick) { if (app.layout.modal && lastDrawer.options.confirmOnOutsideClick) {
return app.layout.modal.confirm({ return app.layout.modal.confirm({
descriptionText: lastDrawer.options.confirmOnOutsideClickText ?? "Are you sure you want to close this drawer?", descriptionText:
lastDrawer.options.confirmOnOutsideClickText ??
"Are you sure you want to close this drawer?",
onConfirm: () => { onConfirm: () => {
lastDrawer.close() lastDrawer.close()
} },
}) })
} }
@ -202,18 +206,12 @@ export default class DrawerController extends React.Component {
} }
if (typeof addresses[id] === "undefined") { if (typeof addresses[id] === "undefined") {
drawers.push(<Drawer drawers.push(<Drawer key={id} {...instance} />)
key={id}
{...instance}
/>)
addresses[id] = drawers.length - 1 addresses[id] = drawers.length - 1
refs[id] = instance.ref refs[id] = instance.ref
} else { } else {
drawers[addresses[id]] = <Drawer drawers[addresses[id]] = <Drawer key={id} {...instance} />
key={id}
{...instance}
/>
refs[id] = instance.ref refs[id] = instance.ref
} }
@ -267,37 +265,39 @@ export default class DrawerController extends React.Component {
} }
render() { render() {
return <> return (
<AnimatePresence> <>
{
this.state.maskVisible && <motion.div
className="drawers-mask"
onClick={() => this.closeLastDrawer()}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
/>
}
</AnimatePresence>
<div
className={classnames(
"drawers-wrapper",
{
["hidden"]: !this.state.drawers.length,
}
)}
>
<AnimatePresence> <AnimatePresence>
{this.state.drawers} {this.state.maskVisible && (
<motion.div
className="drawers-mask"
onClick={() => this.closeLastDrawer()}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
transition={{
type: "spring",
stiffness: 100,
damping: 20,
}}
/>
)}
</AnimatePresence> </AnimatePresence>
</div>
</> <div
className={classnames("drawers-wrapper", {
["hidden"]: !this.state.drawers.length,
})}
>
<AnimatePresence>{this.state.drawers}</AnimatePresence>
</div>
</>
)
} }
} }

View File

@ -1,64 +1,64 @@
import React from "react" import React from "react"
import classnames from "classnames" import classnames from "classnames"
import { Motion, spring } from "react-motion" import { motion, AnimatePresence } from "motion/react"
import useLayoutInterface from "@hooks/useLayoutInterface" import useLayoutInterface from "@hooks/useLayoutInterface"
import "./index.less" import "./index.less"
export default (props) => { export default (props) => {
const [render, setRender] = React.useState(null) const [render, setRender] = React.useState(null)
useLayoutInterface("header", { useLayoutInterface("header", {
render: (component, options) => { render: (component, options) => {
if (component === null) { if (component === null) {
return setRender(null) return setRender(null)
} }
return setRender({ return setRender({
component, component,
options options,
}) })
}, },
}) })
React.useEffect(() => { React.useEffect(() => {
if (render) { if (render) {
app.layout.toggleDisableTopLayoutPadding(true) app.layout.toggleDisableTopLayoutPadding(true)
} else { } else {
app.layout.toggleDisableTopLayoutPadding(false) app.layout.toggleDisableTopLayoutPadding(false)
} }
}, [render]) }, [render])
return <Motion return (
style={{ <AnimatePresence>
y: spring(render ? 0 : 100,), {render && (
}} <motion.div
> className="page_header_wrapper"
{({ y, height }) => { animate={{
return <div y: 0,
className={classnames( }}
"page_header_wrapper", initial={{
{ y: -100,
["hidden"]: !render, }}
} exit={{
)} y: -100,
style={{ }}
WebkitTransform: `translateY(-${y}px)`, transition={{
transform: `translateY(-${y}px)`, type: "spring",
}} stiffness: 100,
> damping: 20,
<div }}
className="page_header" >
> <div className="page_header">
{ {render?.component &&
render?.component && React.cloneElement( React.cloneElement(
render?.component, render?.component,
render?.options?.props ?? {} render?.options?.props ?? {},
) )}
} </div>
</div> </motion.div>
</div> )}
}} </AnimatePresence>
</Motion> )
} }

View File

@ -2,7 +2,7 @@ import React from "react"
import config from "@config" import config from "@config"
import classnames from "classnames" import classnames from "classnames"
import { Translation } from "react-i18next" import { Translation } from "react-i18next"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "motion/react"
import { Menu, Avatar, Dropdown, Tag } from "antd" import { Menu, Avatar, Dropdown, Tag } from "antd"
import Drawer from "@layouts/components/drawer" import Drawer from "@layouts/components/drawer"

View File

@ -1,119 +1,133 @@
import React from "react" import React from "react"
import classnames from "classnames" import classnames from "classnames"
import { Motion, spring } from "react-motion" import { motion, AnimatePresence } from "motion/react"
import WidgetsWrapper from "@components/WidgetsWrapper" import WidgetsWrapper from "@components/WidgetsWrapper"
import "./index.less" import "./index.less"
export default class ToolsBar extends React.Component { export default class ToolsBar extends React.Component {
state = { state = {
visible: false, visible: false,
renders: { renders: {
top: [], top: [],
bottom: [], bottom: [],
}, },
} }
componentDidMount() { componentDidMount() {
app.layout.tools_bar = this.interface app.layout.tools_bar = this.interface
setTimeout(() => { setTimeout(() => {
this.setState({ this.setState({
visible: true, visible: true,
}) })
}, 10) }, 10)
} }
componentWillUnmount() { componentWillUnmount() {
delete app.layout.tools_bar delete app.layout.tools_bar
} }
interface = { interface = {
toggleVisibility: (to) => { toggleVisibility: (to) => {
this.setState({ this.setState({
visible: to ?? !this.state.visible, visible: to ?? !this.state.visible,
}) })
}, },
attachRender: (id, component, props, { position = "bottom" } = {}) => { attachRender: (id, component, props, { position = "bottom" } = {}) => {
this.setState((prev) => { this.setState((prev) => {
prev.renders[position].push({ prev.renders[position].push({
id: id, id: id,
component: component, component: component,
props: props, props: props,
}) })
return prev return prev
}) })
return component return component
}, },
detachRender: (id) => { detachRender: (id) => {
this.setState({ this.setState({
renders: { renders: {
top: this.state.renders.top.filter((render) => render.id !== id), top: this.state.renders.top.filter(
bottom: this.state.renders.bottom.filter((render) => render.id !== id), (render) => render.id !== id,
}, ),
}) bottom: this.state.renders.bottom.filter(
(render) => render.id !== id,
),
},
})
return true return true
} },
} }
render() { render() {
const hasAnyRenders = this.state.renders.top.length > 0 || this.state.renders.bottom.length > 0 const hasAnyRenders =
this.state.renders.top.length > 0 ||
this.state.renders.bottom.length > 0
const isVisible = hasAnyRenders && this.state.visible const isVisible = hasAnyRenders && this.state.visible
return <Motion return (
style={{ <AnimatePresence>
x: spring(isVisible ? 0 : 100), {isVisible && (
width: spring(isVisible ? 100 : 0), <motion.div
}} className={classnames("tools-bar-wrapper", {
> ["visible"]: isVisible,
{({ x, width }) => { })}
return <div animate={{
style={{ x: 0,
width: `${width}%`, width: "100%",
transform: `translateX(${x}%)`, }}
}} initial={{
className={classnames( x: 100,
"tools-bar-wrapper", width: "0%",
{ }}
visible: isVisible, exit={{
} x: 100,
)} width: "0%",
> }}
<div transition={{
id="tools_bar" type: "spring",
className="tools-bar" stiffness: 100,
> damping: 20,
<div className="attached_renders top"> }}
{ >
this.state.renders.top.map((render, index) => { <div id="tools_bar" className="tools-bar">
return React.createElement(render.component, { <div className="attached_renders top">
...render.props, {this.state.renders.top.map((render, index) => {
key: index, return React.createElement(
}) render.component,
}) {
} ...render.props,
</div> key: index,
},
)
})}
</div>
<WidgetsWrapper /> <WidgetsWrapper />
<div className="attached_renders bottom"> <div className="attached_renders bottom">
{ {this.state.renders.bottom.map(
this.state.renders.bottom.map((render, index) => { (render, index) => {
return React.createElement(render.component, { return React.createElement(
...render.props, render.component,
key: index, {
}) ...render.props,
}) key: index,
} },
</div> )
</div> },
</div> )}
}} </div>
</Motion> </div>
} </motion.div>
)}
</AnimatePresence>
)
}
} }

View File

@ -1,79 +1,78 @@
@import "@styles/vars.less"; @import "@styles/vars.less";
.tools-bar-wrapper { .tools-bar-wrapper {
position: sticky; position: sticky;
top: 0; top: 0;
right: 0; right: 0;
z-index: 150; z-index: 150;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
max-width: 420px; max-width: 420px;
padding: 10px; padding: 10px;
.visible { .visible {
min-width: 320px; min-width: 320px;
} }
&:not(.visible) { &:not(.visible) {
min-width: 0; min-width: 0;
padding: 0; padding: 0;
}
}
} }
.tools-bar { .tools-bar {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 12px; border-radius: 12px;
border-radius: @sidebar_borderRadius; border-radius: @sidebar_borderRadius;
box-shadow: @card-shadow; box-shadow: @card-shadow;
padding: 10px; padding: 10px;
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
gap: 20px; gap: 20px;
flex: 0; flex: 0;
.attached_renders { .attached_renders {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
height: fit-content; height: fit-content;
gap: 10px; gap: 10px;
&.bottom { &.bottom {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
padding: 10px; padding: 10px;
} }
.card { .card {
width: 100%; width: 100%;
height: fit-content; height: fit-content;
background-color: var(--background-color-primary); background-color: var(--background-color-primary);
} }
} }
} }

View File

@ -1,7 +1,7 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "motion/react"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import FollowButton from "@components/FollowButton" import FollowButton from "@components/FollowButton"
@ -21,10 +21,10 @@ import FollowersTab from "./tabs/followers"
import "./index.less" import "./index.less"
const TabsComponent = { const TabsComponent = {
"posts": PostsTab, posts: PostsTab,
"followers": FollowersTab, followers: FollowersTab,
"details": DetailsTab, details: DetailsTab,
"music": MusicTab, music: MusicTab,
} }
export default class Account extends React.Component { export default class Account extends React.Component {
@ -60,7 +60,7 @@ export default class Account extends React.Component {
} }
user = await UserModel.data({ user = await UserModel.data({
username: requestedUser username: requestedUser,
}).catch((error) => { }).catch((error) => {
console.error(error) console.error(error)
@ -77,7 +77,9 @@ export default class Account extends React.Component {
console.log(`Loaded User [${user.username}] >`, user) console.log(`Loaded User [${user.username}] >`, user)
const followersResult = await FollowsModel.getFollowers(user._id).catch(() => false) const followersResult = await FollowsModel.getFollowers(
user._id,
).catch(() => false)
if (followersResult) { if (followersResult) {
followersCount = followersResult.count followersCount = followersResult.count
@ -118,7 +120,10 @@ export default class Account extends React.Component {
handlePageTransition = (key) => { handlePageTransition = (key) => {
if (typeof key !== "string") { if (typeof key !== "string") {
console.error("Cannot handle page transition. Invalid key, only valid passing string", key) console.error(
"Cannot handle page transition. Invalid key, only valid passing string",
key,
)
return return
} }
@ -129,7 +134,7 @@ export default class Account extends React.Component {
} }
this.setState({ this.setState({
tabActiveKey: key tabActiveKey: key,
}) })
} }
@ -137,119 +142,119 @@ export default class Account extends React.Component {
const user = this.state.user const user = this.state.user
if (this.state.isNotExistent) { if (this.state.isNotExistent) {
return <antd.Result return (
status="404" <antd.Result
title="This user does not exist, yet..." status="404"
> title="This user does not exist, yet..."
></antd.Result>
</antd.Result> )
} }
if (!user) { if (!user) {
return <antd.Skeleton active /> return <antd.Skeleton active />
} }
return <div return (
id="profile" <div
className={classnames( id="profile"
"account-profile", className={classnames("account-profile", {
{
["withCover"]: user.cover, ["withCover"]: user.cover,
} })}
)} >
> {user.cover && (
{ <div
user.cover && <div className={classnames("cover", {
className={classnames("cover", { ["expanded"]: this.state.coverExpanded,
["expanded"]: this.state.coverExpanded })}
})} style={{ backgroundImage: `url("${user.cover}")` }}
style={{ backgroundImage: `url("${user.cover}")` }} onClick={() => this.toggleCoverExpanded()}
onClick={() => this.toggleCoverExpanded()} id="profile-cover"
id="profile-cover"
/>
}
<div className="panels">
<div className="left-panel">
<UserCard
user={user}
/> />
)}
<div className="actions"> <div className="panels">
<FollowButton <div className="left-panel">
count={this.state.followersCount} <UserCard user={user} />
onClick={this.onClickFollow}
followed={this.state.following}
self={this.state.isSelf}
/>
{ <div className="actions">
!this.state.isSelf && <antd.Button <FollowButton
icon={<Icons.MdMessage />} count={this.state.followersCount}
onClick={() => app.location.push(`/messages/${user._id}`)} onClick={this.onClickFollow}
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>
<div className="center-panel" ref={this.contentRef}>
<AnimatePresence mode="wait">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.15,
}}
key={this.state.tabActiveKey}
style={{
width: "100%",
}}
>
{React.createElement(
TabsComponent[this.state.tabActiveKey],
{
onTopVisibility:
this.onPostListTopVisibility,
state: this.state,
},
)}
</motion.div>
</AnimatePresence>
</div>
<div className="right-panel">
<antd.Menu
className="tabMenu"
mode={app.isMobile ? "horizontal" : "vertical"}
selectedKeys={[this.state.tabActiveKey]}
onClick={(e) => this.handlePageTransition(e.key)}
items={GenerateMenuItems([
{
id: "posts",
label: "Posts",
icon: "FiBookOpen",
},
{
id: "music",
label: "Music",
icon: "MdAlbum",
},
{
id: "followers",
label: "Followers",
icon: "FiUsers",
},
{
id: "details",
label: "Details",
icon: "FiInfo",
},
])}
/>
</div> </div>
</div> </div>
<div
className="center-panel"
ref={this.contentRef}
>
<AnimatePresence mode="wait">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.15,
}}
key={this.state.tabActiveKey}
style={{
width: "100%",
}}
>
{
React.createElement(TabsComponent[this.state.tabActiveKey], {
onTopVisibility: this.onPostListTopVisibility,
state: this.state
})
}
</motion.div>
</AnimatePresence>
</div>
<div className="right-panel">
<antd.Menu
className="tabMenu"
mode={app.isMobile ? "horizontal" : "vertical"}
selectedKeys={[this.state.tabActiveKey]}
onClick={(e) => this.handlePageTransition(e.key)}
items={GenerateMenuItems([
{
id: "posts",
label: "Posts",
icon: "FiBookOpen",
},
{
id: "music",
label: "Music",
icon: "MdAlbum",
},
{
id: "followers",
label: "Followers",
icon: "FiUsers",
},
{
id: "details",
label: "Details",
icon: "FiInfo",
}
])}
/>
</div>
</div> </div>
</div> )
} }
} }

View File

@ -1,153 +1,162 @@
import React from "react" import React from "react"
import classnames from "classnames" import classnames from "classnames"
import { Motion, spring } from "react-motion" import { motion, AnimatePresence } from "motion/react"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const LyricsText = React.forwardRef((props, textRef) => { const LyricsText = React.forwardRef((props, textRef) => {
const [playerState] = usePlayerStateContext() const [playerState] = usePlayerStateContext()
const { lyrics } = props const { lyrics } = props
const [syncInterval, setSyncInterval] = React.useState(null) const [syncInterval, setSyncInterval] = React.useState(null)
const [currentLineIndex, setCurrentLineIndex] = React.useState(0) const [currentLineIndex, setCurrentLineIndex] = React.useState(0)
const [visible, setVisible] = React.useState(false) const [visible, setVisible] = React.useState(false)
function syncPlayback() { function syncPlayback() {
const currentTrackTime = app.cores.player.controls.seek() * 1000 const currentTrackTime = app.cores.player.controls.seek() * 1000
const lineIndex = lyrics.synced_lyrics.findIndex((line) => { const lineIndex = lyrics.synced_lyrics.findIndex((line) => {
return currentTrackTime >= line.startTimeMs && currentTrackTime <= line.endTimeMs return (
}) currentTrackTime >= line.startTimeMs &&
currentTrackTime <= line.endTimeMs
)
})
if (lineIndex === -1) { if (lineIndex === -1) {
if (!visible) { if (!visible) {
setVisible(false) setVisible(false)
} }
return false return false
} }
const line = lyrics.synced_lyrics[lineIndex] const line = lyrics.synced_lyrics[lineIndex]
setCurrentLineIndex(lineIndex) setCurrentLineIndex(lineIndex)
if (line.break) { if (line.break) {
return setVisible(false) return setVisible(false)
} }
if (line.text) { if (line.text) {
return setVisible(true) return setVisible(true)
} }
} }
function startSyncInterval() { function startSyncInterval() {
if (!lyrics || !lyrics.synced_lyrics) { if (!lyrics || !lyrics.synced_lyrics) {
stopSyncInterval() stopSyncInterval()
return false return false
} }
if (playerState.playback_status !== "playing") { if (playerState.playback_status !== "playing") {
stopSyncInterval() stopSyncInterval()
return false return false
} }
if (syncInterval) { if (syncInterval) {
stopSyncInterval() stopSyncInterval()
} }
setSyncInterval(setInterval(syncPlayback, 100)) setSyncInterval(setInterval(syncPlayback, 100))
} }
function stopSyncInterval() { function stopSyncInterval() {
clearInterval(syncInterval) clearInterval(syncInterval)
setSyncInterval(null) setSyncInterval(null)
} }
//* Handle when current line index change //* Handle when current line index change
React.useEffect(() => { React.useEffect(() => {
if (currentLineIndex === 0) { if (currentLineIndex === 0) {
setVisible(false) setVisible(false)
} else { } else {
setVisible(true) setVisible(true)
// find line element by id // find line element by id
const lineElement = textRef.current.querySelector(`#lyrics-line-${currentLineIndex}`) const lineElement = textRef.current.querySelector(
`#lyrics-line-${currentLineIndex}`,
)
// center scroll to current line // center scroll to current line
if (lineElement) { if (lineElement) {
lineElement.scrollIntoView({ lineElement.scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "center", block: "center",
}) })
} else { } else {
// scroll to top // scroll to top
textRef.current.scrollTop = 0 textRef.current.scrollTop = 0
} }
} }
}, [currentLineIndex]) }, [currentLineIndex])
//* Handle when playback status change //* Handle when playback status change
React.useEffect(() => { React.useEffect(() => {
startSyncInterval() startSyncInterval()
}, [playerState.playback_status]) }, [playerState.playback_status])
//* Handle when manifest object change, reset... //* Handle when manifest object change, reset...
React.useEffect(() => { React.useEffect(() => {
setVisible(false) setVisible(false)
setCurrentLineIndex(0) setCurrentLineIndex(0)
}, [playerState.track_manifest]) }, [playerState.track_manifest])
React.useEffect(() => { React.useEffect(() => {
startSyncInterval() startSyncInterval()
}, [lyrics]) }, [lyrics])
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
clearInterval(syncInterval) clearInterval(syncInterval)
} }
}, []) }, [])
if (!lyrics?.synced_lyrics) { if (!lyrics?.synced_lyrics) {
return null return null
} }
return <div return (
className="lyrics-text-wrapper" <div className="lyrics-text-wrapper">
> <AnimatePresence>
<Motion {visible && (
style={{ <motion.div
opacity: spring(visible ? 1 : 0), ref={textRef}
}} className="lyrics-text"
> animate={{
{({ opacity }) => { opacity: 1,
return <div }}
ref={textRef} initial={{
className="lyrics-text" opacity: 0,
style={{ }}
opacity exit={{
}} opacity: 0,
> }}
{ transition={{
lyrics.synced_lyrics.map((line, index) => { type: "spring",
return <p stiffness: 100,
key={index} damping: 20,
id={`lyrics-line-${index}`} }}
className={classnames( >
"line", {lyrics.synced_lyrics.map((line, index) => {
{ return (
["current"]: currentLineIndex === index <p
} key={index}
)} id={`lyrics-line-${index}`}
> className={classnames("line", {
{line.text} ["current"]: currentLineIndex === index,
</p> })}
}) >
} {line.text}
</div> </p>
}} )
</Motion> })}
</div> </motion.div>
)}
</AnimatePresence>
</div>
)
}) })
export default LyricsText export default LyricsText

View File

@ -10,281 +10,299 @@ import config from "@config"
import "./index.less" import "./index.less"
const connectionsTooltipStrings = { const connectionsTooltipStrings = {
secure: "This connection is secure", secure: "This connection is secure",
insecure: "This connection is insecure, cause it's not using HTTPS protocol and the server cannot be verified on the trusted certificate authority.", insecure:
warning: "This connection is secure but the server cannot be verified on the trusted certificate authority.", "This connection is insecure, cause it's not using HTTPS protocol and the server cannot be verified on the trusted certificate authority.",
warning:
"This connection is secure but the server cannot be verified on the trusted certificate authority.",
} }
export default { export default {
id: "about", id: "about",
icon: "FiInfo", icon: "FiInfo",
label: "About", label: "About",
group: "bottom", group: "bottom",
render: () => { render: () => {
const isProduction = import.meta.env.PROD const isProduction = import.meta.env.PROD
const [serverManifest, setServerManifest] = React.useState(null) const [serverManifest, setServerManifest] = React.useState(null)
const [serverOrigin, setServerOrigin] = React.useState(null) const [serverOrigin, setServerOrigin] = React.useState(null)
const [secureConnection, setSecureConnection] = React.useState(false) const [secureConnection, setSecureConnection] = React.useState(false)
const [capInfo, setCapInfo] = React.useState(null) const [capInfo, setCapInfo] = React.useState(null)
const setCapacitorInfo = async () => { const setCapacitorInfo = async () => {
if (Capacitor.Plugins.App) { if (Capacitor) {
const info = await Capacitor.Plugins.App.getInfo() if (Capacitor.Plugins.App) {
const info = await Capacitor.Plugins.App.getInfo()
setCapInfo(info) setCapInfo(info)
} }
} }
}
const checkServerVersion = async () => { const checkServerVersion = async () => {
const serverManifest = await app.cores.api.customRequest() const serverManifest = await app.cores.api.customRequest()
setServerManifest(serverManifest.data) setServerManifest(serverManifest.data)
} }
const checkServerOrigin = async () => { const checkServerOrigin = async () => {
const instance = app.cores.api.client() const instance = app.cores.api.client()
if (instance) { if (instance) {
setServerOrigin(instance.mainOrigin) setServerOrigin(instance.mainOrigin)
if (instance.mainOrigin.startsWith("https")) { if (instance.mainOrigin.startsWith("https")) {
setSecureConnection(true) setSecureConnection(true)
} }
} }
} }
React.useEffect(() => { React.useEffect(() => {
checkServerVersion() checkServerVersion()
checkServerOrigin() checkServerOrigin()
setCapacitorInfo() setCapacitorInfo()
}, []) }, [])
return <div className="about_app"> return (
<div className="header"> <div className="about_app">
<div className="branding"> <div className="header">
<div className="logo"> <div className="branding">
<img <div className="logo">
src={config.logo.alt} <img src={config.logo.alt} alt="Logo" />
alt="Logo" </div>
/> <div className="texts">
</div> <div className="sitename-text">
<div className="texts"> <h2>{config.app.siteName}</h2>
<div className="sitename-text"> <antd.Tag>Beta</antd.Tag>
<h2>{config.app.siteName}</h2> </div>
<antd.Tag>Beta</antd.Tag> <span>{config.author}</span>
</div> <span>
<span>{config.author}</span> Licensed with{" "}
<span>Licensed with {config.package?.license ?? "unlicensed"} </span> {config.package?.license ?? "unlicensed"}{" "}
</div> </span>
</div> </div>
<div className="versions"> </div>
<antd.Tag><Icons.FiTag />v{window.app.version ?? "experimental"}</antd.Tag> <div className="versions">
<antd.Tag color={isProduction ? "green" : "magenta"}> <antd.Tag>
{isProduction ? <Icons.FiCheckCircle /> : <Icons.FiTriangle />} <Icons.FiTag />v
{String(import.meta.env.MODE)} {window.app.version ?? "experimental"}
</antd.Tag> </antd.Tag>
</div> <antd.Tag color={isProduction ? "green" : "magenta"}>
</div> {isProduction ? (
<Icons.FiCheckCircle />
) : (
<Icons.FiTriangle />
)}
{String(import.meta.env.MODE)}
</antd.Tag>
</div>
</div>
<div className="group"> <div className="group">
<div className="group_header"> <div className="group_header">
<h3><Icons.FiInfo />Server information</h3> <h3>
</div> <Icons.FiInfo />
Server information
</h3>
</div>
<div className="field"> <div className="field">
<div className="field_header"> <div className="field_header">
<h3><Icons.MdOutlineStream /> Origin</h3> <h3>
<Icons.MdOutlineStream /> Origin
</h3>
<antd.Tooltip <antd.Tooltip
title={secureConnection ? connectionsTooltipStrings.secure : connectionsTooltipStrings.insecure} title={
> secureConnection
<antd.Tag ? connectionsTooltipStrings.secure
color={secureConnection ? "green" : "red"} : connectionsTooltipStrings.insecure
icon={secureConnection ? <Icons.MdHttps /> : <Icons.MdWarning />} }
> >
{ <antd.Tag
secureConnection ? " Secure connection" : "Insecure connection" color={secureConnection ? "green" : "red"}
} icon={
</antd.Tag> secureConnection ? (
</antd.Tooltip> <Icons.MdHttps />
</div> ) : (
<Icons.MdWarning />
)
}
>
{secureConnection
? " Secure connection"
: "Insecure connection"}
</antd.Tag>
</antd.Tooltip>
</div>
<div className="field_value"> <div className="field_value">
{serverOrigin ?? "Unknown"} {serverOrigin ?? "Unknown"}
</div> </div>
</div> </div>
<div className="field"> <div className="field">
<div className="field_header"> <div className="field_header">
<h3><Icons.MdOutlineMemory /> Instance Performance</h3> <h3>
</div> <Icons.MdOutlineMemory /> Instance Performance
</h3>
</div>
<div className="field_value"> <div className="field_value">
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "0.5rem", gap: "0.5rem",
fontSize: "1.4rem", fontSize: "1.4rem",
justifyContent: "space-evenly", justifyContent: "space-evenly",
width: "100%", width: "100%",
}} }}
> >
<LatencyIndicator <LatencyIndicator type="http" />
type="http"
/>
<LatencyIndicator <LatencyIndicator type="ws" />
type="ws" </div>
/> </div>
</div> </div>
</div>
</div>
<div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdBuild /> <Icons.MdBuild />
</div> </div>
<p>Version</p> <p>Version</p>
</div> </div>
<div className="field_value"> <div className="field_value">
{serverManifest?.version ?? "Unknown"} {serverManifest?.version ?? "Unknown"}
</div> </div>
</div> </div>
</div> </div>
<div className="group"> <div className="group">
<h3>Thanks to our sponsors</h3> <h3>Thanks to our sponsors</h3>
<SponsorsList /> <SponsorsList />
</div> </div>
<div className="group"> <div className="group">
<div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdInfo /> <Icons.MdInfo />
</div> </div>
<p>Platform</p> <p>Platform</p>
</div> </div>
<div className="field_value"> <div className="field_value">Web App</div>
{Capacitor.platform} </div>
</div>
</div>
<div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdInfo /> <Icons.MdInfo />
</div> </div>
<p>React</p> <p>React</p>
</div> </div>
<div className="field_value"> <div className="field_value">
{React.version ?? "Unknown"} {React.version ?? "Unknown"}
</div> </div>
</div> </div>
<div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdInfo /> <Icons.MdInfo />
</div> </div>
<p>Engine</p> <p>Engine</p>
</div> </div>
<div className="field_value"> <div className="field_value">
{app.__version ?? "Unknown"} {app.__version ?? "Unknown"}
</div> </div>
</div> </div>
<div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdInfo /> <Icons.MdInfo />
</div> </div>
<p>Comty.js</p> <p>Comty.js</p>
</div> </div>
<div className="field_value"> <div className="field_value">
{__comty_shared_state.version ?? "Unknown"} {__comty_shared_state.version ?? "Unknown"}
</div> </div>
</div> </div>
{ {capInfo && (
capInfo && <div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdInfo /> <Icons.MdInfo />
</div> </div>
<p>App ID</p> <p>App ID</p>
</div> </div>
<div className="field_value"> <div className="field_value">{capInfo.id}</div>
{capInfo.id} </div>
</div> )}
</div>
}
{ {capInfo && (
capInfo && <div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdInfo /> <Icons.MdInfo />
</div> </div>
<p>App Build</p> <p>App Build</p>
</div> </div>
<div className="field_value"> <div className="field_value">{capInfo.build}</div>
{capInfo.build} </div>
</div> )}
</div>
}
{ {capInfo && (
capInfo && <div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdInfo /> <Icons.MdInfo />
</div> </div>
<p>App Version</p> <p>App Version</p>
</div> </div>
<div className="field_value"> <div className="field_value">{capInfo.version}</div>
{capInfo.version} </div>
</div> )}
</div>
}
<div className="inline_field"> <div className="inline_field">
<div className="field_header"> <div className="field_header">
<div className="field_icon"> <div className="field_icon">
<Icons.MdInfo /> <Icons.MdInfo />
</div> </div>
<p>View Open Source Licenses</p> <p>View Open Source Licenses</p>
</div> </div>
<div className="field_value"> <div className="field_value">
<antd.Button <antd.Button
icon={<Icons.MdOpenInNew />} icon={<Icons.MdOpenInNew />}
onClick={() => app.location.push("/licenses")} onClick={() => app.location.push("/licenses")}
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
} )
},
} }

View File

@ -11,70 +11,46 @@ const oneYearInSeconds = 60 * 60 * 24 * 365
const sslDirPath = path.join(__dirname, ".ssl") const sslDirPath = path.join(__dirname, ".ssl")
const config = { const config = {
plugins: [ plugins: [react()],
react(), resolve: {
], alias: aliases,
resolve: { },
alias: aliases, server: {
}, host: "0.0.0.0",
server: { port: 8000,
host: "0.0.0.0", fs: {
port: 8000, allow: ["..", "../../"],
fs: { },
allow: ["..", "../../"], headers: {
}, "Strict-Transport-Security": `max-age=${oneYearInSeconds}`,
headers: { },
"Strict-Transport-Security": `max-age=${oneYearInSeconds}` proxy: {
}, "/api": {
proxy: { target: backendUri,
"/api": { rewrite: (path) => path.replace(/^\/api/, ""),
target: backendUri, hostRewrite: true,
rewrite: (path) => path.replace(/^\/api/, ""), changeOrigin: true,
hostRewrite: true, xfwd: true,
changeOrigin: true, ws: true,
xfwd: true, toProxy: true,
ws: true, secure: false,
toProxy: true, },
secure: false, },
} },
} css: {
}, preprocessorOptions: {
css: { less: {
preprocessorOptions: { javascriptEnabled: true,
less: { },
javascriptEnabled: true, },
} },
}
},
optimizeDeps: {
esbuildOptions: {
target: "esnext"
}
},
build: {
target: "esnext",
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules")) {
return id.toString().split("node_modules/")[1].split("/")[0].toString()
}
}
}
}
},
esbuild: {
supported: {
"top-level-await": true
},
}
} }
if (fs.existsSync(sslDirPath)) { if (fs.existsSync(sslDirPath)) {
config.server.https = { config.server.https = {
key: path.join(__dirname, ".ssl", "privkey.pem"), key: path.join(__dirname, ".ssl", "privkey.pem"),
cert: path.join(__dirname, ".ssl", "cert.pem"), cert: path.join(__dirname, ".ssl", "cert.pem"),
} }
} }
export default defineConfig(config) export default defineConfig(config)