index.vue 19 KB

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