index.vue 19 KB

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