EditSongs.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. <script setup lang="ts">
  2. import { storeToRefs } from "pinia";
  3. import {
  4. defineAsyncComponent,
  5. ref,
  6. computed,
  7. onMounted,
  8. onBeforeUnmount,
  9. onUnmounted
  10. } from "vue";
  11. import Toast from "toasters";
  12. import { useModalsStore } from "@/stores/modals";
  13. import { useEditSongStore } from "@/stores/editSong";
  14. import { useEditSongsStore } from "@/stores/editSongs";
  15. import { useWebsocketsStore } from "@/stores/websockets";
  16. import { Song } from "@/types/song.js";
  17. const EditSongModal = defineAsyncComponent(
  18. () => import("@/components/modals/EditSong/index.vue")
  19. );
  20. const SongItem = defineAsyncComponent(
  21. () => import("@/components/SongItem.vue")
  22. );
  23. const props = defineProps({
  24. modalUuid: { type: String, default: "" }
  25. });
  26. const editSongStore = useEditSongStore(props);
  27. const editSongsStore = useEditSongsStore(props);
  28. const { socket } = useWebsocketsStore();
  29. const { youtubeIds, songPrefillData } = storeToRefs(editSongsStore);
  30. const { editSong } = editSongStore;
  31. const { openModal, closeCurrentModal } = useModalsStore();
  32. const items = ref([]);
  33. const currentSong = ref(<Song>{});
  34. const flagFilter = ref(false);
  35. const sidebarMobileActive = ref(false);
  36. const songItems = ref([]);
  37. const editingItemIndex = computed(() =>
  38. items.value.findIndex(
  39. item => item.song.youtubeId === currentSong.value.youtubeId
  40. )
  41. );
  42. const filteredItems = computed({
  43. get: () =>
  44. items.value.filter(item => (flagFilter.value ? item.flagged : true)),
  45. set: (newItem: any) => {
  46. const index = items.value.findIndex(
  47. item => item.song.youtubeId === newItem.youtubeId
  48. );
  49. items.value[index] = newItem;
  50. }
  51. });
  52. const filteredEditingItemIndex = computed(() =>
  53. filteredItems.value.findIndex(
  54. item => item.song.youtubeId === currentSong.value.youtubeId
  55. )
  56. );
  57. const currentSongFlagged = computed(
  58. () =>
  59. items.value.find(
  60. item => item.song.youtubeId === currentSong.value.youtubeId
  61. )?.flagged
  62. );
  63. const pickSong = song => {
  64. editSong({
  65. youtubeId: song.youtubeId,
  66. prefill: songPrefillData.value[song.youtubeId]
  67. });
  68. currentSong.value = song;
  69. if (
  70. songItems.value[`edit-songs-item-${song.youtubeId}`] &&
  71. songItems.value[`edit-songs-item-${song.youtubeId}`][0]
  72. )
  73. songItems.value[
  74. `edit-songs-item-${song.youtubeId}`
  75. ][0].scrollIntoView();
  76. };
  77. const editNextSong = () => {
  78. const currentlyEditingSongIndex = filteredEditingItemIndex.value;
  79. let newEditingSongIndex = -1;
  80. const index =
  81. currentlyEditingSongIndex + 1 === filteredItems.value.length
  82. ? 0
  83. : currentlyEditingSongIndex + 1;
  84. for (let i = index; i < filteredItems.value.length; i += 1) {
  85. if (!flagFilter.value || filteredItems.value[i].flagged) {
  86. newEditingSongIndex = i;
  87. break;
  88. }
  89. }
  90. if (newEditingSongIndex > -1) {
  91. const nextSong = filteredItems.value[newEditingSongIndex].song;
  92. if (nextSong.removed) editNextSong();
  93. else pickSong(nextSong);
  94. }
  95. };
  96. const toggleFlag = (songIndex = null) => {
  97. if (songIndex && songIndex > -1) {
  98. filteredItems.value[songIndex].flagged =
  99. !filteredItems.value[songIndex].flagged;
  100. new Toast(
  101. `Successfully ${
  102. filteredItems.value[songIndex].flagged ? "flagged" : "unflagged"
  103. } song.`
  104. );
  105. } else if (!songIndex && editingItemIndex.value > -1) {
  106. items.value[editingItemIndex.value].flagged =
  107. !items.value[editingItemIndex.value].flagged;
  108. new Toast(
  109. `Successfully ${
  110. items.value[editingItemIndex.value].flagged
  111. ? "flagged"
  112. : "unflagged"
  113. } song.`
  114. );
  115. }
  116. };
  117. const onSavedSuccess = youtubeId => {
  118. const itemIndex = items.value.findIndex(
  119. item => item.song.youtubeId === youtubeId
  120. );
  121. if (itemIndex > -1) {
  122. items.value[itemIndex].status = "done";
  123. items.value[itemIndex].flagged = false;
  124. }
  125. };
  126. const onSavedError = youtubeId => {
  127. const itemIndex = items.value.findIndex(
  128. item => item.song.youtubeId === youtubeId
  129. );
  130. if (itemIndex > -1) items.value[itemIndex].status = "error";
  131. };
  132. const onSaving = youtubeId => {
  133. const itemIndex = items.value.findIndex(
  134. item => item.song.youtubeId === youtubeId
  135. );
  136. if (itemIndex > -1) items.value[itemIndex].status = "saving";
  137. };
  138. const toggleDone = (index, overwrite = null) => {
  139. const { status } = filteredItems.value[index];
  140. if (status === "done" && overwrite !== "done")
  141. filteredItems.value[index].status = "todo";
  142. else {
  143. filteredItems.value[index].status = "done";
  144. filteredItems.value[index].flagged = false;
  145. }
  146. };
  147. const toggleFlagFilter = () => {
  148. flagFilter.value = !flagFilter.value;
  149. };
  150. const toggleMobileSidebar = () => {
  151. sidebarMobileActive.value = !sidebarMobileActive.value;
  152. };
  153. const onClose = () => {
  154. const doneItems = items.value.filter(item => item.status === "done").length;
  155. const flaggedItems = items.value.filter(item => item.flagged).length;
  156. const notDoneItems = items.value.length - doneItems;
  157. if (doneItems > 0 && notDoneItems > 0)
  158. openModal({
  159. modal: "confirm",
  160. data: {
  161. message:
  162. "You have songs which are not done yet. Are you sure you want to stop editing songs?",
  163. onCompleted: closeCurrentModal
  164. }
  165. });
  166. else if (flaggedItems > 0)
  167. openModal({
  168. modal: "confirm",
  169. data: {
  170. message:
  171. "You have songs which are flagged. Are you sure you want to stop editing songs?",
  172. onCompleted: closeCurrentModal
  173. }
  174. });
  175. else closeCurrentModal();
  176. };
  177. // onBeforeMount(() => {
  178. // console.log("EDITSONGS BEFOREMOUNT");
  179. // store.registerModule(
  180. // ["modals", "editSongs", props.modalUuid, "editSong"],
  181. // editSongStore
  182. // );
  183. // });
  184. onMounted(async () => {
  185. console.log("EDITSONGS MOUNTED");
  186. socket.dispatch("apis.joinRoom", "edit-songs");
  187. socket.dispatch("songs.getSongsFromYoutubeIds", youtubeIds.value, res => {
  188. if (res.data.songs.length === 0) {
  189. closeCurrentModal();
  190. new Toast("You can't edit 0 songs.");
  191. } else {
  192. items.value = res.data.songs.map(song => ({
  193. status: "todo",
  194. flagged: false,
  195. song
  196. }));
  197. editNextSong();
  198. }
  199. });
  200. socket.on(
  201. `event:admin.song.created`,
  202. res => {
  203. const index = items.value
  204. .map(item => item.song.youtubeId)
  205. .indexOf(res.data.song.youtubeId);
  206. if (index >= 0)
  207. items.value[index].song = {
  208. ...items.value[index].song,
  209. ...res.data.song,
  210. created: true
  211. };
  212. },
  213. { modalUuid: props.modalUuid }
  214. );
  215. socket.on(
  216. `event:admin.song.updated`,
  217. res => {
  218. const index = items.value
  219. .map(item => item.song.youtubeId)
  220. .indexOf(res.data.song.youtubeId);
  221. if (index >= 0)
  222. items.value[index].song = {
  223. ...items.value[index].song,
  224. ...res.data.song,
  225. updated: true
  226. };
  227. },
  228. { modalUuid: props.modalUuid }
  229. );
  230. socket.on(
  231. `event:admin.song.removed`,
  232. res => {
  233. const index = items.value
  234. .map(item => item.song._id)
  235. .indexOf(res.data.songId);
  236. if (index >= 0) items.value[index].song.removed = true;
  237. },
  238. { modalUuid: props.modalUuid }
  239. );
  240. socket.on(
  241. `event:admin.youtubeVideo.removed`,
  242. res => {
  243. const index = items.value
  244. .map(item => item.song.youtubeVideoId)
  245. .indexOf(res.videoId);
  246. if (index >= 0) items.value[index].song.removed = true;
  247. },
  248. { modalUuid: props.modalUuid }
  249. );
  250. });
  251. onBeforeUnmount(() => {
  252. console.log("EDITSONGS BEFORE UNMOUNT");
  253. socket.dispatch("apis.leaveRoom", "edit-songs");
  254. });
  255. onUnmounted(() => {
  256. console.log("EDITSONGS UNMOUNTED");
  257. // Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
  258. editSongsStore.$dispose();
  259. });
  260. </script>
  261. <template>
  262. <div>
  263. <edit-song-modal
  264. :modal-module-path="`modals/editSongs/${modalUuid}/editSong`"
  265. :modal-uuid="modalUuid"
  266. :bulk="true"
  267. :flagged="currentSongFlagged"
  268. v-if="currentSong"
  269. @saved-success="onSavedSuccess"
  270. @saved-error="onSavedError"
  271. @saving="onSaving"
  272. @toggle-flag="toggleFlag"
  273. @next-song="editNextSong"
  274. @close="onClose"
  275. >
  276. <template #toggleMobileSidebar>
  277. <i
  278. class="material-icons toggle-sidebar-icon"
  279. :content="`${
  280. sidebarMobileActive ? 'Close' : 'Open'
  281. } Edit Queue`"
  282. v-tippy
  283. @click="toggleMobileSidebar()"
  284. >expand_circle_down</i
  285. >
  286. </template>
  287. <template #sidebar>
  288. <div class="sidebar" :class="{ active: sidebarMobileActive }">
  289. <header class="sidebar-head">
  290. <h2 class="sidebar-title is-marginless">Edit Queue</h2>
  291. <i
  292. class="material-icons toggle-sidebar-icon"
  293. :content="`${
  294. sidebarMobileActive ? 'Close' : 'Open'
  295. } Edit Queue`"
  296. v-tippy
  297. @click="toggleMobileSidebar()"
  298. >expand_circle_down</i
  299. >
  300. </header>
  301. <section class="sidebar-body">
  302. <div
  303. v-show="filteredItems.length > 0"
  304. class="edit-songs-items"
  305. >
  306. <div
  307. class="item"
  308. v-for="(
  309. { status, flagged, song }, index
  310. ) in filteredItems"
  311. :key="`edit-songs-item-${index}`"
  312. :ref="
  313. el =>
  314. (songItems[
  315. `edit-songs-item-${song.youtubeId}`
  316. ] = el)
  317. "
  318. >
  319. <song-item
  320. :song="song"
  321. :thumbnail="false"
  322. :duration="false"
  323. :disabled-actions="
  324. song.removed
  325. ? ['all']
  326. : ['report', 'edit']
  327. "
  328. :class="{
  329. updated: song.updated,
  330. removed: song.removed
  331. }"
  332. >
  333. <template #leftIcon>
  334. <i
  335. v-if="
  336. currentSong.youtubeId ===
  337. song.youtubeId &&
  338. !song.removed
  339. "
  340. class="material-icons item-icon editing-icon"
  341. content="Currently editing song"
  342. v-tippy="{ theme: 'info' }"
  343. @click="toggleDone(index)"
  344. >edit</i
  345. >
  346. <i
  347. v-else-if="song.removed"
  348. class="material-icons item-icon removed-icon"
  349. content="Song removed"
  350. v-tippy="{ theme: 'info' }"
  351. >delete_forever</i
  352. >
  353. <i
  354. v-else-if="status === 'error'"
  355. class="material-icons item-icon error-icon"
  356. content="Error saving song"
  357. v-tippy="{ theme: 'info' }"
  358. @click="toggleDone(index)"
  359. >error</i
  360. >
  361. <i
  362. v-else-if="status === 'saving'"
  363. class="material-icons item-icon saving-icon"
  364. content="Currently saving song"
  365. v-tippy="{ theme: 'info' }"
  366. >pending</i
  367. >
  368. <i
  369. v-else-if="flagged"
  370. class="material-icons item-icon flag-icon"
  371. content="Song flagged"
  372. v-tippy="{ theme: 'info' }"
  373. @click="toggleDone(index)"
  374. >flag_circle</i
  375. >
  376. <i
  377. v-else-if="status === 'done'"
  378. class="material-icons item-icon done-icon"
  379. content="Song marked complete"
  380. v-tippy="{ theme: 'info' }"
  381. @click="toggleDone(index)"
  382. >check_circle</i
  383. >
  384. <i
  385. v-else-if="status === 'todo'"
  386. class="material-icons item-icon todo-icon"
  387. content="Song marked todo"
  388. v-tippy="{ theme: 'info' }"
  389. @click="toggleDone(index)"
  390. >cancel</i
  391. >
  392. </template>
  393. <template v-if="!song.removed" #actions>
  394. <i
  395. class="material-icons edit-icon"
  396. content="Edit Song"
  397. v-tippy
  398. @click="pickSong(song)"
  399. >
  400. edit
  401. </i>
  402. </template>
  403. <template #tippyActions>
  404. <i
  405. class="material-icons flag-icon"
  406. :class="{ flagged }"
  407. content="Toggle Flag"
  408. v-tippy
  409. @click="toggleFlag(index)"
  410. >
  411. flag_circle
  412. </i>
  413. </template>
  414. </song-item>
  415. </div>
  416. </div>
  417. <p v-if="filteredItems.length === 0" class="no-items">
  418. {{
  419. flagFilter
  420. ? "No flagged songs queued"
  421. : "No songs queued"
  422. }}
  423. </p>
  424. </section>
  425. <footer class="sidebar-foot">
  426. <button
  427. @click="toggleFlagFilter()"
  428. class="button is-primary"
  429. >
  430. {{
  431. flagFilter
  432. ? "Show All Songs"
  433. : "Show Only Flagged Songs"
  434. }}
  435. </button>
  436. </footer>
  437. </div>
  438. <div
  439. v-if="sidebarMobileActive"
  440. class="sidebar-overlay"
  441. @click="toggleMobileSidebar()"
  442. ></div>
  443. </template>
  444. </edit-song-modal>
  445. </div>
  446. </template>
  447. <style lang="less" scoped>
  448. .night-mode .sidebar {
  449. .sidebar-head,
  450. .sidebar-foot {
  451. background-color: var(--dark-grey-3);
  452. border: none;
  453. }
  454. .sidebar-body {
  455. background-color: var(--dark-grey-4) !important;
  456. }
  457. .sidebar-head .toggle-sidebar-icon.material-icons,
  458. .sidebar-title {
  459. color: var(--white);
  460. }
  461. p,
  462. label,
  463. td,
  464. th {
  465. color: var(--light-grey-2) !important;
  466. }
  467. h1,
  468. h2,
  469. h3,
  470. h4,
  471. h5,
  472. h6 {
  473. color: var(--white) !important;
  474. }
  475. }
  476. .toggle-sidebar-icon {
  477. display: none;
  478. }
  479. .sidebar {
  480. width: 100%;
  481. max-width: 350px;
  482. z-index: 2000;
  483. display: flex;
  484. flex-direction: column;
  485. position: relative;
  486. height: 100%;
  487. max-height: calc(100vh - 40px);
  488. overflow: auto;
  489. margin-right: 8px;
  490. border-radius: @border-radius;
  491. .sidebar-head,
  492. .sidebar-foot {
  493. display: flex;
  494. flex-shrink: 0;
  495. position: relative;
  496. justify-content: flex-start;
  497. align-items: center;
  498. padding: 20px;
  499. background-color: var(--light-grey);
  500. }
  501. .sidebar-head {
  502. border-bottom: 1px solid var(--light-grey-2);
  503. border-radius: @border-radius @border-radius 0 0;
  504. .sidebar-title {
  505. display: flex;
  506. flex: 1;
  507. margin: 0;
  508. font-size: 26px;
  509. font-weight: 600;
  510. }
  511. }
  512. .sidebar-body {
  513. background-color: var(--white);
  514. display: flex;
  515. flex-direction: column;
  516. row-gap: 8px;
  517. flex: 1;
  518. overflow: auto;
  519. padding: 10px;
  520. .edit-songs-items {
  521. display: flex;
  522. flex-direction: column;
  523. row-gap: 8px;
  524. .item {
  525. display: flex;
  526. flex-direction: row;
  527. align-items: center;
  528. column-gap: 8px;
  529. :deep(.song-item) {
  530. .item-icon {
  531. margin-right: 10px;
  532. cursor: pointer;
  533. }
  534. .removed-icon,
  535. .error-icon {
  536. color: var(--red);
  537. }
  538. .saving-icon,
  539. .todo-icon,
  540. .editing-icon {
  541. color: var(--primary-color);
  542. }
  543. .done-icon {
  544. color: var(--green);
  545. }
  546. .flag-icon {
  547. color: var(--orange);
  548. &.flagged {
  549. color: var(--grey);
  550. }
  551. }
  552. &.removed {
  553. filter: grayscale(100%);
  554. cursor: not-allowed;
  555. user-select: none;
  556. }
  557. }
  558. }
  559. }
  560. .no-items {
  561. text-align: center;
  562. font-size: 18px;
  563. }
  564. }
  565. .sidebar-foot {
  566. border-top: 1px solid var(--light-grey-2);
  567. border-radius: 0 0 @border-radius @border-radius;
  568. .button {
  569. flex: 1;
  570. }
  571. }
  572. .sidebar-overlay {
  573. display: none;
  574. }
  575. }
  576. @media only screen and (max-width: 1580px) {
  577. .toggle-sidebar-icon {
  578. display: flex;
  579. margin-right: 5px;
  580. transform: rotate(90deg);
  581. cursor: pointer;
  582. }
  583. .sidebar {
  584. display: none;
  585. &.active {
  586. display: flex;
  587. position: absolute;
  588. z-index: 2010;
  589. top: 20px;
  590. left: 20px;
  591. .sidebar-head .toggle-sidebar-icon {
  592. display: flex;
  593. margin-left: 5px;
  594. transform: rotate(-90deg);
  595. }
  596. }
  597. }
  598. .sidebar-overlay {
  599. display: flex;
  600. position: absolute;
  601. z-index: 2009;
  602. top: 0;
  603. left: 0;
  604. right: 0;
  605. bottom: 0;
  606. background-color: rgba(10, 10, 10, 0.85);
  607. }
  608. }
  609. </style>