Songs.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778
  1. <template>
  2. <div>
  3. <page-metadata title="Admin | Songs" />
  4. <div class="admin-tab">
  5. <div class="button-row">
  6. <button class="button is-primary" @click="create()">
  7. Create song
  8. </button>
  9. <button
  10. class="button is-primary"
  11. @click="openModal('importPlaylist')"
  12. >
  13. Import playlist
  14. </button>
  15. <button
  16. class="button is-primary"
  17. @click="openModal('importAlbum')"
  18. >
  19. Import album
  20. </button>
  21. <run-job-dropdown :jobs="jobs" />
  22. </div>
  23. <advanced-table
  24. :column-default="columnDefault"
  25. :columns="columns"
  26. :filters="filters"
  27. data-action="songs.getData"
  28. name="admin-songs"
  29. :events="events"
  30. >
  31. <template #column-options="slotProps">
  32. <div class="row-options">
  33. <button
  34. class="button is-primary icon-with-button material-icons"
  35. @click="editOne(slotProps.item)"
  36. :disabled="slotProps.item.removed"
  37. content="Edit Song"
  38. v-tippy
  39. >
  40. edit
  41. </button>
  42. <quick-confirm
  43. v-if="slotProps.item.verified"
  44. @confirm="unverifyOne(slotProps.item._id)"
  45. >
  46. <button
  47. class="button is-danger icon-with-button material-icons"
  48. :disabled="slotProps.item.removed"
  49. content="Unverify Song"
  50. v-tippy
  51. >
  52. cancel
  53. </button>
  54. </quick-confirm>
  55. <button
  56. v-else
  57. class="button is-success icon-with-button material-icons"
  58. @click="verifyOne(slotProps.item._id)"
  59. :disabled="slotProps.item.removed"
  60. content="Verify Song"
  61. v-tippy
  62. >
  63. check_circle
  64. </button>
  65. <button
  66. class="button is-danger icon-with-button material-icons"
  67. @click.prevent="
  68. confirmAction({
  69. message:
  70. 'Removing this song will remove it from all playlists and cause a ratings recalculation.',
  71. action: 'deleteOne',
  72. params: slotProps.item._id
  73. })
  74. "
  75. :disabled="slotProps.item.removed"
  76. content="Delete Song"
  77. v-tippy
  78. >
  79. delete_forever
  80. </button>
  81. </div>
  82. </template>
  83. <template #column-thumbnailImage="slotProps">
  84. <song-thumbnail
  85. class="song-thumbnail"
  86. :song="slotProps.item"
  87. />
  88. </template>
  89. <template #column-thumbnailUrl="slotProps">
  90. <a :href="slotProps.item.thumbnail" target="_blank">
  91. {{ slotProps.item.thumbnail }}
  92. </a>
  93. </template>
  94. <template #column-title="slotProps">
  95. <span :title="slotProps.item.title">{{
  96. slotProps.item.title
  97. }}</span>
  98. </template>
  99. <template #column-artists="slotProps">
  100. <span :title="slotProps.item.artists.join(', ')">{{
  101. slotProps.item.artists.join(", ")
  102. }}</span>
  103. </template>
  104. <template #column-genres="slotProps">
  105. <span :title="slotProps.item.genres.join(', ')">{{
  106. slotProps.item.genres.join(", ")
  107. }}</span>
  108. </template>
  109. <template #column-tags="slotProps">
  110. <span :title="slotProps.item.tags.join(', ')">{{
  111. slotProps.item.tags.join(", ")
  112. }}</span>
  113. </template>
  114. <template #column-likes="slotProps">
  115. <span :title="slotProps.item.likes">{{
  116. slotProps.item.likes
  117. }}</span>
  118. </template>
  119. <template #column-dislikes="slotProps">
  120. <span :title="slotProps.item.dislikes">{{
  121. slotProps.item.dislikes
  122. }}</span>
  123. </template>
  124. <template #column-_id="slotProps">
  125. <span :title="slotProps.item._id">{{
  126. slotProps.item._id
  127. }}</span>
  128. </template>
  129. <template #column-youtubeId="slotProps">
  130. <a
  131. :href="
  132. 'https://www.youtube.com/watch?v=' +
  133. `${slotProps.item.youtubeId}`
  134. "
  135. target="_blank"
  136. >
  137. {{ slotProps.item.youtubeId }}
  138. </a>
  139. </template>
  140. <template #column-verified="slotProps">
  141. <span :title="slotProps.item.verified">{{
  142. slotProps.item.verified
  143. }}</span>
  144. </template>
  145. <template #column-duration="slotProps">
  146. <span :title="slotProps.item.duration">{{
  147. slotProps.item.duration
  148. }}</span>
  149. </template>
  150. <template #column-skipDuration="slotProps">
  151. <span :title="slotProps.item.skipDuration">{{
  152. slotProps.item.skipDuration
  153. }}</span>
  154. </template>
  155. <template #column-requestedBy="slotProps">
  156. <user-link :user-id="slotProps.item.requestedBy" />
  157. </template>
  158. <template #column-requestedAt="slotProps">
  159. <span :title="new Date(slotProps.item.requestedAt)">{{
  160. getDateFormatted(slotProps.item.requestedAt)
  161. }}</span>
  162. </template>
  163. <template #column-verifiedBy="slotProps">
  164. <user-link :user-id="slotProps.item.verifiedBy" />
  165. </template>
  166. <template #column-verifiedAt="slotProps">
  167. <span :title="new Date(slotProps.item.verifiedAt)">{{
  168. getDateFormatted(slotProps.item.verifiedAt)
  169. }}</span>
  170. </template>
  171. <template #bulk-actions="slotProps">
  172. <div class="bulk-actions">
  173. <i
  174. class="material-icons edit-songs-icon"
  175. @click.prevent="editMany(slotProps.item)"
  176. content="Edit Songs"
  177. v-tippy
  178. tabindex="0"
  179. >
  180. edit
  181. </i>
  182. <i
  183. class="material-icons verify-songs-icon"
  184. @click.prevent="verifyMany(slotProps.item)"
  185. content="Verify Songs"
  186. v-tippy
  187. tabindex="0"
  188. >
  189. check_circle
  190. </i>
  191. <quick-confirm
  192. placement="left"
  193. @confirm="unverifyMany(slotProps.item)"
  194. tabindex="0"
  195. >
  196. <i
  197. class="material-icons unverify-songs-icon"
  198. content="Unverify Songs"
  199. v-tippy
  200. >
  201. cancel
  202. </i>
  203. </quick-confirm>
  204. <i
  205. class="material-icons tag-songs-icon"
  206. @click.prevent="setTags(slotProps.item)"
  207. content="Set Tags"
  208. v-tippy
  209. tabindex="0"
  210. >
  211. local_offer
  212. </i>
  213. <i
  214. class="material-icons artists-songs-icon"
  215. @click.prevent="setArtists(slotProps.item)"
  216. content="Set Artists"
  217. v-tippy
  218. tabindex="0"
  219. >
  220. group
  221. </i>
  222. <i
  223. class="material-icons genres-songs-icon"
  224. @click.prevent="setGenres(slotProps.item)"
  225. content="Set Genres"
  226. v-tippy
  227. tabindex="0"
  228. >
  229. theater_comedy
  230. </i>
  231. <i
  232. class="material-icons delete-icon"
  233. @click.prevent="
  234. confirmAction({
  235. message:
  236. 'Removing these songs will remove them from all playlists and cause a ratings recalculation.',
  237. action: 'deleteMany',
  238. params: slotProps.item
  239. })
  240. "
  241. content="Delete Songs"
  242. v-tippy
  243. tabindex="0"
  244. >
  245. delete_forever
  246. </i>
  247. </div>
  248. </template>
  249. </advanced-table>
  250. </div>
  251. </div>
  252. </template>
  253. <script>
  254. import { mapState, mapActions, mapGetters } from "vuex";
  255. import Toast from "toasters";
  256. import AdvancedTable from "@/components/AdvancedTable.vue";
  257. import RunJobDropdown from "@/components/RunJobDropdown.vue";
  258. export default {
  259. components: {
  260. AdvancedTable,
  261. RunJobDropdown
  262. },
  263. data() {
  264. return {
  265. columnDefault: {
  266. sortable: true,
  267. hidable: true,
  268. defaultVisibility: "shown",
  269. draggable: true,
  270. resizable: true,
  271. minWidth: 200,
  272. maxWidth: 600
  273. },
  274. columns: [
  275. {
  276. name: "options",
  277. displayName: "Options",
  278. properties: ["_id", "verified"],
  279. sortable: false,
  280. hidable: false,
  281. resizable: false,
  282. minWidth: 129,
  283. defaultWidth: 129
  284. },
  285. {
  286. name: "thumbnailImage",
  287. displayName: "Thumb",
  288. properties: ["thumbnail"],
  289. sortable: false,
  290. minWidth: 75,
  291. defaultWidth: 75,
  292. maxWidth: 75,
  293. resizable: false
  294. },
  295. {
  296. name: "title",
  297. displayName: "Title",
  298. properties: ["title"],
  299. sortProperty: "title"
  300. },
  301. {
  302. name: "artists",
  303. displayName: "Artists",
  304. properties: ["artists"],
  305. sortable: false
  306. },
  307. {
  308. name: "genres",
  309. displayName: "Genres",
  310. properties: ["genres"],
  311. sortable: false
  312. },
  313. {
  314. name: "tags",
  315. displayName: "Tags",
  316. properties: ["tags"],
  317. sortable: false
  318. },
  319. {
  320. name: "likes",
  321. displayName: "Likes",
  322. properties: ["likes"],
  323. sortProperty: "likes",
  324. minWidth: 100,
  325. defaultWidth: 100,
  326. defaultVisibility: "hidden"
  327. },
  328. {
  329. name: "dislikes",
  330. displayName: "Dislikes",
  331. properties: ["dislikes"],
  332. sortProperty: "dislikes",
  333. minWidth: 100,
  334. defaultWidth: 100,
  335. defaultVisibility: "hidden"
  336. },
  337. {
  338. name: "_id",
  339. displayName: "Song ID",
  340. properties: ["_id"],
  341. sortProperty: "_id",
  342. minWidth: 215,
  343. defaultWidth: 215
  344. },
  345. {
  346. name: "youtubeId",
  347. displayName: "YouTube ID",
  348. properties: ["youtubeId"],
  349. sortProperty: "youtubeId",
  350. minWidth: 120,
  351. defaultWidth: 120
  352. },
  353. {
  354. name: "verified",
  355. displayName: "Verified",
  356. properties: ["verified"],
  357. sortProperty: "verified",
  358. minWidth: 120,
  359. defaultWidth: 120
  360. },
  361. {
  362. name: "thumbnailUrl",
  363. displayName: "Thumbnail (URL)",
  364. properties: ["thumbnail"],
  365. sortProperty: "thumbnail",
  366. defaultVisibility: "hidden"
  367. },
  368. {
  369. name: "duration",
  370. displayName: "Duration",
  371. properties: ["duration"],
  372. sortProperty: "duration",
  373. defaultWidth: 200,
  374. defaultVisibility: "hidden"
  375. },
  376. {
  377. name: "skipDuration",
  378. displayName: "Skip Duration",
  379. properties: ["skipDuration"],
  380. sortProperty: "skipDuration",
  381. defaultWidth: 200,
  382. defaultVisibility: "hidden"
  383. },
  384. {
  385. name: "requestedBy",
  386. displayName: "Requested By",
  387. properties: ["requestedBy"],
  388. sortProperty: "requestedBy",
  389. defaultWidth: 200,
  390. defaultVisibility: "hidden"
  391. },
  392. {
  393. name: "requestedAt",
  394. displayName: "Requested At",
  395. properties: ["requestedAt"],
  396. sortProperty: "requestedAt",
  397. defaultWidth: 200,
  398. defaultVisibility: "hidden"
  399. },
  400. {
  401. name: "verifiedBy",
  402. displayName: "Verified By",
  403. properties: ["verifiedBy"],
  404. sortProperty: "verifiedBy",
  405. defaultWidth: 200,
  406. defaultVisibility: "hidden"
  407. },
  408. {
  409. name: "verifiedAt",
  410. displayName: "Verified At",
  411. properties: ["verifiedAt"],
  412. sortProperty: "verifiedAt",
  413. defaultWidth: 200,
  414. defaultVisibility: "hidden"
  415. }
  416. ],
  417. filters: [
  418. {
  419. name: "_id",
  420. displayName: "Song ID",
  421. property: "_id",
  422. filterTypes: ["exact"],
  423. defaultFilterType: "exact"
  424. },
  425. {
  426. name: "youtubeId",
  427. displayName: "YouTube ID",
  428. property: "youtubeId",
  429. filterTypes: ["contains", "exact", "regex"],
  430. defaultFilterType: "contains"
  431. },
  432. {
  433. name: "title",
  434. displayName: "Title",
  435. property: "title",
  436. filterTypes: ["contains", "exact", "regex"],
  437. defaultFilterType: "contains"
  438. },
  439. {
  440. name: "artists",
  441. displayName: "Artists",
  442. property: "artists",
  443. filterTypes: ["contains", "exact", "regex"],
  444. defaultFilterType: "contains",
  445. autosuggest: true,
  446. autosuggestDataAction: "songs.getArtists"
  447. },
  448. {
  449. name: "genres",
  450. displayName: "Genres",
  451. property: "genres",
  452. filterTypes: ["contains", "exact", "regex"],
  453. defaultFilterType: "contains",
  454. autosuggest: true,
  455. autosuggestDataAction: "songs.getGenres"
  456. },
  457. {
  458. name: "tags",
  459. displayName: "Tags",
  460. property: "tags",
  461. filterTypes: ["contains", "exact", "regex"],
  462. defaultFilterType: "contains",
  463. autosuggest: true,
  464. autosuggestDataAction: "songs.getTags"
  465. },
  466. {
  467. name: "thumbnail",
  468. displayName: "Thumbnail",
  469. property: "thumbnail",
  470. filterTypes: ["contains", "exact", "regex"],
  471. defaultFilterType: "contains"
  472. },
  473. {
  474. name: "requestedBy",
  475. displayName: "Requested By",
  476. property: "requestedBy",
  477. filterTypes: ["contains", "exact", "regex"],
  478. defaultFilterType: "contains"
  479. },
  480. {
  481. name: "requestedAt",
  482. displayName: "Requested At",
  483. property: "requestedAt",
  484. filterTypes: ["datetimeBefore", "datetimeAfter"],
  485. defaultFilterType: "datetimeBefore"
  486. },
  487. {
  488. name: "verifiedBy",
  489. displayName: "Verified By",
  490. property: "verifiedBy",
  491. filterTypes: ["contains", "exact", "regex"],
  492. defaultFilterType: "contains"
  493. },
  494. {
  495. name: "verifiedAt",
  496. displayName: "Verified At",
  497. property: "verifiedAt",
  498. filterTypes: ["datetimeBefore", "datetimeAfter"],
  499. defaultFilterType: "datetimeBefore"
  500. },
  501. {
  502. name: "verified",
  503. displayName: "Verified",
  504. property: "verified",
  505. filterTypes: ["boolean"],
  506. defaultFilterType: "boolean"
  507. },
  508. {
  509. name: "likes",
  510. displayName: "Likes",
  511. property: "likes",
  512. filterTypes: [
  513. "numberLesserEqual",
  514. "numberLesser",
  515. "numberGreater",
  516. "numberGreaterEqual",
  517. "numberEquals"
  518. ],
  519. defaultFilterType: "numberLesser"
  520. },
  521. {
  522. name: "dislikes",
  523. displayName: "Dislikes",
  524. property: "dislikes",
  525. filterTypes: [
  526. "numberLesserEqual",
  527. "numberLesser",
  528. "numberGreater",
  529. "numberGreaterEqual",
  530. "numberEquals"
  531. ],
  532. defaultFilterType: "numberLesser"
  533. },
  534. {
  535. name: "duration",
  536. displayName: "Duration",
  537. property: "duration",
  538. filterTypes: [
  539. "numberLesserEqual",
  540. "numberLesser",
  541. "numberGreater",
  542. "numberGreaterEqual",
  543. "numberEquals"
  544. ],
  545. defaultFilterType: "numberLesser"
  546. },
  547. {
  548. name: "skipDuration",
  549. displayName: "Skip Duration",
  550. property: "skipDuration",
  551. filterTypes: [
  552. "numberLesserEqual",
  553. "numberLesser",
  554. "numberGreater",
  555. "numberGreaterEqual",
  556. "numberEquals"
  557. ],
  558. defaultFilterType: "numberLesser"
  559. }
  560. ],
  561. events: {
  562. adminRoom: "songs",
  563. updated: {
  564. event: "admin.song.updated",
  565. id: "song._id",
  566. item: "song"
  567. },
  568. removed: {
  569. event: "admin.song.removed",
  570. id: "songId"
  571. }
  572. },
  573. jobs: [
  574. {
  575. name: "Update all songs",
  576. socket: "songs.updateAll"
  577. },
  578. {
  579. name: "Recalculate all song ratings",
  580. socket: "songs.recalculateAllRatings"
  581. }
  582. ]
  583. };
  584. },
  585. computed: {
  586. ...mapState("modals/editSong", {
  587. song: state => state.song
  588. }),
  589. ...mapGetters({
  590. socket: "websockets/getSocket"
  591. })
  592. },
  593. mounted() {
  594. if (this.$route.query.songId) {
  595. this.socket.dispatch(
  596. "songs.getSongFromSongId",
  597. this.$route.query.songId,
  598. res => {
  599. if (res.status === "success")
  600. this.editMany([res.data.song]);
  601. else new Toast("Song with that ID not found");
  602. }
  603. );
  604. }
  605. },
  606. methods: {
  607. create() {
  608. this.openModal({
  609. modal: "editSong",
  610. data: { song: { newSong: true } }
  611. });
  612. },
  613. editOne(song) {
  614. this.openModal({
  615. modal: "editSong",
  616. data: { song: { songId: song._id } }
  617. });
  618. },
  619. editMany(selectedRows) {
  620. if (selectedRows.length === 1) this.editOne(selectedRows[0]);
  621. else {
  622. const songs = selectedRows.map(row => ({
  623. songId: row._id
  624. }));
  625. this.openModal({ modal: "editSongs", data: { songs } });
  626. }
  627. },
  628. verifyOne(songId) {
  629. this.socket.dispatch("songs.verify", songId, res => {
  630. new Toast(res.message);
  631. });
  632. },
  633. verifyMany(selectedRows) {
  634. this.socket.dispatch(
  635. "songs.verifyMany",
  636. selectedRows.map(row => row._id),
  637. res => {
  638. new Toast(res.message);
  639. }
  640. );
  641. },
  642. unverifyOne(songId) {
  643. this.socket.dispatch("songs.unverify", songId, res => {
  644. new Toast(res.message);
  645. });
  646. },
  647. unverifyMany(selectedRows) {
  648. this.socket.dispatch(
  649. "songs.unverifyMany",
  650. selectedRows.map(row => row._id),
  651. res => {
  652. new Toast(res.message);
  653. }
  654. );
  655. },
  656. setTags(selectedRows) {
  657. this.openModal({
  658. modal: "bulkActions",
  659. data: {
  660. type: {
  661. name: "tags",
  662. action: "songs.editTags",
  663. items: selectedRows.map(row => row._id),
  664. regex: /^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/,
  665. autosuggest: true,
  666. autosuggestDataAction: "songs.getTags"
  667. }
  668. }
  669. });
  670. },
  671. setArtists(selectedRows) {
  672. this.openModal({
  673. modal: "bulkActions",
  674. data: {
  675. type: {
  676. name: "artists",
  677. action: "songs.editArtists",
  678. items: selectedRows.map(row => row._id),
  679. regex: /^(?=.{1,64}$).*$/,
  680. autosuggest: true,
  681. autosuggestDataAction: "songs.getArtists"
  682. }
  683. }
  684. });
  685. },
  686. setGenres(selectedRows) {
  687. this.openModal({
  688. modal: "bulkActions",
  689. data: {
  690. type: {
  691. name: "genres",
  692. action: "songs.editGenres",
  693. items: selectedRows.map(row => row._id),
  694. regex: /^[\x00-\x7F]{1,32}$/,
  695. autosuggest: true,
  696. autosuggestDataAction: "songs.getGenres"
  697. }
  698. }
  699. });
  700. },
  701. deleteOne(songId) {
  702. this.socket.dispatch("songs.remove", songId, res => {
  703. new Toast(res.message);
  704. });
  705. },
  706. deleteMany(selectedRows) {
  707. this.socket.dispatch(
  708. "songs.removeMany",
  709. selectedRows.map(row => row._id),
  710. res => {
  711. new Toast(res.message);
  712. }
  713. );
  714. },
  715. getDateFormatted(createdAt) {
  716. const date = new Date(createdAt);
  717. const year = date.getFullYear();
  718. const month = `${date.getMonth() + 1}`.padStart(2, 0);
  719. const day = `${date.getDate()}`.padStart(2, 0);
  720. const hour = `${date.getHours()}`.padStart(2, 0);
  721. const minute = `${date.getMinutes()}`.padStart(2, 0);
  722. return `${year}-${month}-${day} ${hour}:${minute}`;
  723. },
  724. confirmAction({ message, action, params }) {
  725. this.openModal({
  726. modal: "confirm",
  727. data: {
  728. message,
  729. action,
  730. params,
  731. onCompleted: this.handleConfirmed
  732. }
  733. });
  734. },
  735. handleConfirmed({ action, params }) {
  736. if (typeof this[action] === "function") {
  737. if (params) this[action](params);
  738. else this[action]();
  739. }
  740. },
  741. ...mapActions("modalVisibility", ["openModal"])
  742. }
  743. };
  744. </script>
  745. <style lang="less" scoped>
  746. :deep(.song-thumbnail) {
  747. width: 50px;
  748. height: 50px;
  749. min-width: 50px;
  750. min-height: 50px;
  751. margin: 0 auto;
  752. }
  753. :deep(.bulk-popup .bulk-actions) {
  754. .verify-songs-icon {
  755. color: var(--green);
  756. }
  757. & > span {
  758. position: relative;
  759. top: 6px;
  760. margin-left: 5px;
  761. height: 25px;
  762. & > div {
  763. height: 25px;
  764. & > .unverify-songs-icon {
  765. color: var(--dark-red);
  766. top: unset;
  767. margin-left: unset;
  768. }
  769. }
  770. }
  771. }
  772. </style>