index.vue 17 KB

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