diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx index 68405fdd..f32c4342 100644 --- a/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx +++ b/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx @@ -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,68 +125,112 @@ const ReleaseEditor = (props) => { const Tab = Tabs.find(({ key }) => key === selectedTab) - return + return
-
- setSelectedTab(e.key)} - selectedKeys={[selectedTab]} - items={Tabs} - mode="vertical" - /> - -
- } - disabled={loading || !canFinish()} - > - Save - - + { + customPage &&
{ - release_id !== "new" ? } - disabled={loading} - > - Delete - : null + customPage.header &&
+
+ } + onClick={() => setCustomPage(null)} + /> + +

{customPage.header}

+
+ + { + customPage.props?.onSave && } + onClick={() => customPage.props.onSave()} + > + Save + + } +
} { - release_id !== "new" ? } - onClick={() => app.location.push(`/music/release/${globalState._id}`)} - > - Go to release - : null + React.cloneElement(customPage.content, { + ...customPage.props, + close: () => setCustomPage(null), + }) }
-
+ } + { + !customPage && <> +
+ setSelectedTab(e.key)} + selectedKeys={[selectedTab]} + items={Tabs} + mode="vertical" + /> -
- { - !Tab && - } - { - Tab && React.createElement(Tab.render, { - release: globalState, - onFinish: onFinish, +
+ } + disabled={submitting || loading || !canFinish()} + loading={submitting} + > + Save + - state: globalState, - setState: setGlobalState, + { + release_id !== "new" ? } + disabled={loading} + onClick={handleDelete} + > + Delete + : null + } - references: { - basic: basicInfoRef + { + release_id !== "new" ? } + onClick={() => app.location.push(`/music/release/${globalState._id}`)} + > + Go to release + : null + } +
+
+ +
+ { + !Tab && } - }) - } -
+ { + Tab && React.createElement(Tab.render, { + release: globalState, + onFinish: onFinish, + + state: globalState, + setState: setGlobalState, + + references: { + basic: basicInfoRef + } + }) + } +
+ + }
} diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/index.less index 671d6882..80e4f075 100644 --- a/packages/app/src/components/MusicStudio/ReleaseEditor/index.less +++ b/packages/app/src/components/MusicStudio/ReleaseEditor/index.less @@ -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; @@ -56,7 +99,7 @@ flex-direction: column; gap: 10px; - + width: 100%; } } diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx index c2d8b095..14cb4f2b 100644 --- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx +++ b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.jsx @@ -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: , props: { - width: "600px", - headerStyle: { - display: "none", - } - }, - componentProps: { - track, onSave: (newTrackData) => { console.log("Saving track", newTrackData) }, - }, + } }) } @@ -57,9 +55,9 @@ const TrackListItem = (props) => { diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less index 70989cea..a701920c 100644 --- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less +++ b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/components/TrackListItem/index.less @@ -4,6 +4,8 @@ display: flex; flex-direction: row; + align-items: center; + padding: 10px; gap: 10px; diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx index 8b639e72..a026f7e7 100644 --- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx +++ b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.jsx @@ -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
: } - /> + > + Add another + } diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less index be2a2297..9ab8d7e2 100644 --- a/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less +++ b/packages/app/src/components/MusicStudio/ReleaseEditor/tabs/Tracks/index.less @@ -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; diff --git a/packages/app/src/components/MusicStudio/TrackEditor/index.jsx b/packages/app/src/components/MusicStudio/TrackEditor/index.jsx index ce290e28..84096206 100644 --- a/packages/app/src/components/MusicStudio/TrackEditor/index.jsx +++ b/packages/app/src/components/MusicStudio/TrackEditor/index.jsx @@ -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: , props: { - width: "600px", - headerStyle: { - display: "none", - } - }, - componentProps: { - track, - onSave: (lyrics) => { - console.log("Saving lyrics for track >", lyrics) + onSave: () => { + console.log("Saved lyrics") }, - }, + } }) } async function openVideoEditor() { - app.layout.drawer.open("video_editor", VideoEditor, { - type: "drawer", + context.setCustomPage({ + header: "Video Editor", + content: , props: { - width: "600px", - headerStyle: { - display: "none", - } - }, - componentProps: { - track, - onSave: (video) => { - console.log("Saving video for track", video) + onSave: () => { + console.log("Saved video") }, - }, + } }) } @@ -109,7 +100,7 @@ const TrackEditor = (props) => {
handleChange("artist", e.target.value)} /> @@ -184,24 +175,6 @@ const TrackEditor = (props) => { Edit
- -
- } - onClick={onClose} - > - Cancel - - - } - onClick={onSave} - > - Save - -
} diff --git a/packages/app/src/components/SelectableText/index.jsx b/packages/app/src/components/SelectableText/index.jsx new file mode 100644 index 00000000..8adb9012 --- /dev/null +++ b/packages/app/src/components/SelectableText/index.jsx @@ -0,0 +1,11 @@ +import React from "react" + +import "./index.less" + +const SelectableText = (props) => { + return

+ {props.children} +

+} + +export default SelectableText \ No newline at end of file diff --git a/packages/app/src/components/SelectableText/index.less b/packages/app/src/components/SelectableText/index.less new file mode 100644 index 00000000..42e0a128 --- /dev/null +++ b/packages/app/src/components/SelectableText/index.less @@ -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; +} \ No newline at end of file diff --git a/packages/app/src/contexts/MusicReleaseEditor/index.js b/packages/app/src/contexts/MusicReleaseEditor/index.js index ed8df1d2..75046ce7 100644 --- a/packages/app/src/contexts/MusicReleaseEditor/index.js +++ b/packages/app/src/contexts/MusicReleaseEditor/index.js @@ -8,6 +8,8 @@ export const DefaultReleaseEditorState = { list: [], pendingUploads: [], + + setCustomPage: () => {}, } export const ReleaseEditorStateContext = React.createContext(DefaultReleaseEditorState) diff --git a/packages/app/src/hooks/useGetMainOrigin/index.js b/packages/app/src/hooks/useGetMainOrigin/index.js new file mode 100644 index 00000000..b2ca8acc --- /dev/null +++ b/packages/app/src/hooks/useGetMainOrigin/index.js @@ -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 \ No newline at end of file diff --git a/packages/app/src/layouts/components/drawer/index.jsx b/packages/app/src/layouts/components/drawer/index.jsx index 84908e69..3005fa24 100755 --- a/packages/app/src/layouts/components/drawer/index.jsx +++ b/packages/app/src/layouts/components/drawer/index.jsx @@ -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 - {({ x, opacity }) => { - return
+ { + this.state.visible && - { React.createElement(this.props.children, componentProps) } -
- }} -
+ + } + } } @@ -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 (prevState.maskVisible !== this.state.maskVisible) { - app.layout.sidebar.toggleVisibility(false) - } else if (prevState.maskRender !== this.state.maskRender) { - app.layout.sidebar.toggleVisibility(true) + if (app.layout.sidebar) { + if (prevState.maskVisible !== this.state.maskVisible) { + 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() + drawers.push() addresses[id] = drawers.length - 1 refs[id] = instance.ref } else { - drawers[addresses[id]] = + drawers[addresses[id]] = 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 <> - - {({ opacity }) => { - return
+ { + this.state.maskVisible && this.closeLastDrawer()} - style={{ - opacity, - display: this.state.maskRender ? "block" : "none", + initial={{ + opacity: 0, + }} + animate={{ + opacity: 1, + }} + exit={{ + opacity: 0, }} /> - }} - + } +
- {this.state.drawers} + + {this.state.drawers} +
} diff --git a/packages/app/src/layouts/components/drawer/index.less b/packages/app/src/layouts/components/drawer/index.less index 4697dc5b..190968c3 100644 --- a/packages/app/src/layouts/components/drawer/index.less +++ b/packages/app/src/layouts/components/drawer/index.less @@ -36,8 +36,6 @@ background-color: rgba(0, 0, 0, 0.1); backdrop-filter: blur(4px); - - } .drawer { @@ -63,4 +61,27 @@ 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; + } } \ No newline at end of file diff --git a/packages/app/src/layouts/components/modals/index.jsx b/packages/app/src/layouts/components/modals/index.jsx index a2c42270..21c43b65 100755 --- a/packages/app/src/layouts/components/modals/index.jsx +++ b/packages/app/src/layouts/components/modals/index.jsx @@ -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
+
+

{props.headerText ?? "Are you sure?"} Are you sure?

+ + { + props.descriptionText &&

{props.descriptionText}

+ } +
+ +
+ + +
+
+} + 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 diff --git a/packages/app/src/layouts/components/sidebar/index.jsx b/packages/app/src/layouts/components/sidebar/index.jsx index d677105c..20e634dc 100755 --- a/packages/app/src/layouts/components/sidebar/index.jsx +++ b/packages/app/src/layouts/components/sidebar/index.jsx @@ -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()} /> + + Beta
diff --git a/packages/app/src/layouts/components/sidebar/index.less b/packages/app/src/layouts/components/sidebar/index.less index 86485bb8..901f0a83 100755 --- a/packages/app/src/layouts/components/sidebar/index.less +++ b/packages/app/src/layouts/components/sidebar/index.less @@ -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; diff --git a/packages/app/src/pages/chats/[roomID].jsx b/packages/app/src/pages/chats/[roomID].jsx deleted file mode 100644 index 38935a24..00000000 --- a/packages/app/src/pages/chats/[roomID].jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react" -import LiveChat from "@components/LiveChat" - -const RoomChat = (props) => { - return -} - -export default RoomChat \ No newline at end of file diff --git a/packages/app/src/pages/settings/index.jsx b/packages/app/src/pages/settings/index.jsx index 69be9cff..d69740d0 100755 --- a/packages/app/src/pages/settings/index.jsx +++ b/packages/app/src/pages/settings/index.jsx @@ -123,10 +123,14 @@ export default () => { } React.useEffect(() => { - app.layout.tools_bar.toggleVisibility(false) + if (app.layout.tools_bar) { + app.layout.tools_bar.toggleVisibility(false) + } return () => { - app.layout.tools_bar.toggleVisibility(true) + if (app.layout.tools_bar) { + app.layout.tools_bar.toggleVisibility(true) + } } }, []) diff --git a/packages/app/src/pages/studio/tv/components/EditableText/index.jsx b/packages/app/src/pages/studio/tv/components/EditableText/index.jsx new file mode 100644 index 00000000..73de60a8 --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/EditableText/index.jsx @@ -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
+ { + !isEditing && setEditing(true)} + className="editable-text-value" + > + + + {value} + + } + { + isEditing &&
+ setValue(e.target.value)} + loading={loading} + disabled={loading} + onPressEnter={() => handleSave(value)} + /> + handleSave(value)} + icon={} + loading={loading} + disabled={loading} + size="small" + /> + } + size="small" + /> +
+ } +
+} + +export default EditableText \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/EditableText/index.less b/packages/app/src/pages/studio/tv/components/EditableText/index.less new file mode 100644 index 00000000..30e3e37e --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/EditableText/index.less @@ -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; + } + } +} \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/HiddenText/index.jsx b/packages/app/src/pages/studio/tv/components/HiddenText/index.jsx new file mode 100644 index 00000000..c1e84976 --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/HiddenText/index.jsx @@ -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
+ } + type="ghost" + size="small" + onClick={copyToClipboard} + /> + + + { + visible ? props.value : "********" + } + + + : } + type="ghost" + size="small" + onClick={() => setVisible(!visible)} + /> +
+} + +export default HiddenText \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileConnection/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileConnection/index.jsx new file mode 100644 index 00000000..f73360fc --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/ProfileConnection/index.jsx @@ -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 + Disconnected + + } + + if (loading) { + return + Loading + + } + + return + Connected + +} + +export default ProfileConnection \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx new file mode 100644 index 00000000..91f0f256 --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx @@ -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
+ + +
+ + Cancel + + + { + handleSubmit(name) + }} + disabled={!name || loading} + loading={loading} + > + { + props.editValue ? "Update" : "Create" + } + +
+
+} + +export default ProfileCreator \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileCreator/index.less b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.less new file mode 100644 index 00000000..4570d938 --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.less @@ -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; + } +} \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileData/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileData/index.jsx new file mode 100644 index 00000000..7f11d816 --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/ProfileData/index.jsx @@ -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: 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 fetchData(props.profile_id)} + > + Retry + + ]} + /> + } + + if (fetching) { + return + } + + return
+
+ +
+ { + return handleChange("title", newValue) + }} + disabled={loading} + /> + { + return handleChange("description", newValue) + }} + disabled={loading} + /> +
+
+ +
+
+ + Server +
+ +
+
+ Ingestion URL +
+ +
+ + {profile.ingestion_url} + +
+
+ +
+
+ Stream Key +
+ +
+ +
+
+
+ +
+
+ + Configuration +
+ +
+
+ + Private Mode +
+ +
+

