index.vue 16 KB

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