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"
|
import "./index.less"
|
||||||
|
|
||||||
|
console.log(MusicModel.deleteRelease)
|
||||||
|
|
||||||
const ReleaseEditor = (props) => {
|
const ReleaseEditor = (props) => {
|
||||||
const { release_id } = props
|
const { release_id } = props
|
||||||
|
|
||||||
const basicInfoRef = React.useRef()
|
const basicInfoRef = React.useRef()
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
const [submitError, setSubmitError] = React.useState(null)
|
||||||
|
|
||||||
const [loading, setLoading] = React.useState(true)
|
const [loading, setLoading] = React.useState(true)
|
||||||
const [loadError, setLoadError] = React.useState(null)
|
const [loadError, setLoadError] = React.useState(null)
|
||||||
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState)
|
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState)
|
||||||
const [selectedTab, setSelectedTab] = React.useState("info")
|
const [selectedTab, setSelectedTab] = React.useState("info")
|
||||||
|
const [customPage, setCustomPage] = React.useState(null)
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -42,7 +48,55 @@ const ReleaseEditor = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
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) {
|
async function onFinish(values) {
|
||||||
@ -71,68 +125,112 @@ const ReleaseEditor = (props) => {
|
|||||||
|
|
||||||
const Tab = Tabs.find(({ key }) => key === selectedTab)
|
const Tab = Tabs.find(({ key }) => key === selectedTab)
|
||||||
|
|
||||||
return <ReleaseEditorStateContext.Provider value={globalState}>
|
return <ReleaseEditorStateContext.Provider
|
||||||
|
value={{
|
||||||
|
...globalState,
|
||||||
|
setCustomPage,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="music-studio-release-editor">
|
<div className="music-studio-release-editor">
|
||||||
<div className="music-studio-release-editor-menu">
|
{
|
||||||
<antd.Menu
|
customPage && <div className="music-studio-release-editor-custom-page">
|
||||||
onClick={(e) => setSelectedTab(e.key)}
|
|
||||||
selectedKeys={[selectedTab]}
|
|
||||||
items={Tabs}
|
|
||||||
mode="vertical"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="music-studio-release-editor-menu-actions">
|
|
||||||
<antd.Button
|
|
||||||
type="primary"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
icon={<Icons.Save />}
|
|
||||||
disabled={loading || !canFinish()}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</antd.Button>
|
|
||||||
|
|
||||||
{
|
{
|
||||||
release_id !== "new" ? <antd.Button
|
customPage.header && <div className="music-studio-release-editor-custom-page-header">
|
||||||
icon={<Icons.IoMdTrash />}
|
<div className="music-studio-release-editor-custom-page-header-title">
|
||||||
disabled={loading}
|
<antd.Button
|
||||||
>
|
icon={<Icons.IoIosArrowBack />}
|
||||||
Delete
|
onClick={() => setCustomPage(null)}
|
||||||
</antd.Button> : null
|
/>
|
||||||
|
|
||||||
|
<h2>{customPage.header}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
customPage.props?.onSave && <antd.Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Icons.Save />}
|
||||||
|
onClick={() => customPage.props.onSave()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</antd.Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
release_id !== "new" ? <antd.Button
|
React.cloneElement(customPage.content, {
|
||||||
icon={<Icons.MdLink />}
|
...customPage.props,
|
||||||
onClick={() => app.location.push(`/music/release/${globalState._id}`)}
|
close: () => setCustomPage(null),
|
||||||
>
|
})
|
||||||
Go to release
|
|
||||||
</antd.Button> : null
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
{
|
||||||
|
!customPage && <>
|
||||||
|
<div className="music-studio-release-editor-menu">
|
||||||
|
<antd.Menu
|
||||||
|
onClick={(e) => setSelectedTab(e.key)}
|
||||||
|
selectedKeys={[selectedTab]}
|
||||||
|
items={Tabs}
|
||||||
|
mode="vertical"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="music-studio-release-editor-content">
|
<div className="music-studio-release-editor-menu-actions">
|
||||||
{
|
<antd.Button
|
||||||
!Tab && <antd.Result
|
type="primary"
|
||||||
status="error"
|
onClick={handleSubmit}
|
||||||
title="Error"
|
icon={<Icons.Save />}
|
||||||
subTitle="Tab not found"
|
disabled={submitting || loading || !canFinish()}
|
||||||
/>
|
loading={submitting}
|
||||||
}
|
>
|
||||||
{
|
Save
|
||||||
Tab && React.createElement(Tab.render, {
|
</antd.Button>
|
||||||
release: globalState,
|
|
||||||
onFinish: onFinish,
|
|
||||||
|
|
||||||
state: globalState,
|
{
|
||||||
setState: setGlobalState,
|
release_id !== "new" ? <antd.Button
|
||||||
|
icon={<Icons.IoMdTrash />}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</antd.Button> : null
|
||||||
|
}
|
||||||
|
|
||||||
references: {
|
{
|
||||||
basic: basicInfoRef
|
release_id !== "new" ? <antd.Button
|
||||||
|
icon={<Icons.MdLink />}
|
||||||
|
onClick={() => app.location.push(`/music/release/${globalState._id}`)}
|
||||||
|
>
|
||||||
|
Go to release
|
||||||
|
</antd.Button> : null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="music-studio-release-editor-content">
|
||||||
|
{
|
||||||
|
!Tab && <antd.Result
|
||||||
|
status="error"
|
||||||
|
title="Error"
|
||||||
|
subTitle="Tab not found"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
})
|
{
|
||||||
}
|
Tab && React.createElement(Tab.render, {
|
||||||
</div>
|
release: globalState,
|
||||||
|
onFinish: onFinish,
|
||||||
|
|
||||||
|
state: globalState,
|
||||||
|
setState: setGlobalState,
|
||||||
|
|
||||||
|
references: {
|
||||||
|
basic: basicInfoRef
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ReleaseEditorStateContext.Provider>
|
</ReleaseEditorStateContext.Provider>
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,53 @@
|
|||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
padding: 20px;
|
//padding: 20px;
|
||||||
|
|
||||||
gap: 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 {
|
.music-studio-release-editor-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -56,7 +99,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,29 +7,27 @@ import Image from "@components/Image"
|
|||||||
import { Icons } from "@components/Icons"
|
import { Icons } from "@components/Icons"
|
||||||
import TrackEditor from "@components/MusicStudio/TrackEditor"
|
import TrackEditor from "@components/MusicStudio/TrackEditor"
|
||||||
|
|
||||||
|
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const TrackListItem = (props) => {
|
const TrackListItem = (props) => {
|
||||||
|
const context = React.useContext(ReleaseEditorStateContext)
|
||||||
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [error, setError] = React.useState(null)
|
const [error, setError] = React.useState(null)
|
||||||
|
|
||||||
const { track } = props
|
const { track } = props
|
||||||
|
|
||||||
async function onClickEditTrack() {
|
async function onClickEditTrack() {
|
||||||
app.layout.drawer.open("track_editor", TrackEditor, {
|
context.setCustomPage({
|
||||||
type: "drawer",
|
header: "Track Editor",
|
||||||
|
content: <TrackEditor track={track} />,
|
||||||
props: {
|
props: {
|
||||||
width: "600px",
|
|
||||||
headerStyle: {
|
|
||||||
display: "none",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
componentProps: {
|
|
||||||
track,
|
|
||||||
onSave: (newTrackData) => {
|
onSave: (newTrackData) => {
|
||||||
console.log("Saving track", newTrackData)
|
console.log("Saving track", newTrackData)
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,9 +55,9 @@ const TrackListItem = (props) => {
|
|||||||
|
|
||||||
<Image
|
<Image
|
||||||
src={track.cover}
|
src={track.cover}
|
||||||
|
height={25}
|
||||||
|
width={25}
|
||||||
style={{
|
style={{
|
||||||
width: 25,
|
|
||||||
height: 25,
|
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
@ -32,9 +32,35 @@ class TrackManifest {
|
|||||||
constructor(params) {
|
constructor(params) {
|
||||||
this.params = 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
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uid = null
|
||||||
|
|
||||||
cover = "https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
cover = "https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
||||||
|
|
||||||
title = "Untitled"
|
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) => {
|
addTrackUIDToPendingUploads = (uid) => {
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
return false
|
return false
|
||||||
@ -160,24 +205,28 @@ class TracksManager extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleUploaderStateChange = async (change) => {
|
handleUploaderStateChange = async (change) => {
|
||||||
|
const uid = change.file.uid
|
||||||
|
|
||||||
switch (change.file.status) {
|
switch (change.file.status) {
|
||||||
case "uploading": {
|
case "uploading": {
|
||||||
this.addTrackUIDToPendingUploads(change.file.uid)
|
this.addTrackUIDToPendingUploads(uid)
|
||||||
|
|
||||||
const trackManifest = new TrackManifest({
|
const trackManifest = new TrackManifest({
|
||||||
uid: change.file.uid,
|
uid: uid,
|
||||||
file: change.file,
|
file: change.file,
|
||||||
})
|
})
|
||||||
|
|
||||||
await trackManifest.initialize()
|
|
||||||
|
|
||||||
this.addTrackToList(trackManifest)
|
this.addTrackToList(trackManifest)
|
||||||
|
|
||||||
|
const trackData = await trackManifest.initialize()
|
||||||
|
|
||||||
|
this.modifyTrackByUid(uid, trackData)
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "done": {
|
case "done": {
|
||||||
// remove pending file
|
// remove pending file
|
||||||
this.removeTrackUIDFromPendingUploads(change.file.uid)
|
this.removeTrackUIDFromPendingUploads(uid)
|
||||||
|
|
||||||
const trackIndex = this.state.list.findIndex((item) => item.uid === uid)
|
const trackIndex = this.state.list.findIndex((item) => item.uid === uid)
|
||||||
|
|
||||||
@ -187,24 +236,22 @@ class TracksManager extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update track list
|
// update track list
|
||||||
this.setState((state) => {
|
await this.modifyTrackByUid(uid, {
|
||||||
state.list[trackIndex].source = change.file.response.url
|
source: change.file.response.url
|
||||||
|
|
||||||
return state
|
|
||||||
})
|
})
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "error": {
|
case "error": {
|
||||||
// remove pending file
|
// remove pending file
|
||||||
this.removeTrackUIDFromPendingUploads(change.file.uid)
|
this.removeTrackUIDFromPendingUploads(uid)
|
||||||
|
|
||||||
// remove from tracklist
|
// remove from tracklist
|
||||||
await this.removeTrackByUid(change.file.uid)
|
await this.removeTrackByUid(uid)
|
||||||
}
|
}
|
||||||
case "removed": {
|
case "removed": {
|
||||||
// stop upload & delete from pending list and tracklist
|
// stop upload & delete from pending list and tracklist
|
||||||
await this.removeTrackByUid(change.file.uid)
|
await this.removeTrackByUid(uid)
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
break
|
break
|
||||||
@ -253,6 +300,7 @@ class TracksManager extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
console.log(`Tracks List >`, this.state.list)
|
||||||
return <div className="music-studio-release-editor-tracks">
|
return <div className="music-studio-release-editor-tracks">
|
||||||
<antd.Upload
|
<antd.Upload
|
||||||
className="music-studio-tracks-uploader"
|
className="music-studio-tracks-uploader"
|
||||||
@ -267,7 +315,9 @@ class TracksManager extends React.Component {
|
|||||||
<UploadHint /> : <antd.Button
|
<UploadHint /> : <antd.Button
|
||||||
className="uploadMoreButton"
|
className="uploadMoreButton"
|
||||||
icon={<Icons.Plus />}
|
icon={<Icons.Plus />}
|
||||||
/>
|
>
|
||||||
|
Add another
|
||||||
|
</antd.Button>
|
||||||
}
|
}
|
||||||
</antd.Upload>
|
</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 {
|
.music-studio-release-editor-tracks-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -7,9 +7,12 @@ import { Icons } from "@components/Icons"
|
|||||||
import LyricsEditor from "@components/MusicStudio/LyricsEditor"
|
import LyricsEditor from "@components/MusicStudio/LyricsEditor"
|
||||||
import VideoEditor from "@components/MusicStudio/VideoEditor"
|
import VideoEditor from "@components/MusicStudio/VideoEditor"
|
||||||
|
|
||||||
|
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const TrackEditor = (props) => {
|
const TrackEditor = (props) => {
|
||||||
|
const context = React.useContext(ReleaseEditorStateContext)
|
||||||
const [track, setTrack] = React.useState(props.track ?? {})
|
const [track, setTrack] = React.useState(props.track ?? {})
|
||||||
|
|
||||||
async function handleChange(key, value) {
|
async function handleChange(key, value) {
|
||||||
@ -22,38 +25,26 @@ const TrackEditor = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openLyricsEditor() {
|
async function openLyricsEditor() {
|
||||||
app.layout.drawer.open("lyrics_editor", LyricsEditor, {
|
context.setCustomPage({
|
||||||
type: "drawer",
|
header: "Lyrics Editor",
|
||||||
|
content: <LyricsEditor track={track} />,
|
||||||
props: {
|
props: {
|
||||||
width: "600px",
|
onSave: () => {
|
||||||
headerStyle: {
|
console.log("Saved lyrics")
|
||||||
display: "none",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
componentProps: {
|
|
||||||
track,
|
|
||||||
onSave: (lyrics) => {
|
|
||||||
console.log("Saving lyrics for track >", lyrics)
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openVideoEditor() {
|
async function openVideoEditor() {
|
||||||
app.layout.drawer.open("video_editor", VideoEditor, {
|
context.setCustomPage({
|
||||||
type: "drawer",
|
header: "Video Editor",
|
||||||
|
content: <VideoEditor track={track} />,
|
||||||
props: {
|
props: {
|
||||||
width: "600px",
|
onSave: () => {
|
||||||
headerStyle: {
|
console.log("Saved video")
|
||||||
display: "none",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
componentProps: {
|
|
||||||
track,
|
|
||||||
onSave: (video) => {
|
|
||||||
console.log("Saving video for track", video)
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,7 +100,7 @@ const TrackEditor = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<antd.Input
|
<antd.Input
|
||||||
value={track.artists.join(", ")}
|
value={track.artists?.join(", ")}
|
||||||
placeholder="Artist"
|
placeholder="Artist"
|
||||||
onChange={(e) => handleChange("artist", e.target.value)}
|
onChange={(e) => handleChange("artist", e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -184,24 +175,6 @@ const TrackEditor = (props) => {
|
|||||||
Edit
|
Edit
|
||||||
</antd.Button>
|
</antd.Button>
|
||||||
</div>
|
</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>
|
</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: [],
|
list: [],
|
||||||
pendingUploads: [],
|
pendingUploads: [],
|
||||||
|
|
||||||
|
setCustomPage: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReleaseEditorStateContext = React.createContext(DefaultReleaseEditorState)
|
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 React from "react"
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
import { Motion, spring } from "react-motion"
|
import * as antd from "antd"
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
|
function transformTemplate({ x }) {
|
||||||
|
return `translateX(${x}px)`
|
||||||
|
}
|
||||||
|
|
||||||
export class Drawer extends React.Component {
|
export class Drawer extends React.Component {
|
||||||
options = this.props.options ?? {}
|
options = this.props.options ?? {}
|
||||||
|
|
||||||
@ -25,7 +30,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, {
|
||||||
delay: 500
|
transition: 150
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,37 +60,36 @@ export class Drawer extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const componentProps = {
|
const componentProps = {
|
||||||
...this.options.componentProps,
|
...this.options.props,
|
||||||
close: this.close,
|
close: this.close,
|
||||||
handleDone: this.handleDone,
|
handleDone: this.handleDone,
|
||||||
handleFail: this.handleFail,
|
handleFail: this.handleFail,
|
||||||
}
|
}
|
||||||
|
return <AnimatePresence>
|
||||||
return <Motion
|
{
|
||||||
key={this.props.id}
|
this.state.visible && <motion.div
|
||||||
style={{
|
|
||||||
x: spring(!this.state.visible ? 100 : 0),
|
|
||||||
opacity: spring(!this.state.visible ? 0 : 1),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ x, opacity }) => {
|
|
||||||
return <div
|
|
||||||
key={this.props.id}
|
key={this.props.id}
|
||||||
id={this.props.id}
|
id={this.props.id}
|
||||||
className="drawer"
|
className="drawer"
|
||||||
style={{
|
style={{
|
||||||
...this.options.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)
|
React.createElement(this.props.children, componentProps)
|
||||||
}
|
}
|
||||||
</div>
|
</motion.div>
|
||||||
}}
|
}
|
||||||
</Motion>
|
</AnimatePresence>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +103,6 @@ export default class DrawerController extends React.Component {
|
|||||||
drawers: [],
|
drawers: [],
|
||||||
|
|
||||||
maskVisible: false,
|
maskVisible: false,
|
||||||
maskRender: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.interface = {
|
this.interface = {
|
||||||
@ -125,10 +128,10 @@ export default class DrawerController extends React.Component {
|
|||||||
|
|
||||||
componentWillUpdate = (prevProps, prevState) => {
|
componentWillUpdate = (prevProps, prevState) => {
|
||||||
// is mask visible, hide sidebar with `app.layout.sidebar.toggleVisibility(false)`
|
// is mask visible, hide sidebar with `app.layout.sidebar.toggleVisibility(false)`
|
||||||
if (prevState.maskVisible !== this.state.maskVisible) {
|
if (app.layout.sidebar) {
|
||||||
app.layout.sidebar.toggleVisibility(false)
|
if (prevState.maskVisible !== this.state.maskVisible) {
|
||||||
} else if (prevState.maskRender !== this.state.maskRender) {
|
app.layout.sidebar.toggleVisibility(this.state.maskVisible)
|
||||||
app.layout.sidebar.toggleVisibility(true)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,30 +169,23 @@ export default class DrawerController extends React.Component {
|
|||||||
const lastDrawer = this.getLastDrawer()
|
const lastDrawer = this.getLastDrawer()
|
||||||
|
|
||||||
if (lastDrawer) {
|
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()
|
lastDrawer.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMaskVisibility = async (to) => {
|
toggleMaskVisibility = async (to) => {
|
||||||
to = to ?? !this.state.maskVisible
|
|
||||||
|
|
||||||
this.setState({
|
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) => {
|
open = (id, component, options) => {
|
||||||
@ -198,21 +194,26 @@ export default class DrawerController extends React.Component {
|
|||||||
const addresses = this.state.addresses ?? {}
|
const addresses = this.state.addresses ?? {}
|
||||||
|
|
||||||
const instance = {
|
const instance = {
|
||||||
id,
|
id: id,
|
||||||
key: id,
|
|
||||||
ref: React.createRef(),
|
ref: React.createRef(),
|
||||||
children: component,
|
children: component,
|
||||||
options,
|
options: options,
|
||||||
controller: this,
|
controller: this,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof addresses[id] === "undefined") {
|
if (typeof addresses[id] === "undefined") {
|
||||||
drawers.push(<Drawer {...instance} />)
|
drawers.push(<Drawer
|
||||||
|
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 {...instance} />
|
drawers[addresses[id]] = <Drawer
|
||||||
|
key={id}
|
||||||
|
{...instance}
|
||||||
|
/>
|
||||||
refs[id] = instance.ref
|
refs[id] = instance.ref
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,7 +226,7 @@ export default class DrawerController extends React.Component {
|
|||||||
this.toggleMaskVisibility(true)
|
this.toggleMaskVisibility(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
close = async (id, { delay = 0 }) => {
|
close = async (id, { transition = 0 } = {}) => {
|
||||||
let { addresses, drawers, refs } = this.state
|
let { addresses, drawers, refs } = this.state
|
||||||
|
|
||||||
const index = addresses[id]
|
const index = addresses[id]
|
||||||
@ -239,9 +240,9 @@ export default class DrawerController extends React.Component {
|
|||||||
this.toggleMaskVisibility(false)
|
this.toggleMaskVisibility(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delay > 0) {
|
if (transition > 0) {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
setTimeout(resolve, delay)
|
setTimeout(resolve, transition)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -267,22 +268,23 @@ export default class DrawerController extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <>
|
return <>
|
||||||
<Motion
|
<AnimatePresence>
|
||||||
style={{
|
{
|
||||||
opacity: spring(this.state.maskVisible ? 1 : 0),
|
this.state.maskVisible && <motion.div
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ opacity }) => {
|
|
||||||
return <div
|
|
||||||
className="drawers-mask"
|
className="drawers-mask"
|
||||||
onClick={() => this.closeLastDrawer()}
|
onClick={() => this.closeLastDrawer()}
|
||||||
style={{
|
initial={{
|
||||||
opacity,
|
opacity: 0,
|
||||||
display: this.state.maskRender ? "block" : "none",
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}}
|
}
|
||||||
</Motion>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classnames(
|
className={classnames(
|
||||||
@ -292,7 +294,9 @@ export default class DrawerController extends React.Component {
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{this.state.drawers}
|
<AnimatePresence>
|
||||||
|
{this.state.drawers}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
@ -36,8 +36,6 @@
|
|||||||
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
@ -63,4 +61,27 @@
|
|||||||
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: overlay;
|
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 React from "react"
|
||||||
import Modal from "./modal"
|
import Modal from "./modal"
|
||||||
|
import { Button } from "antd"
|
||||||
|
|
||||||
import useLayoutInterface from "@hooks/useLayoutInterface"
|
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 () => {
|
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(
|
function open(
|
||||||
id,
|
id,
|
||||||
render,
|
render,
|
||||||
@ -41,6 +109,7 @@ export default () => {
|
|||||||
useLayoutInterface("modal", {
|
useLayoutInterface("modal", {
|
||||||
open: open,
|
open: open,
|
||||||
close: close,
|
close: close,
|
||||||
|
confirm: confirm,
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
@ -3,7 +3,7 @@ import config from "@config"
|
|||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
import { Translation } from "react-i18next"
|
import { Translation } from "react-i18next"
|
||||||
import { Motion, spring } from "react-motion"
|
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 Drawer from "@layouts/components/drawer"
|
||||||
|
|
||||||
import { Icons, createIconRender } from "@components/Icons"
|
import { Icons, createIconRender } from "@components/Icons"
|
||||||
@ -491,6 +491,8 @@ export default class Sidebar extends React.Component {
|
|||||||
src={config.logo?.alt}
|
src={config.logo?.alt}
|
||||||
onClick={() => app.navigation.goMain()}
|
onClick={() => app.navigation.goMain()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Tag>Beta</Tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -98,9 +98,17 @@
|
|||||||
|
|
||||||
.app_sidebar_header_logo {
|
.app_sidebar_header_logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
--webkit-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,10 +123,14 @@ export default () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
app.layout.tools_bar.toggleVisibility(false)
|
if (app.layout.tools_bar) {
|
||||||
|
app.layout.tools_bar.toggleVisibility(false)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
app.layout.tools_bar.toggleVisibility(true)
|
if (app.layout.tools_bar) {
|
||||||
|
app.layout.tools_bar.toggleVisibility(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
@ -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));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.livestream_item {
|
.livestream_item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -72,9 +72,12 @@ export default {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="texts">
|
<div className="texts">
|
||||||
<h2>{config.app.siteName}</h2>
|
<div className="sitename-text">
|
||||||
|
<h2>{config.app.siteName}</h2>
|
||||||
|
<antd.Tag>Beta</antd.Tag>
|
||||||
|
</div>
|
||||||
<span>{config.author}</span>
|
<span>{config.author}</span>
|
||||||
<span> Licensed with {config.package?.license ?? "unlicensed"} </span>
|
<span>Licensed with {config.package?.license ?? "unlicensed"} </span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="versions">
|
<div className="versions">
|
||||||
|
@ -30,7 +30,17 @@
|
|||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
margin: 0;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
@ -45,6 +55,23 @@
|
|||||||
.texts {
|
.texts {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.sitename-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
|
@ -1,24 +1,233 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import * as antd from "antd"
|
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"
|
import "./index.less"
|
||||||
|
|
||||||
const useGetMainOrigin = () => {
|
const ServerKeyCreator = (props) => {
|
||||||
const [mainOrigin, setMainOrigin] = React.useState(null)
|
const [name, setName] = React.useState("")
|
||||||
|
const [access, setAccess] = React.useState(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
const [result, setResult] = React.useState(null)
|
||||||
const instance = app.cores.api.client()
|
const [error, setError] = React.useState(null)
|
||||||
|
|
||||||
if (instance) {
|
const canSubmit = () => {
|
||||||
setMainOrigin(instance.mainOrigin)
|
return name && access
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!canSubmit()) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
const result = await ServerKeysModel.createNewServerKey({
|
||||||
setMainOrigin(null)
|
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 {
|
export default {
|
||||||
@ -28,7 +237,29 @@ export default {
|
|||||||
group: "advanced",
|
group: "advanced",
|
||||||
render: () => {
|
render: () => {
|
||||||
const mainOrigin = useGetMainOrigin()
|
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">
|
return <div className="developer-settings">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@ -49,6 +280,7 @@ export default {
|
|||||||
|
|
||||||
<antd.Button
|
<antd.Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
onClick={onClickCreateNewKey}
|
||||||
>
|
>
|
||||||
Create new
|
Create new
|
||||||
</antd.Button>
|
</antd.Button>
|
||||||
@ -56,13 +288,34 @@ export default {
|
|||||||
|
|
||||||
<div className="api_keys_list">
|
<div className="api_keys_list">
|
||||||
{
|
{
|
||||||
keys.map((key) => {
|
L_Keys && <antd.Skeleton active />
|
||||||
return null
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
keys.length === 0 && <antd.Empty />
|
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}
|
||||||
|
/>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
{
|
||||||
|
R_Keys.length === 0 && <antd.Empty />
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -22,6 +22,44 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
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 {
|
.links {
|
||||||
@ -29,4 +67,23 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
gap: 5px;
|
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 MusicMetadata from "music-metadata"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
|
|
||||||
|
import ModifyTrack from "./modify"
|
||||||
|
|
||||||
export default async (payload = {}) => {
|
export default async (payload = {}) => {
|
||||||
requiredFields(["title", "source", "user_id"], payload)
|
requiredFields(["title", "source", "user_id"], payload)
|
||||||
|
|
||||||
let stream = null
|
let stream = null
|
||||||
let headers = null
|
let headers = null
|
||||||
|
|
||||||
|
if (typeof payload._id === "string") {
|
||||||
|
return await ModifyTrack(payload._id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sourceStream = await axios({
|
const sourceStream = await axios({
|
||||||
url: payload.source,
|
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 {
|
export default {
|
||||||
middlewares: ["withAuthentication"],
|
middlewares: ["withAuthentication"],
|
||||||
fn: async (req) => {
|
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)
|
requiredFields(["title", "source"], req.body)
|
||||||
|
|
||||||
const track = await TrackClass.create({
|
const track = await TrackClass.create({
|
||||||
|
Loading…
x
Reference in New Issue
Block a user