When this is enabled, only users with the livestream url can access the stream.

+
+ +
+ handleChange("private", value)} + /> +
+ +
+

Must restart the livestream to apply changes

+
+
+ +
+
+ + DVR [beta] +
+ +
+

Save a copy of your stream with its entire duration. You can download this copy after finishing this livestream.

+
+ +
+ +
+
+
+ + { + profile.sources &&
+
+ + Media URL +
+ +
+
+ HLS +
+ +
+

This protocol is highly compatible with a multitude of devices and services. Recommended for general use.

+
+ +
+ + {profile.sources.hls} + +
+
+
+
+ FLV +
+ +
+

This protocol operates at better latency and quality than HLS, but is less compatible for most devices.

+
+ +
+ + {profile.sources.flv} + +
+
+
+
+ RTSP [tcp] +
+ +
+

This protocol has the lowest possible latency and the best quality. A compatible player is required.

+
+ +
+ + {profile.sources.rtsp} + +
+
+
+
+ HTML Viewer +
+ +
+

Share a link to easily view your stream on any device with a web browser.

+
+ +
+ + {profile.sources.html} + +
+
+
+ } + +
+
+ Other +
+ +
+
+ Delete profile +
+ +
+ + + Delete + + +
+
+ +
+
+ Change profile name +
+ +
+ + Change + +
+
+
+
+} + +export default ProfileData \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileData/index.less b/packages/app/src/pages/studio/tv/components/ProfileData/index.less new file mode 100644 index 00000000..6badfbed --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/ProfileData/index.less @@ -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; + } + } + } +} \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileSelector/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileSelector/index.jsx new file mode 100644 index 00000000..8d5726b9 --- /dev/null +++ b/packages/app/src/pages/studio/tv/components/ProfileSelector/index.jsx @@ -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 + Retry + + ]} + /> + } + + if (loading) { + return + } + + return + { + result.map((profile) => { + return + {profile.profile_name ?? String(profile._id)} + + }) + } + +} + +//const ProfileSelectorForwardRef = React.forwardRef(ProfileSelector) + +export default ProfileSelector \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/index.jsx b/packages/app/src/pages/studio/tv/index.jsx new file mode 100644 index 00000000..036a9171 --- /dev/null +++ b/packages/app/src/pages/studio/tv/index.jsx @@ -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: modal.destroy()} + onCreate={(id, data) => { + setSelectedProfileId(id) + }} + />, + footer: null + }) + } + + return
+
+ + + + Create new + +
+ + { + selectedProfileId && + } + + { + !selectedProfileId &&
+

+ Select profile or create new +

+
+ } +
+} + +export default TVStudioPage \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/index.less b/packages/app/src/pages/studio/tv/index.less new file mode 100644 index 00000000..cf4d28d3 --- /dev/null +++ b/packages/app/src/pages/studio/tv/index.less @@ -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%; + } + } +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/tabs/livestreamsList/index.less b/packages/app/src/pages/tv/tabs/livestreamsList/index.less index fc7a69e6..adeb83c5 100755 --- a/packages/app/src/pages/tv/tabs/livestreamsList/index.less +++ b/packages/app/src/pages/tv/tabs/livestreamsList/index.less @@ -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; diff --git a/packages/app/src/settings/about/index.jsx b/packages/app/src/settings/about/index.jsx index 348273bb..35b2872f 100755 --- a/packages/app/src/settings/about/index.jsx +++ b/packages/app/src/settings/about/index.jsx @@ -72,9 +72,12 @@ export default { />
-

{config.app.siteName}

+
+

{config.app.siteName}

+ Beta +
{config.author} - Licensed with {config.package?.license ?? "unlicensed"} + Licensed with {config.package?.license ?? "unlicensed"}
diff --git a/packages/app/src/settings/about/index.less b/packages/app/src/settings/about/index.less index 0ccf6e55..c3642d2e 100755 --- a/packages/app/src/settings/about/index.less +++ b/packages/app/src/settings/about/index.less @@ -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, diff --git a/packages/app/src/settings/api/index.jsx b/packages/app/src/settings/api/index.jsx index 6ef90cd0..32bbed72 100644 --- a/packages/app/src/settings/api/index.jsx +++ b/packages/app/src/settings/api/index.jsx @@ -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 + } + + const onSubmit = async () => { + if (!canSubmit()) { + return } - return () => { - setMainOrigin(null) + 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
+

Your server key

+ +

Name: {result.name}

+ +
+ Access ID: + {result.access_id} +
+ + { + result.secret_token &&
+ Secret: + {result.secret_token} +
+ } + + { + result.secret_token && + } + + { + result.secret_token && + Save JSON + + } + + { + !result.secret_token && onRegenerate()} + > + Regenerate secret + + } + + onDelete()} + > + Delete + + + props.close()} + > + Ok + +
+ } + + return <> +

