Songs.vue 18 KB

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