mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 10:34:17 +00:00
merge from local
This commit is contained in:
parent
b2dbc3cc9c
commit
03badcbfd9
@ -11,15 +11,21 @@ import Tabs from "./tabs"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
console.log(MusicModel.deleteRelease)
|
||||
|
||||
const ReleaseEditor = (props) => {
|
||||
const { release_id } = props
|
||||
|
||||
const basicInfoRef = React.useRef()
|
||||
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [submitError, setSubmitError] = React.useState(null)
|
||||
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [loadError, setLoadError] = React.useState(null)
|
||||
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState)
|
||||
const [selectedTab, setSelectedTab] = React.useState("info")
|
||||
const [customPage, setCustomPage] = React.useState(null)
|
||||
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
@ -42,7 +48,55 @@ const ReleaseEditor = (props) => {
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
console.log("Submit >", globalState)
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
|
||||
try {
|
||||
// first sumbit tracks
|
||||
console.time("submit:tracks:")
|
||||
const tracks = await MusicModel.putTrack({
|
||||
list: globalState.list,
|
||||
})
|
||||
console.timeEnd("submit:tracks:")
|
||||
|
||||
// then submit release
|
||||
console.time("submit:release:")
|
||||
await MusicModel.putRelease({
|
||||
_id: globalState._id,
|
||||
title: globalState.title,
|
||||
description: globalState.description,
|
||||
public: globalState.public,
|
||||
cover: globalState.cover,
|
||||
explicit: globalState.explicit,
|
||||
type: globalState.type,
|
||||
list: tracks.list,
|
||||
})
|
||||
console.timeEnd("submit:release:")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.message.error(error.message)
|
||||
|
||||
setSubmitError(error)
|
||||
setSubmitting(false)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
app.message.success("Release saved")
|
||||
|
||||
return release
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
app.layout.modal.confirm({
|
||||
headerText: "Are you sure you want to delete this release?",
|
||||
descriptionText: "This action cannot be undone.",
|
||||
onConfirm: async () => {
|
||||
await MusicModel.deleteRelease(globalState._id)
|
||||
app.location.push(window.location.pathname.split("/").slice(0, -1).join("/"))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function onFinish(values) {
|
||||
@ -71,8 +125,48 @@ const ReleaseEditor = (props) => {
|
||||
|
||||
const Tab = Tabs.find(({ key }) => key === selectedTab)
|
||||
|
||||
return <ReleaseEditorStateContext.Provider value={globalState}>
|
||||
return <ReleaseEditorStateContext.Provider
|
||||
value={{
|
||||
...globalState,
|
||||
setCustomPage,
|
||||
}}
|
||||
>
|
||||
<div className="music-studio-release-editor">
|
||||
{
|
||||
customPage && <div className="music-studio-release-editor-custom-page">
|
||||
{
|
||||
customPage.header && <div className="music-studio-release-editor-custom-page-header">
|
||||
<div className="music-studio-release-editor-custom-page-header-title">
|
||||
<antd.Button
|
||||
icon={<Icons.IoIosArrowBack />}
|
||||
onClick={() => setCustomPage(null)}
|
||||
/>
|
||||
|
||||
<h2>{customPage.header}</h2>
|
||||
</div>
|
||||
|
||||
{
|
||||
customPage.props?.onSave && <antd.Button
|
||||
type="primary"
|
||||
icon={<Icons.Save />}
|
||||
onClick={() => customPage.props.onSave()}
|
||||
>
|
||||
Save
|
||||
</antd.Button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
React.cloneElement(customPage.content, {
|
||||
...customPage.props,
|
||||
close: () => setCustomPage(null),
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{
|
||||
!customPage && <>
|
||||
<div className="music-studio-release-editor-menu">
|
||||
<antd.Menu
|
||||
onClick={(e) => setSelectedTab(e.key)}
|
||||
@ -86,7 +180,8 @@ const ReleaseEditor = (props) => {
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
icon={<Icons.Save />}
|
||||
disabled={loading || !canFinish()}
|
||||
disabled={submitting || loading || !canFinish()}
|
||||
loading={submitting}
|
||||
>
|
||||
Save
|
||||
</antd.Button>
|
||||
@ -95,6 +190,7 @@ const ReleaseEditor = (props) => {
|
||||
release_id !== "new" ? <antd.Button
|
||||
icon={<Icons.IoMdTrash />}
|
||||
disabled={loading}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</antd.Button> : null
|
||||
@ -133,6 +229,8 @@ const ReleaseEditor = (props) => {
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</ReleaseEditorStateContext.Provider>
|
||||
}
|
||||
|
@ -4,10 +4,53 @@
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: 20px;
|
||||
//padding: 20px;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.music-studio-release-editor-custom-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.music-studio-release-editor-custom-page-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
padding: 10px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.music-studio-release-editor-custom-page-header-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-studio-release-editor-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -7,29 +7,27 @@ import Image from "@components/Image"
|
||||
import { Icons } from "@components/Icons"
|
||||
import TrackEditor from "@components/MusicStudio/TrackEditor"
|
||||
|
||||
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const TrackListItem = (props) => {
|
||||
const context = React.useContext(ReleaseEditorStateContext)
|
||||
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState(null)
|
||||
|
||||
const { track } = props
|
||||
|
||||
async function onClickEditTrack() {
|
||||
app.layout.drawer.open("track_editor", TrackEditor, {
|
||||
type: "drawer",
|
||||
context.setCustomPage({
|
||||
header: "Track Editor",
|
||||
content: <TrackEditor track={track} />,
|
||||
props: {
|
||||
width: "600px",
|
||||
headerStyle: {
|
||||
display: "none",
|
||||
}
|
||||
},
|
||||
componentProps: {
|
||||
track,
|
||||
onSave: (newTrackData) => {
|
||||
console.log("Saving track", newTrackData)
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -57,9 +55,9 @@ const TrackListItem = (props) => {
|
||||
|
||||
<Image
|
||||
src={track.cover}
|
||||
height={25}
|
||||
width={25}
|
||||
style={{
|
||||
width: 25,
|
||||
height: 25,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
|
@ -4,6 +4,8 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
padding: 10px;
|
||||
|
||||
gap: 10px;
|
||||
|
@ -32,9 +32,35 @@ class TrackManifest {
|
||||
constructor(params) {
|
||||
this.params = params
|
||||
|
||||
if (params.uid) {
|
||||
this.uid = params.uid
|
||||
}
|
||||
|
||||
if (params.cover) {
|
||||
this.cover = params.cover
|
||||
}
|
||||
|
||||
if (params.title) {
|
||||
this.title = params.title
|
||||
}
|
||||
|
||||
if (params.album) {
|
||||
this.album = params.album
|
||||
}
|
||||
|
||||
if (params.artist) {
|
||||
this.artist = params.artist
|
||||
}
|
||||
|
||||
if (params.source) {
|
||||
this.source = params.source
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
uid = null
|
||||
|
||||
cover = "https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
||||
|
||||
title = "Untitled"
|
||||
@ -137,6 +163,25 @@ class TracksManager extends React.Component {
|
||||
})
|
||||
}
|
||||
|
||||
modifyTrackByUid = (uid, track) => {
|
||||
if (!uid || !track) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.setState({
|
||||
list: this.state.list.map((item) => {
|
||||
if (item.uid === uid) {
|
||||
return {
|
||||
...item,
|
||||
...track,
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
addTrackUIDToPendingUploads = (uid) => {
|
||||
if (!uid) {
|
||||
return false
|
||||
@ -160,24 +205,28 @@ class TracksManager extends React.Component {
|
||||
}
|
||||
|
||||
handleUploaderStateChange = async (change) => {
|
||||
const uid = change.file.uid
|
||||
|
||||
switch (change.file.status) {
|
||||
case "uploading": {
|
||||
this.addTrackUIDToPendingUploads(change.file.uid)
|
||||
this.addTrackUIDToPendingUploads(uid)
|
||||
|
||||
const trackManifest = new TrackManifest({
|
||||
uid: change.file.uid,
|
||||
uid: uid,
|
||||
file: change.file,
|
||||
})
|
||||
|
||||
await trackManifest.initialize()
|
||||
|
||||
this.addTrackToList(trackManifest)
|
||||
|
||||
const trackData = await trackManifest.initialize()
|
||||
|
||||
this.modifyTrackByUid(uid, trackData)
|
||||
|
||||
break
|
||||
}
|
||||
case "done": {
|
||||
// remove pending file
|
||||
this.removeTrackUIDFromPendingUploads(change.file.uid)
|
||||
this.removeTrackUIDFromPendingUploads(uid)
|
||||
|
||||
const trackIndex = this.state.list.findIndex((item) => item.uid === uid)
|
||||
|
||||
@ -187,24 +236,22 @@ class TracksManager extends React.Component {
|
||||
}
|
||||
|
||||
// update track list
|
||||
this.setState((state) => {
|
||||
state.list[trackIndex].source = change.file.response.url
|
||||
|
||||
return state
|
||||
await this.modifyTrackByUid(uid, {
|
||||
source: change.file.response.url
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
case "error": {
|
||||
// remove pending file
|
||||
this.removeTrackUIDFromPendingUploads(change.file.uid)
|
||||
this.removeTrackUIDFromPendingUploads(uid)
|
||||
|
||||
// remove from tracklist
|
||||
await this.removeTrackByUid(change.file.uid)
|
||||
await this.removeTrackByUid(uid)
|
||||
}
|
||||
case "removed": {
|
||||
// stop upload & delete from pending list and tracklist
|
||||
await this.removeTrackByUid(change.file.uid)
|
||||
await this.removeTrackByUid(uid)
|
||||
}
|
||||
default: {
|
||||
break
|
||||
@ -253,6 +300,7 @@ class TracksManager extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log(`Tracks List >`, this.state.list)
|
||||
return <div className="music-studio-release-editor-tracks">
|
||||
<antd.Upload
|
||||
className="music-studio-tracks-uploader"
|
||||
@ -267,7 +315,9 @@ class TracksManager extends React.Component {
|
||||
<UploadHint /> : <antd.Button
|
||||
className="uploadMoreButton"
|
||||
icon={<Icons.Plus />}
|
||||
/>
|
||||
>
|
||||
Add another
|
||||
</antd.Button>
|
||||
}
|
||||
</antd.Upload>
|
||||
|
||||
|
@ -1,3 +1,30 @@
|
||||
.music-studio-release-editor-tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.music-studio-tracks-uploader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.ant-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-studio-release-editor-tracks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -7,9 +7,12 @@ import { Icons } from "@components/Icons"
|
||||
import LyricsEditor from "@components/MusicStudio/LyricsEditor"
|
||||
import VideoEditor from "@components/MusicStudio/VideoEditor"
|
||||
|
||||
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const TrackEditor = (props) => {
|
||||
const context = React.useContext(ReleaseEditorStateContext)
|
||||
const [track, setTrack] = React.useState(props.track ?? {})
|
||||
|
||||
async function handleChange(key, value) {
|
||||
@ -22,38 +25,26 @@ const TrackEditor = (props) => {
|
||||
}
|
||||
|
||||
async function openLyricsEditor() {
|
||||
app.layout.drawer.open("lyrics_editor", LyricsEditor, {
|
||||
type: "drawer",
|
||||
context.setCustomPage({
|
||||
header: "Lyrics Editor",
|
||||
content: <LyricsEditor track={track} />,
|
||||
props: {
|
||||
width: "600px",
|
||||
headerStyle: {
|
||||
display: "none",
|
||||
onSave: () => {
|
||||
console.log("Saved lyrics")
|
||||
},
|
||||
}
|
||||
},
|
||||
componentProps: {
|
||||
track,
|
||||
onSave: (lyrics) => {
|
||||
console.log("Saving lyrics for track >", lyrics)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function openVideoEditor() {
|
||||
app.layout.drawer.open("video_editor", VideoEditor, {
|
||||
type: "drawer",
|
||||
context.setCustomPage({
|
||||
header: "Video Editor",
|
||||
content: <VideoEditor track={track} />,
|
||||
props: {
|
||||
width: "600px",
|
||||
headerStyle: {
|
||||
display: "none",
|
||||
onSave: () => {
|
||||
console.log("Saved video")
|
||||
},
|
||||
}
|
||||
},
|
||||
componentProps: {
|
||||
track,
|
||||
onSave: (video) => {
|
||||
console.log("Saving video for track", video)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -109,7 +100,7 @@ const TrackEditor = (props) => {
|
||||
</div>
|
||||
|
||||
<antd.Input
|
||||
value={track.artists.join(", ")}
|
||||
value={track.artists?.join(", ")}
|
||||
placeholder="Artist"
|
||||
onChange={(e) => handleChange("artist", e.target.value)}
|
||||
/>
|
||||
@ -184,24 +175,6 @@ const TrackEditor = (props) => {
|
||||
Edit
|
||||
</antd.Button>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-actions">
|
||||
<antd.Button
|
||||
type="text"
|
||||
icon={<Icons.MdClose />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</antd.Button>
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
icon={<Icons.MdCheck />}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save
|
||||
</antd.Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
11
packages/app/src/components/SelectableText/index.jsx
Normal file
11
packages/app/src/components/SelectableText/index.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from "react"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const SelectableText = (props) => {
|
||||
return <p className="selectable-text">
|
||||
{props.children}
|
||||
</p>
|
||||
}
|
||||
|
||||
export default SelectableText
|
12
packages/app/src/components/SelectableText/index.less
Normal file
12
packages/app/src/components/SelectableText/index.less
Normal file
@ -0,0 +1,12 @@
|
||||
.selectable-text {
|
||||
user-select: text;
|
||||
--webkit-user-select: text;
|
||||
|
||||
background-color: rgba(var(--bg_color_3), 0.8);
|
||||
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
@ -8,6 +8,8 @@ export const DefaultReleaseEditorState = {
|
||||
|
||||
list: [],
|
||||
pendingUploads: [],
|
||||
|
||||
setCustomPage: () => {},
|
||||
}
|
||||
|
||||
export const ReleaseEditorStateContext = React.createContext(DefaultReleaseEditorState)
|
||||
|
21
packages/app/src/hooks/useGetMainOrigin/index.js
Normal file
21
packages/app/src/hooks/useGetMainOrigin/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
|
||||
const useGetMainOrigin = () => {
|
||||
const [mainOrigin, setMainOrigin] = React.useState(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const instance = app.cores.api.client()
|
||||
|
||||
if (instance) {
|
||||
setMainOrigin(instance.mainOrigin)
|
||||
}
|
||||
|
||||
return () => {
|
||||
setMainOrigin(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return mainOrigin
|
||||
}
|
||||
|
||||
export default useGetMainOrigin
|
@ -1,9 +1,14 @@
|
||||
import React from "react"
|
||||
import classnames from "classnames"
|
||||
import { Motion, spring } from "react-motion"
|
||||
import * as antd from "antd"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
function transformTemplate({ x }) {
|
||||
return `translateX(${x}px)`
|
||||
}
|
||||
|
||||
export class Drawer extends React.Component {
|
||||
options = this.props.options ?? {}
|
||||
|
||||
@ -25,7 +30,7 @@ export class Drawer extends React.Component {
|
||||
this.toggleVisibility(false)
|
||||
|
||||
this.props.controller.close(this.props.id, {
|
||||
delay: 500
|
||||
transition: 150
|
||||
})
|
||||
}
|
||||
|
||||
@ -55,37 +60,36 @@ export class Drawer extends React.Component {
|
||||
|
||||
render() {
|
||||
const componentProps = {
|
||||
...this.options.componentProps,
|
||||
...this.options.props,
|
||||
close: this.close,
|
||||
handleDone: this.handleDone,
|
||||
handleFail: this.handleFail,
|
||||
}
|
||||
|
||||
return <Motion
|
||||
key={this.props.id}
|
||||
style={{
|
||||
x: spring(!this.state.visible ? 100 : 0),
|
||||
opacity: spring(!this.state.visible ? 0 : 1),
|
||||
}}
|
||||
>
|
||||
{({ x, opacity }) => {
|
||||
return <div
|
||||
return <AnimatePresence>
|
||||
{
|
||||
this.state.visible && <motion.div
|
||||
key={this.props.id}
|
||||
id={this.props.id}
|
||||
className="drawer"
|
||||
style={{
|
||||
...this.options.style,
|
||||
transform: `translateX(-${x}%)`,
|
||||
opacity: opacity,
|
||||
}}
|
||||
transformTemplate={transformTemplate}
|
||||
animate={{
|
||||
x: [-100, 0],
|
||||
opacity: [0, 1],
|
||||
}}
|
||||
exit={{
|
||||
x: [0, -100],
|
||||
opacity: [1, 0],
|
||||
}}
|
||||
>
|
||||
|
||||
{
|
||||
React.createElement(this.props.children, componentProps)
|
||||
}
|
||||
</div>
|
||||
}}
|
||||
</Motion>
|
||||
</motion.div>
|
||||
}
|
||||
</AnimatePresence>
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,7 +103,6 @@ export default class DrawerController extends React.Component {
|
||||
drawers: [],
|
||||
|
||||
maskVisible: false,
|
||||
maskRender: false,
|
||||
}
|
||||
|
||||
this.interface = {
|
||||
@ -125,10 +128,10 @@ export default class DrawerController extends React.Component {
|
||||
|
||||
componentWillUpdate = (prevProps, prevState) => {
|
||||
// is mask visible, hide sidebar with `app.layout.sidebar.toggleVisibility(false)`
|
||||
if (app.layout.sidebar) {
|
||||
if (prevState.maskVisible !== this.state.maskVisible) {
|
||||
app.layout.sidebar.toggleVisibility(false)
|
||||
} else if (prevState.maskRender !== this.state.maskRender) {
|
||||
app.layout.sidebar.toggleVisibility(true)
|
||||
app.layout.sidebar.toggleVisibility(this.state.maskVisible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,30 +169,23 @@ export default class DrawerController extends React.Component {
|
||||
const lastDrawer = this.getLastDrawer()
|
||||
|
||||
if (lastDrawer) {
|
||||
if (app.layout.modal && lastDrawer.options.confirmOnOutsideClick) {
|
||||
return app.layout.modal.confirm({
|
||||
descriptionText: lastDrawer.options.confirmOnOutsideClickText ?? "Are you sure you want to close this drawer?",
|
||||
onConfirm: () => {
|
||||
lastDrawer.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
lastDrawer.close()
|
||||
}
|
||||
}
|
||||
|
||||
toggleMaskVisibility = async (to) => {
|
||||
to = to ?? !this.state.maskVisible
|
||||
|
||||
this.setState({
|
||||
maskVisible: to,
|
||||
maskVisible: to ?? !this.state.maskVisible,
|
||||
})
|
||||
|
||||
if (to === true) {
|
||||
this.setState({
|
||||
maskRender: true
|
||||
})
|
||||
} else {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
|
||||
this.setState({
|
||||
maskRender: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
open = (id, component, options) => {
|
||||
@ -198,21 +194,26 @@ export default class DrawerController extends React.Component {
|
||||
const addresses = this.state.addresses ?? {}
|
||||
|
||||
const instance = {
|
||||
id,
|
||||
key: id,
|
||||
id: id,
|
||||
ref: React.createRef(),
|
||||
children: component,
|
||||
options,
|
||||
options: options,
|
||||
controller: this,
|
||||
}
|
||||
|
||||
if (typeof addresses[id] === "undefined") {
|
||||
drawers.push(<Drawer {...instance} />)
|
||||
drawers.push(<Drawer
|
||||
key={id}
|
||||
{...instance}
|
||||
/>)
|
||||
|
||||
addresses[id] = drawers.length - 1
|
||||
refs[id] = instance.ref
|
||||
} else {
|
||||
drawers[addresses[id]] = <Drawer {...instance} />
|
||||
drawers[addresses[id]] = <Drawer
|
||||
key={id}
|
||||
{...instance}
|
||||
/>
|
||||
refs[id] = instance.ref
|
||||
}
|
||||
|
||||
@ -225,7 +226,7 @@ export default class DrawerController extends React.Component {
|
||||
this.toggleMaskVisibility(true)
|
||||
}
|
||||
|
||||
close = async (id, { delay = 0 }) => {
|
||||
close = async (id, { transition = 0 } = {}) => {
|
||||
let { addresses, drawers, refs } = this.state
|
||||
|
||||
const index = addresses[id]
|
||||
@ -239,9 +240,9 @@ export default class DrawerController extends React.Component {
|
||||
this.toggleMaskVisibility(false)
|
||||
}
|
||||
|
||||
if (delay > 0) {
|
||||
if (transition > 0) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delay)
|
||||
setTimeout(resolve, transition)
|
||||
})
|
||||
}
|
||||
|
||||
@ -267,22 +268,23 @@ export default class DrawerController extends React.Component {
|
||||
|
||||
render() {
|
||||
return <>
|
||||
<Motion
|
||||
style={{
|
||||
opacity: spring(this.state.maskVisible ? 1 : 0),
|
||||
}}
|
||||
>
|
||||
{({ opacity }) => {
|
||||
return <div
|
||||
<AnimatePresence>
|
||||
{
|
||||
this.state.maskVisible && <motion.div
|
||||
className="drawers-mask"
|
||||
onClick={() => this.closeLastDrawer()}
|
||||
style={{
|
||||
opacity,
|
||||
display: this.state.maskRender ? "block" : "none",
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
</Motion>
|
||||
}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
className={classnames(
|
||||
@ -292,7 +294,9 @@ export default class DrawerController extends React.Component {
|
||||
}
|
||||
)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{this.state.drawers}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
@ -36,8 +36,6 @@
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
|
||||
}
|
||||
|
||||
.drawer {
|
||||
@ -64,3 +62,26 @@
|
||||
overflow-x: hidden;
|
||||
overflow-y: overlay;
|
||||
}
|
||||
|
||||
.drawer_close_confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.drawer_close_confirm_content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.drawer_close_confirm_actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
@ -1,9 +1,77 @@
|
||||
import React from "react"
|
||||
import Modal from "./modal"
|
||||
import { Button } from "antd"
|
||||
|
||||
import useLayoutInterface from "@hooks/useLayoutInterface"
|
||||
|
||||
function ConfirmModal(props) {
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
|
||||
async function close({ confirm } = {}) {
|
||||
props.close()
|
||||
|
||||
if (typeof props.onClose === "function") {
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
if (confirm === true) {
|
||||
if (typeof props.onConfirm === "function") {
|
||||
if (props.onConfirm.constructor.name === "AsyncFunction") {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
await props.onConfirm()
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
if (typeof props.onCancel === "function") {
|
||||
props.onCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="drawer_close_confirm">
|
||||
<div className="drawer_close_confirm_content">
|
||||
<h1>{props.headerText ?? "Are you sure?"} Are you sure?</h1>
|
||||
|
||||
{
|
||||
props.descriptionText && <p>{props.descriptionText}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="drawer_close_confirm_actions">
|
||||
<Button
|
||||
onClick={() => close({ confirm: false })}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => close({ confirm: true })}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default () => {
|
||||
function confirm(options = {}) {
|
||||
open("confirm", ConfirmModal, {
|
||||
props: {
|
||||
onConfirm: options.onConfirm,
|
||||
onCancel: options.onCancel,
|
||||
onClose: options.onClose,
|
||||
|
||||
headerText: options.headerText,
|
||||
descriptionText: options.descriptionText,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function open(
|
||||
id,
|
||||
render,
|
||||
@ -41,6 +109,7 @@ export default () => {
|
||||
useLayoutInterface("modal", {
|
||||
open: open,
|
||||
close: close,
|
||||
confirm: confirm,
|
||||
})
|
||||
|
||||
return null
|
||||
|
@ -3,7 +3,7 @@ import config from "@config"
|
||||
import classnames from "classnames"
|
||||
import { Translation } from "react-i18next"
|
||||
import { Motion, spring } from "react-motion"
|
||||
import { Menu, Avatar, Dropdown } from "antd"
|
||||
import { Menu, Avatar, Dropdown, Tag } from "antd"
|
||||
import Drawer from "@layouts/components/drawer"
|
||||
|
||||
import { Icons, createIconRender } from "@components/Icons"
|
||||
@ -491,6 +491,8 @@ export default class Sidebar extends React.Component {
|
||||
src={config.logo?.alt}
|
||||
onClick={() => app.navigation.goMain()}
|
||||
/>
|
||||
|
||||
<Tag>Beta</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -98,9 +98,17 @@
|
||||
|
||||
.app_sidebar_header_logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
user-select: none;
|
||||
--webkit-user-select: none;
|
||||
|
@ -1,10 +0,0 @@
|
||||
import React from "react"
|
||||
import LiveChat from "@components/LiveChat"
|
||||
|
||||
const RoomChat = (props) => {
|
||||
return <LiveChat
|
||||
id={props.params["roomID"]}
|
||||
/>
|
||||
}
|
||||
|
||||
export default RoomChat
|
@ -123,11 +123,15 @@ export default () => {
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (app.layout.tools_bar) {
|
||||
app.layout.tools_bar.toggleVisibility(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (app.layout.tools_bar) {
|
||||
app.layout.tools_bar.toggleVisibility(true)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div className="settings_wrapper">
|
||||
|
@ -0,0 +1,80 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import { MdSave, MdEdit, MdClose } from "react-icons/md"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const EditableText = (props) => {
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [isEditing, setEditing] = React.useState(false)
|
||||
const [value, setValue] = React.useState(props.value)
|
||||
|
||||
async function handleSave(newValue) {
|
||||
setLoading(true)
|
||||
|
||||
if (typeof props.onSave === "function") {
|
||||
await props.onSave(newValue)
|
||||
|
||||
setEditing(false)
|
||||
setLoading(false)
|
||||
} else {
|
||||
setValue(newValue)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setValue(props.value)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(props.value)
|
||||
}, [props.value])
|
||||
|
||||
return <div
|
||||
style={props.style}
|
||||
className={classnames("editable-text", props.className)}
|
||||
>
|
||||
{
|
||||
!isEditing && <span
|
||||
onClick={() => setEditing(true)}
|
||||
className="editable-text-value"
|
||||
>
|
||||
<MdEdit />
|
||||
|
||||
{value}
|
||||
</span>
|
||||
}
|
||||
{
|
||||
isEditing && <div className="editable-text-input-container">
|
||||
<antd.Input
|
||||
className="editable-text-input"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onPressEnter={() => handleSave(value)}
|
||||
/>
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={() => handleSave(value)}
|
||||
icon={<MdSave />}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
/>
|
||||
<antd.Button
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
icon={<MdClose />}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default EditableText
|
@ -0,0 +1,44 @@
|
||||
.editable-text {
|
||||
border-radius: 12px;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
--fontSize: 14px;
|
||||
--fontWeight: normal;
|
||||
|
||||
.editable-text-value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 7px;
|
||||
|
||||
font-size: var(--fontSize);
|
||||
font-weight: var(--fontWeight);
|
||||
|
||||
svg {
|
||||
font-size: 1rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.editable-text-input-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.ant-input {
|
||||
background-color: transparent;
|
||||
font-family: "DM Mono", sans-serif;
|
||||
|
||||
font-size: var(--fontSize);
|
||||
font-weight: var(--fontWeight);
|
||||
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import { IoMdClipboard, IoMdEye, IoMdEyeOff } from "react-icons/io"
|
||||
|
||||
const HiddenText = (props) => {
|
||||
const [visible, setVisible] = React.useState(false)
|
||||
|
||||
function copyToClipboard() {
|
||||
try {
|
||||
navigator.clipboard.writeText(props.value)
|
||||
antd.message.success("Copied to clipboard")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
antd.message.error("Failed to copy to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
return <div
|
||||
style={{
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
...props.style
|
||||
}}
|
||||
>
|
||||
<antd.Button
|
||||
icon={<IoMdClipboard />}
|
||||
type="ghost"
|
||||
size="small"
|
||||
onClick={copyToClipboard}
|
||||
/>
|
||||
|
||||
<span>
|
||||
{
|
||||
visible ? props.value : "********"
|
||||
}
|
||||
</span>
|
||||
|
||||
<antd.Button
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0
|
||||
}}
|
||||
icon={visible ? <IoMdEye /> : <IoMdEyeOff />}
|
||||
type="ghost"
|
||||
size="small"
|
||||
onClick={() => setVisible(!visible)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default HiddenText
|
@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import useRequest from "comty.js/dist/hooks/useRequest"
|
||||
import Streaming from "@models/spectrum"
|
||||
|
||||
const ProfileConnection = (props) => {
|
||||
const [loading, result, error, repeat] = useRequest(Streaming.getConnectionStatus, {
|
||||
profile_id: props.profile_id
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
repeat({
|
||||
profile_id: props.profile_id
|
||||
})
|
||||
}, [props.profile_id])
|
||||
|
||||
if (error) {
|
||||
return <antd.Tag
|
||||
color="error"
|
||||
>
|
||||
<span>Disconnected</span>
|
||||
</antd.Tag>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <antd.Tag>
|
||||
<span>Loading</span>
|
||||
</antd.Tag>
|
||||
}
|
||||
|
||||
return <antd.Tag
|
||||
color="green"
|
||||
>
|
||||
<span>Connected</span>
|
||||
</antd.Tag>
|
||||
}
|
||||
|
||||
export default ProfileConnection
|
@ -0,0 +1,74 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import Streaming from "@models/spectrum"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const ProfileCreator = (props) => {
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [name, setName] = React.useState(props.editValue ?? null)
|
||||
|
||||
function handleChange(e) {
|
||||
setName(e.target.value.trim())
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setLoading(true)
|
||||
|
||||
if (props.editValue) {
|
||||
if (typeof props.onEdit === "function") {
|
||||
await props.onEdit(name)
|
||||
}
|
||||
} else {
|
||||
const result = await Streaming.createOrUpdateStream({ profile_name: name }).catch((error) => {
|
||||
console.error(error)
|
||||
app.message.error("Failed to create")
|
||||
return null
|
||||
})
|
||||
|
||||
if (result) {
|
||||
app.message.success("Created")
|
||||
app.eventBus.emit("app:new_profile", result)
|
||||
props.onCreate(result._id, result)
|
||||
}
|
||||
}
|
||||
|
||||
props.close()
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return <div
|
||||
className="profile-creator"
|
||||
>
|
||||
<antd.Input
|
||||
value={name}
|
||||
placeholder="Enter a profile name"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<div className="profile-creator-actions">
|
||||
<antd.Button
|
||||
onClick={props.close}
|
||||
>
|
||||
Cancel
|
||||
</antd.Button>
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
handleSubmit(name)
|
||||
}}
|
||||
disabled={!name || loading}
|
||||
loading={loading}
|
||||
>
|
||||
{
|
||||
props.editValue ? "Update" : "Create"
|
||||
}
|
||||
</antd.Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ProfileCreator
|
@ -0,0 +1,19 @@
|
||||
.profile-creator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.profile-creator-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
width: 100%;
|
||||
|
||||
justify-content: flex-end;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
@ -0,0 +1,351 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import Streaming from "@models/spectrum"
|
||||
|
||||
import EditableText from "../EditableText"
|
||||
import HiddenText from "../HiddenText"
|
||||
import ProfileCreator from "../ProfileCreator"
|
||||
|
||||
import { MdOutlineWifiTethering } from "react-icons/md"
|
||||
import { IoMdEyeOff } from "react-icons/io"
|
||||
import { GrStorage, GrConfigure } from "react-icons/gr"
|
||||
import { FiLink } from "react-icons/fi"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const ProfileData = (props) => {
|
||||
if (!props.profile_id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [fetching, setFetching] = React.useState(true)
|
||||
const [error, setError] = React.useState(null)
|
||||
const [profile, setProfile] = React.useState(null)
|
||||
|
||||
async function fetchData(profile_id) {
|
||||
setFetching(true)
|
||||
|
||||
const result = await Streaming.getProfile({ profile_id }).catch((error) => {
|
||||
console.error(error)
|
||||
setError(error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (result) {
|
||||
setProfile(result)
|
||||
}
|
||||
|
||||
setFetching(false)
|
||||
}
|
||||
|
||||
async function handleChange(key, value) {
|
||||
setLoading(true)
|
||||
|
||||
const result = await Streaming.createOrUpdateStream({
|
||||
[key]: value,
|
||||
_id: profile._id,
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
antd.message.error("Failed to update")
|
||||
return false
|
||||
})
|
||||
|
||||
if (result) {
|
||||
antd.message.success("Updated")
|
||||
setProfile(result)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
|
||||
const result = await Streaming.deleteProfile({ profile_id: profile._id }).catch((error) => {
|
||||
console.error(error)
|
||||
antd.message.error("Failed to delete")
|
||||
return false
|
||||
})
|
||||
|
||||
if (result) {
|
||||
antd.message.success("Deleted")
|
||||
app.eventBus.emit("app:profile_deleted", profile._id)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function handleEditName() {
|
||||
const modal = app.modal.info({
|
||||
title: "Edit name",
|
||||
content: <ProfileCreator
|
||||
close={() => modal.destroy()}
|
||||
editValue={profile.profile_name}
|
||||
onEdit={async (value) => {
|
||||
await handleChange("profile_name", value)
|
||||
app.eventBus.emit("app:profiles_updated", profile._id)
|
||||
}}
|
||||
/>,
|
||||
footer: null
|
||||
})
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchData(props.profile_id)
|
||||
}, [props.profile_id])
|
||||
|
||||
if (error) {
|
||||
return <antd.Result
|
||||
status="warning"
|
||||
title="Error"
|
||||
subTitle={error.message}
|
||||
extra={[
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={() => fetchData(props.profile_id)}
|
||||
>
|
||||
Retry
|
||||
</antd.Button>
|
||||
]}
|
||||
/>
|
||||
}
|
||||
|
||||
if (fetching) {
|
||||
return <antd.Skeleton
|
||||
active
|
||||
/>
|
||||
}
|
||||
|
||||
return <div className="profile-data">
|
||||
<div
|
||||
className="profile-data-header"
|
||||
>
|
||||
<img
|
||||
className="profile-data-header-image"
|
||||
src={profile.info?.thumbnail}
|
||||
/>
|
||||
<div className="profile-data-header-content">
|
||||
<EditableText
|
||||
value={profile.info?.title ?? "Untitled"}
|
||||
className="profile-data-header-title"
|
||||
style={{
|
||||
"--fontSize": "2rem",
|
||||
"--fontWeight": "800"
|
||||
}}
|
||||
onSave={(newValue) => {
|
||||
return handleChange("title", newValue)
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<EditableText
|
||||
value={profile.info?.description ?? "No description"}
|
||||
className="profile-data-header-description"
|
||||
style={{
|
||||
"--fontSize": "1rem",
|
||||
}}
|
||||
onSave={(newValue) => {
|
||||
return handleChange("description", newValue)
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-data-field">
|
||||
<div className="profile-data-field-header">
|
||||
<MdOutlineWifiTethering />
|
||||
<span>Server</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<span>Ingestion URL</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-value">
|
||||
<span>
|
||||
{profile.ingestion_url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<span>Stream Key</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-value">
|
||||
<HiddenText
|
||||
value={profile.stream_key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-data-field">
|
||||
<div className="profile-data-field-header">
|
||||
<GrConfigure />
|
||||
<span>Configuration</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<IoMdEyeOff />
|
||||
<span> Private Mode</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-description">
|
||||
<p>When this is enabled, only users with the livestream url can access the stream.</p>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-content">
|
||||
<antd.Switch
|
||||
checked={profile.options.private}
|
||||
loading={loading}
|
||||
onChange={(value) => handleChange("private", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-description">
|
||||
<p style={{ fontWeight: "bold" }}>Must restart the livestream to apply changes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<GrStorage />
|
||||
<span> DVR [beta]</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-description">
|
||||
<p>Save a copy of your stream with its entire duration. You can download this copy after finishing this livestream.</p>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-content">
|
||||
<antd.Switch
|
||||
disabled
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
profile.sources && <div className="profile-data-field">
|
||||
<div className="profile-data-field-header">
|
||||
<FiLink />
|
||||
<span>Media URL</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<span>HLS</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-description">
|
||||
<p>This protocol is highly compatible with a multitude of devices and services. Recommended for general use.</p>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-value">
|
||||
<span>
|
||||
{profile.sources.hls}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<span>FLV</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-description">
|
||||
<p>This protocol operates at better latency and quality than HLS, but is less compatible for most devices.</p>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-value">
|
||||
<span>
|
||||
{profile.sources.flv}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<span>RTSP [tcp]</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-description">
|
||||
<p>This protocol has the lowest possible latency and the best quality. A compatible player is required.</p>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-value">
|
||||
<span>
|
||||
{profile.sources.rtsp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<span>HTML Viewer</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-description">
|
||||
<p>Share a link to easily view your stream on any device with a web browser.</p>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-value">
|
||||
<span>
|
||||
{profile.sources.html}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="profile-data-field">
|
||||
<div className="profile-data-field-header">
|
||||
<span>Other</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<span>Delete profile</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-content">
|
||||
<antd.Popconfirm
|
||||
title="Delete the profile"
|
||||
description="Once deleted, the profile cannot be recovered."
|
||||
onConfirm={handleDelete}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
<antd.Button
|
||||
danger
|
||||
loading={loading}
|
||||
>
|
||||
Delete
|
||||
</antd.Button>
|
||||
</antd.Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field">
|
||||
<div className="key-value-field-key">
|
||||
<span>Change profile name</span>
|
||||
</div>
|
||||
|
||||
<div className="key-value-field-content">
|
||||
<antd.Button
|
||||
loading={loading}
|
||||
onClick={handleEditName}
|
||||
>
|
||||
Change
|
||||
</antd.Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ProfileData
|
@ -0,0 +1,66 @@
|
||||
.profile-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.profile-data-header {
|
||||
position: relative;
|
||||
|
||||
max-height: 200px;
|
||||
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
.profile-data-header-image {
|
||||
position: absolute;
|
||||
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
z-index: 10;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-data-header-content {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
z-index: 20;
|
||||
|
||||
padding: 30px 10px;
|
||||
|
||||
gap: 5px;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-data-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.profile-data-field-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
span {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import Streaming from "@models/spectrum"
|
||||
|
||||
const ProfileSelector = (props) => {
|
||||
const [loading, result, error, repeat] = app.cores.api.useRequest(Streaming.getOwnProfiles)
|
||||
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
|
||||
|
||||
function handleOnChange(value) {
|
||||
if (typeof props.onChange === "function") {
|
||||
props.onChange(value)
|
||||
}
|
||||
|
||||
setSelectedProfileId(value)
|
||||
}
|
||||
|
||||
const handleOnCreateNewProfile = async (data) => {
|
||||
await repeat()
|
||||
handleOnChange(data._id)
|
||||
}
|
||||
|
||||
const handleOnDeletedProfile = async (profile_id) => {
|
||||
await repeat()
|
||||
handleOnChange(result[0]._id)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
app.eventBus.on("app:new_profile", handleOnCreateNewProfile)
|
||||
app.eventBus.on("app:profile_deleted", handleOnDeletedProfile)
|
||||
app.eventBus.on("app:profiles_updated", repeat)
|
||||
|
||||
return () => {
|
||||
app.eventBus.off("app:new_profile", handleOnCreateNewProfile)
|
||||
app.eventBus.off("app:profile_deleted", handleOnDeletedProfile)
|
||||
app.eventBus.off("app:profiles_updated", repeat)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return <antd.Result
|
||||
status="warning"
|
||||
title="Error"
|
||||
subTitle={error.message}
|
||||
extra={[
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={repeat}
|
||||
>
|
||||
Retry
|
||||
</antd.Button>
|
||||
]}
|
||||
/>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <antd.Select
|
||||
disabled
|
||||
placeholder="Loading"
|
||||
style={props.style}
|
||||
className="profile-selector"
|
||||
/>
|
||||
}
|
||||
|
||||
return <antd.Select
|
||||
placeholder="Select a profile"
|
||||
value={selectedProfileId}
|
||||
onChange={handleOnChange}
|
||||
style={props.style}
|
||||
className="profile-selector"
|
||||
>
|
||||
{
|
||||
result.map((profile) => {
|
||||
return <antd.Select.Option
|
||||
key={profile._id}
|
||||
value={profile._id}
|
||||
>
|
||||
{profile.profile_name ?? String(profile._id)}
|
||||
</antd.Select.Option>
|
||||
})
|
||||
}
|
||||
</antd.Select>
|
||||
}
|
||||
|
||||
//const ProfileSelectorForwardRef = React.forwardRef(ProfileSelector)
|
||||
|
||||
export default ProfileSelector
|
64
packages/app/src/pages/studio/tv/index.jsx
Normal file
64
packages/app/src/pages/studio/tv/index.jsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import ProfileSelector from "./components/ProfileSelector"
|
||||
import ProfileData from "./components/ProfileData"
|
||||
import ProfileCreator from "./components/ProfileCreator"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const TVStudioPage = (props) => {
|
||||
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
|
||||
|
||||
function newProfileModal() {
|
||||
const modal = app.modal.info({
|
||||
title: "Create new profile",
|
||||
content: <ProfileCreator
|
||||
close={() => modal.destroy()}
|
||||
onCreate={(id, data) => {
|
||||
setSelectedProfileId(id)
|
||||
}}
|
||||
/>,
|
||||
footer: null
|
||||
})
|
||||
}
|
||||
|
||||
return <div className="main-page">
|
||||
<div className="main-page-actions">
|
||||
<ProfileSelector
|
||||
onChange={setSelectedProfileId}
|
||||
/>
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={newProfileModal}
|
||||
>
|
||||
Create new
|
||||
</antd.Button>
|
||||
</div>
|
||||
|
||||
{
|
||||
selectedProfileId && <ProfileData
|
||||
profile_id={selectedProfileId}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!selectedProfileId && <div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "70vh"
|
||||
}}
|
||||
>
|
||||
<h3>
|
||||
Select profile or create new
|
||||
</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default TVStudioPage
|
24
packages/app/src/pages/studio/tv/index.less
Normal file
24
packages/app/src/pages/studio/tv/index.less
Normal file
@ -0,0 +1,24 @@
|
||||
.main-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.main-page-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.profile-selector {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -26,6 +26,14 @@
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.livestream_item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -72,7 +72,10 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div className="texts">
|
||||
<div className="sitename-text">
|
||||
<h2>{config.app.siteName}</h2>
|
||||
<antd.Tag>Beta</antd.Tag>
|
||||
</div>
|
||||
<span>{config.author}</span>
|
||||
<span>Licensed with {config.package?.license ?? "unlicensed"} </span>
|
||||
</div>
|
||||
|
@ -30,7 +30,17 @@
|
||||
|
||||
align-items: center;
|
||||
|
||||
.ant-tag {
|
||||
margin: 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
width: 60px;
|
||||
height: 100%;
|
||||
|
||||
@ -45,6 +55,23 @@
|
||||
.texts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
gap: 6px;
|
||||
|
||||
.sitename-text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
|
@ -1,24 +1,233 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
import SelectableText from "@components/SelectableText"
|
||||
|
||||
import useGetMainOrigin from "@hooks/useGetMainOrigin"
|
||||
|
||||
import textToDownload from "@utils/textToDownload"
|
||||
|
||||
import ServerKeysModel from "@models/api"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const useGetMainOrigin = () => {
|
||||
const [mainOrigin, setMainOrigin] = React.useState(null)
|
||||
const ServerKeyCreator = (props) => {
|
||||
const [name, setName] = React.useState("")
|
||||
const [access, setAccess] = React.useState(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const instance = app.cores.api.client()
|
||||
const [result, setResult] = React.useState(null)
|
||||
const [error, setError] = React.useState(null)
|
||||
|
||||
if (instance) {
|
||||
setMainOrigin(instance.mainOrigin)
|
||||
const canSubmit = () => {
|
||||
return name && access
|
||||
}
|
||||
|
||||
return () => {
|
||||
setMainOrigin(null)
|
||||
const onSubmit = async () => {
|
||||
if (!canSubmit()) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await ServerKeysModel.createNewServerKey({
|
||||
name,
|
||||
access
|
||||
})
|
||||
|
||||
if (result) {
|
||||
setResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
const onRegenerate = async () => {
|
||||
app.layout.modal.confirm({
|
||||
headerText: "Regenerate secret token",
|
||||
descriptionText: "When a key is regenerated, the old secret token will be replaced with a new one. This action cannot be undone.",
|
||||
onConfirm: async () => {
|
||||
await ServerKeysModel.regenerateSecretToken(result.access_id)
|
||||
.then((data) => {
|
||||
app.message.info("Secret token regenerated")
|
||||
setResult(data)
|
||||
})
|
||||
.catch((error) => {
|
||||
app.message.error(error.message)
|
||||
setError(error.message)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onDelete = async () => {
|
||||
app.layout.modal.confirm({
|
||||
headerText: "Delete server key",
|
||||
descriptionText: "Deleting this server key will remove it from your account. This action cannot be undone.",
|
||||
onConfirm: async () => {
|
||||
await ServerKeysModel.deleteServerKey(result.access_id)
|
||||
.then(() => {
|
||||
app.message.info("Server key deleted")
|
||||
props.close()
|
||||
})
|
||||
.catch((error) => {
|
||||
app.message.error(error.message)
|
||||
setError(error.message)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function generateAuthJSON() {
|
||||
const data = {
|
||||
name: result.name,
|
||||
access: result.access,
|
||||
access_id: result.access_id,
|
||||
secret_token: result.secret_token
|
||||
}
|
||||
|
||||
await textToDownload(JSON.stringify(data), `comtyapi-${result.name}-auth.json`)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.data) {
|
||||
setResult(props.data)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return mainOrigin
|
||||
if (result) {
|
||||
return <div className="server-key-creator">
|
||||
<h1>Your server key</h1>
|
||||
|
||||
<p>Name: {result.name}</p>
|
||||
|
||||
<div className="server-key-creator-info">
|
||||
<span>Access ID:</span>
|
||||
<SelectableText>{result.access_id}</SelectableText>
|
||||
</div>
|
||||
|
||||
{
|
||||
result.secret_token && <div className="server-key-creator-info">
|
||||
<span>Secret:</span>
|
||||
<SelectableText>{result.secret_token}</SelectableText>
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
result.secret_token && <antd.Alert
|
||||
type="warning"
|
||||
message="Save these credentials in a safe place. You can't see them again."
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
result.secret_token && <antd.Button
|
||||
onClick={generateAuthJSON}
|
||||
type="primary"
|
||||
>
|
||||
Save JSON
|
||||
</antd.Button>
|
||||
}
|
||||
|
||||
{
|
||||
!result.secret_token && <antd.Button
|
||||
type="primary"
|
||||
onClick={() => onRegenerate()}
|
||||
>
|
||||
Regenerate secret
|
||||
</antd.Button>
|
||||
}
|
||||
|
||||
<antd.Button
|
||||
danger
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
Delete
|
||||
</antd.Button>
|
||||
|
||||
<antd.Button
|
||||
onClick={() => props.close()}
|
||||
>
|
||||
Ok
|
||||
</antd.Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <>
|
||||
<h1>Create a server key</h1>
|
||||
|
||||
<antd.Form
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
>
|
||||
<antd.Form.Item
|
||||
label="Name"
|
||||
name="name"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Name is required"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<antd.Input
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</antd.Form.Item>
|
||||
|
||||
<antd.Form.Item
|
||||
label="Access"
|
||||
name="access"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Access is required"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<antd.Select
|
||||
onChange={(e) => setAccess(e)}
|
||||
>
|
||||
<antd.Select.Option value="read">Read</antd.Select.Option>
|
||||
<antd.Select.Option value="write">Write</antd.Select.Option>
|
||||
<antd.Select.Option value="readWrite">Read/Write</antd.Select.Option>
|
||||
</antd.Select>
|
||||
</antd.Form.Item>
|
||||
|
||||
<antd.Form.Item>
|
||||
<antd.Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canSubmit()}
|
||||
>
|
||||
Create
|
||||
</antd.Button>
|
||||
</antd.Form.Item>
|
||||
|
||||
{error && <antd.Form.Item>
|
||||
<antd.Alert
|
||||
type="error"
|
||||
message={error}
|
||||
/>
|
||||
</antd.Form.Item>}
|
||||
|
||||
</antd.Form>
|
||||
</>
|
||||
}
|
||||
|
||||
const ServerKeyItem = (props) => {
|
||||
const { name, access_id } = props.data
|
||||
|
||||
return <div className="server-key-item">
|
||||
<div clas className="server-key-item-info">
|
||||
<p>{name}</p>
|
||||
<span>{access_id}</span>
|
||||
</div>
|
||||
|
||||
<div className="server-key-item-actions">
|
||||
<antd.Button
|
||||
size="small"
|
||||
icon={<Icons.TbEdit />}
|
||||
onClick={() => props.onEdit(props.data)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default {
|
||||
@ -28,7 +237,29 @@ export default {
|
||||
group: "advanced",
|
||||
render: () => {
|
||||
const mainOrigin = useGetMainOrigin()
|
||||
const [keys, setKeys] = React.useState([])
|
||||
|
||||
const [L_Keys, R_Keys, E_Keys, F_Keys] = app.cores.api.useRequest(ServerKeysModel.getMyServerKeys)
|
||||
|
||||
async function onClickCreateNewKey() {
|
||||
app.layout.drawer.open("server_key_creator", ServerKeyCreator, {
|
||||
onClose: () => {
|
||||
F_Keys()
|
||||
},
|
||||
confirmOnOutsideClick: true,
|
||||
confirmOnOutsideClickText: "All changes will be lost."
|
||||
})
|
||||
}
|
||||
|
||||
async function onClickEditKey(key) {
|
||||
app.layout.drawer.open("server_key_creator", ServerKeyCreator, {
|
||||
props: {
|
||||
data: key,
|
||||
},
|
||||
onClose: () => {
|
||||
F_Keys()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return <div className="developer-settings">
|
||||
<div className="card">
|
||||
@ -49,6 +280,7 @@ export default {
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={onClickCreateNewKey}
|
||||
>
|
||||
Create new
|
||||
</antd.Button>
|
||||
@ -56,13 +288,34 @@ export default {
|
||||
|
||||
<div className="api_keys_list">
|
||||
{
|
||||
keys.map((key) => {
|
||||
return null
|
||||
L_Keys && <antd.Skeleton active />
|
||||
}
|
||||
|
||||
{
|
||||
E_Keys && <antd.Result
|
||||
status="warning"
|
||||
title="Failed to retrieve keys"
|
||||
subTitle={E_Keys.message}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!E_Keys && !L_Keys && <>
|
||||
{
|
||||
R_Keys.map((data, index) => {
|
||||
return <ServerKeyItem
|
||||
key={index}
|
||||
data={data}
|
||||
onEdit={onClickEditKey}
|
||||
/>
|
||||
})
|
||||
}
|
||||
{
|
||||
keys.length === 0 && <antd.Empty />
|
||||
R_Keys.length === 0 && <antd.Empty />
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -22,6 +22,44 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.api_keys_list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.server-key-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
background-color: var(--background-color-primary);
|
||||
border-radius: 12px;
|
||||
|
||||
padding: 10px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.server-key-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.server-key-item-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
@ -30,3 +68,22 @@
|
||||
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.server-key-creator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.server-key-creator-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 5px;
|
||||
|
||||
.selectable-text {
|
||||
font-family: "DM Mono", monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
14
packages/app/src/utils/textToDownload/index.js
Normal file
14
packages/app/src/utils/textToDownload/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
export default (text, filename) => {
|
||||
const element = document.createElement("a")
|
||||
|
||||
const file = new Blob([text], { type: "text/plain" })
|
||||
|
||||
element.href = URL.createObjectURL(file)
|
||||
element.download = filename ?? "download.txt"
|
||||
|
||||
document.body.appendChild(element) // Required for this to work in FireFox
|
||||
|
||||
element.click()
|
||||
|
||||
document.body.removeChild(element)
|
||||
}
|
@ -3,12 +3,18 @@ import requiredFields from "@shared-utils/requiredFields"
|
||||
import MusicMetadata from "music-metadata"
|
||||
import axios from "axios"
|
||||
|
||||
import ModifyTrack from "./modify"
|
||||
|
||||
export default async (payload = {}) => {
|
||||
requiredFields(["title", "source", "user_id"], payload)
|
||||
|
||||
let stream = null
|
||||
let headers = null
|
||||
|
||||
if (typeof payload._id === "string") {
|
||||
return await ModifyTrack(payload._id, payload)
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceStream = await axios({
|
||||
url: payload.source,
|
||||
|
@ -0,0 +1,25 @@
|
||||
import { Track } from "@db_models"
|
||||
|
||||
export default async (track_id, payload) => {
|
||||
if (!track_id) {
|
||||
throw new OperationError(400, "Missing track_id")
|
||||
}
|
||||
|
||||
const track = await Track.findById(track_id)
|
||||
|
||||
if (!track) {
|
||||
throw new OperationError(404, "Track not found")
|
||||
}
|
||||
|
||||
if (track.publisher.user_id !== payload.user_id) {
|
||||
throw new PermissionError(403, "You dont have permission to edit this track")
|
||||
}
|
||||
|
||||
for (const field of Object.keys(payload)) {
|
||||
track[field] = payload[field]
|
||||
}
|
||||
|
||||
track.modified_at = Date.now()
|
||||
|
||||
return await track.save()
|
||||
}
|
@ -4,6 +4,25 @@ import TrackClass from "@classes/track"
|
||||
export default {
|
||||
middlewares: ["withAuthentication"],
|
||||
fn: async (req) => {
|
||||
if (Array.isArray(req.body.list)) {
|
||||
let results = []
|
||||
|
||||
for await (const item of req.body.list) {
|
||||
requiredFields(["title", "source"], item)
|
||||
|
||||
const track = await TrackClass.create({
|
||||
...item,
|
||||
user_id: req.auth.session.user_id,
|
||||
})
|
||||
|
||||
results.push(track)
|
||||
}
|
||||
|
||||
return {
|
||||
list: results
|
||||
}
|
||||
}
|
||||
|
||||
requiredFields(["title", "source"], req.body)
|
||||
|
||||
const track = await TrackClass.create({
|
||||
|
Loading…
x
Reference in New Issue
Block a user