index.vue 18 KB

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