Create a server key

+ + + + setName(e.target.value)} + /> + + + + setAccess(e)} + > + Read + Write + Read/Write + + + + + + Create + + + + {error && + + } + + + +} + +const ServerKeyItem = (props) => { + const { name, access_id } = props.data + + return
+
+

{name}

+ {access_id} +
+ +
+ } + onClick={() => props.onEdit(props.data)} + /> +
+
} 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
@@ -49,6 +280,7 @@ export default { Create new @@ -56,13 +288,34 @@ export default {
{ - keys.map((key) => { - return null - }) + L_Keys && } + { - keys.length === 0 && + E_Keys && } + + { + !E_Keys && !L_Keys && <> + { + R_Keys.map((data, index) => { + return + }) + } + { + R_Keys.length === 0 && + } + + } +
diff --git a/packages/app/src/settings/api/index.less b/packages/app/src/settings/api/index.less index 77874408..22d790a5 100644 --- a/packages/app/src/settings/api/index.less +++ b/packages/app/src/settings/api/index.less @@ -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 { @@ -29,4 +67,23 @@ flex-direction: column; 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; + } + } } \ No newline at end of file diff --git a/packages/app/src/utils/textToDownload/index.js b/packages/app/src/utils/textToDownload/index.js new file mode 100644 index 00000000..aae0163c --- /dev/null +++ b/packages/app/src/utils/textToDownload/index.js @@ -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) +} \ No newline at end of file diff --git a/packages/server/services/music/classes/track/methods/create.js b/packages/server/services/music/classes/track/methods/create.js index 2f0d25ac..9bf99349 100644 --- a/packages/server/services/music/classes/track/methods/create.js +++ b/packages/server/services/music/classes/track/methods/create.js @@ -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, diff --git a/packages/server/services/music/classes/track/methods/modify.js b/packages/server/services/music/classes/track/methods/modify.js new file mode 100644 index 00000000..d8714f91 --- /dev/null +++ b/packages/server/services/music/classes/track/methods/modify.js @@ -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() +} \ No newline at end of file diff --git a/packages/server/services/music/routes/music/tracks/put.js b/packages/server/services/music/routes/music/tracks/put.js index c83a489d..74e305a5 100644 --- a/packages/server/services/music/routes/music/tracks/put.js +++ b/packages/server/services/music/routes/music/tracks/put.js @@ -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({