EditPlaylist.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958
  1. <template>
  2. <modal
  3. :title="
  4. userId === playlist.createdBy ? 'Edit Playlist' : 'View Playlist'
  5. "
  6. class="edit-playlist-modal"
  7. >
  8. <div
  9. slot="body"
  10. :class="{
  11. 'view-only': !isEditable(),
  12. 'edit-playlist-modal-inner-container': true
  13. }"
  14. >
  15. <div id="first-column">
  16. <div id="playlist-info-section" class="section">
  17. <h3>{{ playlist.displayName }}</h3>
  18. <h5>Song Count: {{ playlist.songs.length }}</h5>
  19. <h5>Duration: {{ totalLength() }}</h5>
  20. </div>
  21. <div
  22. id="playlist-settings-section"
  23. v-if="
  24. userId === playlist.createdBy ||
  25. isEditable() ||
  26. (playlist.type === 'genre' && isAdmin())
  27. "
  28. class="section"
  29. >
  30. <div v-if="userId === playlist.createdBy || isEditable()">
  31. <h4 class="section-title">Edit Details</h4>
  32. <p class="section-description">
  33. Change the display name and privacy of the playlist.
  34. </p>
  35. <hr class="section-horizontal-rule" />
  36. <label class="label"> Change display name </label>
  37. <div class="control is-grouped input-with-button">
  38. <p class="control is-expanded">
  39. <input
  40. v-model="playlist.displayName"
  41. class="input"
  42. type="text"
  43. placeholder="Playlist Display Name"
  44. @keyup.enter="renamePlaylist()"
  45. />
  46. </p>
  47. <p class="control">
  48. <a
  49. class="button is-info"
  50. @click.prevent="renamePlaylist()"
  51. href="#"
  52. >Rename</a
  53. >
  54. </p>
  55. </div>
  56. </div>
  57. <div
  58. v-if="
  59. isEditable() ||
  60. (playlist.type === 'genre' && isAdmin())
  61. "
  62. >
  63. <label class="label"> Change privacy </label>
  64. <div class="control is-grouped input-with-button">
  65. <div class="control is-expanded select">
  66. <select v-model="playlist.privacy">
  67. <option value="private">Private</option>
  68. <option value="public">Public</option>
  69. </select>
  70. </div>
  71. <p class="control">
  72. <a
  73. class="button is-info"
  74. @click.prevent="updatePrivacy()"
  75. href="#"
  76. >Update Privacy</a
  77. >
  78. </p>
  79. </div>
  80. </div>
  81. </div>
  82. <div
  83. id="import-from-youtube-section"
  84. class="section"
  85. v-if="isEditable()"
  86. >
  87. <h4 class="section-title">Import from YouTube</h4>
  88. <p class="section-description">
  89. Import a playlist or song by searching or using a link
  90. from YouTube.
  91. </p>
  92. <hr class="section-horizontal-rule" />
  93. <label class="label">
  94. Search for a playlist from YouTube
  95. </label>
  96. <div class="control is-grouped input-with-button">
  97. <p class="control is-expanded">
  98. <input
  99. class="input"
  100. type="text"
  101. placeholder="Enter YouTube Playlist URL here..."
  102. v-model="search.playlist.query"
  103. @keyup.enter="importPlaylist()"
  104. />
  105. </p>
  106. <p class="control has-addons">
  107. <span class="select" id="playlist-import-type">
  108. <select
  109. v-model="
  110. search.playlist.isImportingOnlyMusic
  111. "
  112. >
  113. <option :value="false">Import all</option>
  114. <option :value="true">
  115. Import only music
  116. </option>
  117. </select>
  118. </span>
  119. <a
  120. class="button is-info"
  121. @click.prevent="importPlaylist()"
  122. href="#"
  123. ><i class="material-icons icon-with-button"
  124. >publish</i
  125. >Import</a
  126. >
  127. </p>
  128. </div>
  129. <label class="label">
  130. Search for a song from YouTube
  131. </label>
  132. <div class="control is-grouped input-with-button">
  133. <p class="control is-expanded">
  134. <input
  135. class="input"
  136. type="text"
  137. placeholder="Enter your YouTube query here..."
  138. v-model="search.songs.query"
  139. autofocus
  140. @keyup.enter="searchForSongs()"
  141. />
  142. </p>
  143. <p class="control">
  144. <a
  145. class="button is-info"
  146. @click.prevent="searchForSongs()"
  147. href="#"
  148. ><i class="material-icons icon-with-button"
  149. >search</i
  150. >Search</a
  151. >
  152. </p>
  153. </div>
  154. <div
  155. v-if="search.songs.results.length > 0"
  156. id="song-query-results"
  157. >
  158. <search-query-item
  159. v-for="(result, index) in search.songs.results"
  160. :key="result.id"
  161. :result="result"
  162. >
  163. <div slot="actions">
  164. <transition
  165. name="search-query-actions"
  166. mode="out-in"
  167. >
  168. <a
  169. class="button is-success"
  170. v-if="result.isAddedToQueue"
  171. href="#"
  172. key="added-to-playlist"
  173. >
  174. <i
  175. class="material-icons icon-with-button"
  176. >done</i
  177. >
  178. Added to playlist
  179. </a>
  180. <a
  181. class="button is-dark"
  182. v-else
  183. @click.prevent="
  184. addSongToPlaylist(result.id, index)
  185. "
  186. href="#"
  187. key="add-to-playlist"
  188. >
  189. <i
  190. class="material-icons icon-with-button"
  191. >add</i
  192. >
  193. Add to playlist
  194. </a>
  195. </transition>
  196. </div>
  197. </search-query-item>
  198. <a
  199. class="button is-primary load-more-button"
  200. @click.prevent="loadMoreSongs()"
  201. href="#"
  202. >
  203. Load more...
  204. </a>
  205. </div>
  206. </div>
  207. </div>
  208. <div id="second-column">
  209. <div id="rearrange-songs-section" class="section">
  210. <div v-if="isEditable()">
  211. <h4 class="section-title">Rearrange Songs</h4>
  212. <p class="section-description">
  213. Drag and drop songs to change their order
  214. </p>
  215. <hr class="section-horizontal-rule" />
  216. </div>
  217. <aside class="menu">
  218. <draggable
  219. class="menu-list scrollable-list"
  220. tag="ul"
  221. v-if="playlist.songs.length > 0"
  222. v-model="playlist.songs"
  223. v-bind="dragOptions"
  224. @start="drag = true"
  225. @end="drag = false"
  226. @change="updateSongPositioning"
  227. >
  228. <transition-group
  229. type="transition"
  230. :name="
  231. !drag ? 'draggable-list-transition' : null
  232. "
  233. >
  234. <li
  235. v-for="(song, index) in playlist.songs"
  236. :key="`key-${song._id}`"
  237. >
  238. <song-item
  239. :song="song"
  240. :class="{
  241. 'item-draggable': isEditable()
  242. }"
  243. >
  244. <div
  245. class="song-actions"
  246. slot="actions"
  247. >
  248. <i
  249. class="material-icons add-to-queue-icon"
  250. v-if="
  251. station.partyMode &&
  252. !station.locked
  253. "
  254. @click="
  255. addSongToQueue(
  256. song.youtubeId
  257. )
  258. "
  259. content="Add Song to Queue"
  260. v-tippy
  261. >queue</i
  262. >
  263. <confirm
  264. v-if="
  265. userId ===
  266. playlist.createdBy ||
  267. isEditable()
  268. "
  269. placement="left"
  270. @confirm="
  271. removeSongFromPlaylist(
  272. song.youtubeId
  273. )
  274. "
  275. >
  276. <i
  277. class="material-icons delete-icon"
  278. content="Remove Song from Playlist"
  279. v-tippy
  280. >delete_forever</i
  281. >
  282. </confirm>
  283. <i
  284. class="material-icons"
  285. v-if="isEditable() && index > 0"
  286. @click="moveSongToTop(index)"
  287. content="Move to top of Playlist"
  288. v-tippy
  289. >vertical_align_top</i
  290. >
  291. <i
  292. v-if="
  293. isEditable() &&
  294. playlist.songs.length -
  295. 1 !==
  296. index
  297. "
  298. @click="moveSongToBottom(index)"
  299. class="material-icons"
  300. content="Move to bottom of Playlist"
  301. v-tippy
  302. >vertical_align_bottom</i
  303. >
  304. </div>
  305. </song-item>
  306. </li>
  307. </transition-group>
  308. </draggable>
  309. <p v-else class="nothing-here-text">
  310. This playlist doesn't have any songs.
  311. </p>
  312. </aside>
  313. </div>
  314. </div>
  315. <!--
  316. <button
  317. class="button is-info"
  318. @click="shuffle()"
  319. v-if="playlist.isUserModifiable"
  320. >
  321. Shuffle
  322. </button>
  323. <h5>Edit playlist details:</h5>
  324. -->
  325. </div>
  326. <div slot="footer">
  327. <a
  328. class="button is-default"
  329. v-if="
  330. this.userId === this.playlist.createdBy ||
  331. isEditable() ||
  332. this.playlist.privacy === 'public'
  333. "
  334. @click="downloadPlaylist()"
  335. href="#"
  336. >
  337. Download Playlist
  338. </a>
  339. <div class="right">
  340. <confirm
  341. v-if="playlist.type === 'station'"
  342. @confirm="clearAndRefillStationPlaylist()"
  343. >
  344. <a class="button is-danger">
  345. Clear and refill station playlist
  346. </a>
  347. </confirm>
  348. <confirm
  349. v-if="playlist.type === 'genre'"
  350. @confirm="clearAndRefillGenrePlaylist()"
  351. >
  352. <a class="button is-danger">
  353. Clear and refill genre playlist
  354. </a>
  355. </confirm>
  356. <confirm v-if="isEditable()" @confirm="removePlaylist()">
  357. <a class="button is-danger"> Remove Playlist </a>
  358. </confirm>
  359. </div>
  360. </div>
  361. </modal>
  362. </template>
  363. <script>
  364. import { mapState, mapGetters, mapActions } from "vuex";
  365. import draggable from "vuedraggable";
  366. import Toast from "toasters";
  367. import SearchYoutube from "@/mixins/SearchYoutube.vue";
  368. import validation from "@/validation";
  369. import Confirm from "@/components/Confirm.vue";
  370. import Modal from "../Modal.vue";
  371. import SearchQueryItem from "../SearchQueryItem.vue";
  372. import SongItem from "../SongItem.vue";
  373. import utils from "../../../js/utils";
  374. export default {
  375. components: { Modal, draggable, Confirm, SearchQueryItem, SongItem },
  376. mixins: [SearchYoutube],
  377. data() {
  378. return {
  379. utils,
  380. drag: false,
  381. apiDomain: "",
  382. playlist: { songs: [] }
  383. };
  384. },
  385. computed: {
  386. ...mapState("station", {
  387. station: state => state.station
  388. }),
  389. ...mapState("user/playlists", {
  390. editing: state => state.editing
  391. }),
  392. ...mapState({
  393. userId: state => state.user.auth.userId,
  394. userRole: state => state.user.auth.role
  395. }),
  396. dragOptions() {
  397. return {
  398. animation: 200,
  399. group: "songs",
  400. disabled: !this.isEditable(),
  401. ghostClass: "draggable-list-ghost"
  402. };
  403. },
  404. ...mapGetters({
  405. socket: "websockets/getSocket"
  406. })
  407. },
  408. watch: {
  409. "search.songs.results": function checkIfSongInPlaylist(songs) {
  410. songs.forEach((searchItem, index) =>
  411. this.playlist.songs.find(song => {
  412. if (song.youtubeId === searchItem.id)
  413. this.search.songs.results[index].isAddedToQueue = true;
  414. return song.youtubeId === searchItem.id;
  415. })
  416. );
  417. }
  418. },
  419. mounted() {
  420. this.socket.dispatch("playlists.getPlaylist", this.editing, res => {
  421. if (res.status === "success") {
  422. this.playlist = res.data.playlist;
  423. this.playlist.songs.sort((a, b) => a.position - b.position);
  424. this.playlist.oldId = res.data.playlist._id;
  425. } else new Toast(res.message);
  426. });
  427. this.socket.on(
  428. "event:playlist.addSong",
  429. res => {
  430. if (this.playlist._id === res.data.playlistId)
  431. this.playlist.songs.push(res.data.song);
  432. },
  433. {
  434. modal: "editPlaylist"
  435. }
  436. );
  437. this.socket.on(
  438. "event:playlist.removeSong",
  439. res => {
  440. if (this.playlist._id === res.data.playlistId) {
  441. // remove song from array of playlists
  442. this.playlist.songs.forEach((song, index) => {
  443. if (song.youtubeId === res.data.youtubeId)
  444. this.playlist.songs.splice(index, 1);
  445. });
  446. // if this song is in search results, mark it available to add to the playlist again
  447. this.search.songs.results.forEach((searchItem, index) => {
  448. if (res.data.youtubeId === searchItem.id) {
  449. this.search.songs.results[
  450. index
  451. ].isAddedToQueue = false;
  452. }
  453. });
  454. }
  455. },
  456. {
  457. modal: "editPlaylist"
  458. }
  459. );
  460. this.socket.on(
  461. "event:playlist.updateDisplayName",
  462. res => {
  463. if (this.playlist._id === res.data.playlistId)
  464. this.playlist.displayName = res.data.displayName;
  465. },
  466. {
  467. modal: "editPlaylist"
  468. }
  469. );
  470. this.socket.on(
  471. "event:playlist.repositionSongs",
  472. res => {
  473. if (this.playlist._id === res.data.playlistId) {
  474. // for each song that has a new position
  475. res.data.songsBeingChanged.forEach(changedSong => {
  476. this.playlist.songs.forEach((song, index) => {
  477. // find song locally
  478. if (song.youtubeId === changedSong.youtubeId) {
  479. // change song position attribute
  480. this.playlist.songs[index].position =
  481. changedSong.position;
  482. // reposition in array if needed
  483. if (index !== changedSong.position - 1)
  484. this.playlist.songs.splice(
  485. changedSong.position - 1,
  486. 0,
  487. this.playlist.songs.splice(index, 1)[0]
  488. );
  489. }
  490. });
  491. });
  492. }
  493. },
  494. {
  495. modal: "editPlaylist"
  496. }
  497. );
  498. },
  499. methods: {
  500. importPlaylist() {
  501. let isImportingPlaylist = true;
  502. // import query is blank
  503. if (!this.search.playlist.query)
  504. return new Toast("Please enter a YouTube playlist URL.");
  505. const regex = new RegExp(`[\\?&]list=([^&#]*)`);
  506. const splitQuery = regex.exec(this.search.playlist.query);
  507. if (!splitQuery) {
  508. return new Toast({
  509. content: "Please enter a valid YouTube playlist URL.",
  510. timeout: 4000
  511. });
  512. }
  513. // don't give starting import message instantly in case of instant error
  514. setTimeout(() => {
  515. if (isImportingPlaylist) {
  516. new Toast(
  517. "Starting to import your playlist. This can take some time to do."
  518. );
  519. }
  520. }, 750);
  521. return this.socket.dispatch(
  522. "playlists.addSetToPlaylist",
  523. this.search.playlist.query,
  524. this.playlist._id,
  525. this.search.playlist.isImportingOnlyMusic,
  526. res => {
  527. new Toast({ content: res.message, timeout: 20000 });
  528. if (res.status === "success") {
  529. isImportingPlaylist = false;
  530. if (this.search.playlist.isImportingOnlyMusic) {
  531. new Toast({
  532. content: `${res.data.stats.songsInPlaylistTotal} of the ${res.data.stats.videosInPlaylistTotal} videos in the playlist were songs.`,
  533. timeout: 20000
  534. });
  535. }
  536. }
  537. }
  538. );
  539. },
  540. isEditable() {
  541. return (
  542. this.playlist.isUserModifiable &&
  543. (this.userId === this.playlist.createdBy ||
  544. this.userRole === "admin")
  545. );
  546. },
  547. isAdmin() {
  548. return this.userRole === "admin";
  549. },
  550. updateSongPositioning({ moved }) {
  551. if (!moved) return; // we only need to update when song is moved
  552. const songsBeingChanged = [];
  553. this.playlist.songs.forEach((song, index) => {
  554. if (song.position !== index + 1)
  555. songsBeingChanged.push({
  556. youtubeId: song.youtubeId,
  557. position: index + 1
  558. });
  559. });
  560. this.socket.dispatch(
  561. "playlists.repositionSongs",
  562. this.playlist._id,
  563. songsBeingChanged,
  564. res => {
  565. new Toast(res.message);
  566. }
  567. );
  568. },
  569. totalLength() {
  570. let length = 0;
  571. this.playlist.songs.forEach(song => {
  572. length += song.duration;
  573. });
  574. return this.utils.formatTimeLong(length);
  575. },
  576. shuffle() {
  577. this.socket.dispatch(
  578. "playlists.shuffle",
  579. this.playlist._id,
  580. res => {
  581. new Toast(res.message);
  582. if (res.status === "success") {
  583. this.playlist.songs = res.data.playlist.songs.sort(
  584. (a, b) => a.position - b.position
  585. );
  586. }
  587. }
  588. );
  589. },
  590. addSongToPlaylist(id, index) {
  591. this.socket.dispatch(
  592. "playlists.addSongToPlaylist",
  593. false,
  594. id,
  595. this.playlist._id,
  596. res => {
  597. new Toast(res.message);
  598. if (res.status === "success")
  599. this.search.songs.results[index].isAddedToQueue = true;
  600. }
  601. );
  602. },
  603. removeSongFromPlaylist(id) {
  604. if (this.playlist.displayName === "Liked Songs") {
  605. this.socket.dispatch("songs.unlike", id, res => {
  606. new Toast(res.message);
  607. });
  608. }
  609. if (this.playlist.displayName === "Disliked Songs") {
  610. this.socket.dispatch("songs.undislike", id, res => {
  611. new Toast(res.message);
  612. });
  613. } else {
  614. this.socket.dispatch(
  615. "playlists.removeSongFromPlaylist",
  616. id,
  617. this.playlist._id,
  618. res => {
  619. new Toast(res.message);
  620. }
  621. );
  622. }
  623. },
  624. renamePlaylist() {
  625. const { displayName } = this.playlist;
  626. if (!validation.isLength(displayName, 2, 32))
  627. return new Toast(
  628. "Display name must have between 2 and 32 characters."
  629. );
  630. if (!validation.regex.ascii.test(displayName))
  631. return new Toast(
  632. "Invalid display name format. Only ASCII characters are allowed."
  633. );
  634. return this.socket.dispatch(
  635. "playlists.updateDisplayName",
  636. this.playlist._id,
  637. this.playlist.displayName,
  638. res => {
  639. new Toast(res.message);
  640. }
  641. );
  642. },
  643. removePlaylist() {
  644. this.socket.dispatch("playlists.remove", this.playlist._id, res => {
  645. new Toast(res.message);
  646. if (res.status === "success") this.closeModal("editPlaylist");
  647. });
  648. },
  649. async downloadPlaylist() {
  650. if (this.apiDomain === "")
  651. this.apiDomain = await lofig.get("apiDomain");
  652. fetch(
  653. `${this.apiDomain}/export/privatePlaylist/${this.playlist._id}`,
  654. { credentials: "include" }
  655. )
  656. .then(res => res.blob())
  657. .then(blob => {
  658. const url = window.URL.createObjectURL(blob);
  659. const a = document.createElement("a");
  660. a.style.display = "none";
  661. a.href = url;
  662. a.download = `musare-privateplaylist-${
  663. this.playlist._id
  664. }-${new Date().toISOString()}.json`;
  665. document.body.appendChild(a);
  666. a.click();
  667. window.URL.revokeObjectURL(url);
  668. new Toast("Successfully downloaded playlist.");
  669. })
  670. .catch(
  671. () => new Toast("Failed to export and download playlist.")
  672. );
  673. },
  674. moveSongToTop(index) {
  675. this.playlist.songs.splice(
  676. 0,
  677. 0,
  678. this.playlist.songs.splice(index, 1)[0]
  679. );
  680. this.updateSongPositioning({ moved: {} });
  681. },
  682. moveSongToBottom(index) {
  683. this.playlist.songs.splice(
  684. this.playlist.songs.length,
  685. 0,
  686. this.playlist.songs.splice(index, 1)[0]
  687. );
  688. this.updateSongPositioning({ moved: {} });
  689. },
  690. updatePrivacy() {
  691. const { privacy } = this.playlist;
  692. if (privacy === "public" || privacy === "private") {
  693. this.socket.dispatch(
  694. "playlists.updatePrivacy",
  695. this.playlist._id,
  696. privacy,
  697. res => {
  698. new Toast(res.message);
  699. }
  700. );
  701. }
  702. },
  703. addSongToQueue(youtubeId) {
  704. this.socket.dispatch(
  705. "stations.addToQueue",
  706. this.station._id,
  707. youtubeId,
  708. data => {
  709. if (data.status !== "success")
  710. new Toast({
  711. content: `Error: ${data.message}`,
  712. timeout: 8000
  713. });
  714. else new Toast({ content: data.message, timeout: 4000 });
  715. }
  716. );
  717. },
  718. clearAndRefillStationPlaylist() {
  719. this.socket.dispatch(
  720. "playlists.clearAndRefillStationPlaylist",
  721. this.playlist._id,
  722. data => {
  723. console.log(data.message);
  724. if (data.status !== "success")
  725. new Toast({
  726. content: `Error: ${data.message}`,
  727. timeout: 8000
  728. });
  729. else new Toast({ content: data.message, timeout: 4000 });
  730. }
  731. );
  732. },
  733. clearAndRefillGenrePlaylist() {
  734. this.socket.dispatch(
  735. "playlists.clearAndRefillGenrePlaylist",
  736. this.playlist._id,
  737. data => {
  738. if (data.status !== "success")
  739. new Toast({
  740. content: `Error: ${data.message}`,
  741. timeout: 8000
  742. });
  743. else new Toast({ content: data.message, timeout: 4000 });
  744. }
  745. );
  746. },
  747. ...mapActions("modalVisibility", ["openModal", "closeModal"])
  748. }
  749. };
  750. </script>
  751. <style lang="scss">
  752. .edit-playlist-modal {
  753. .modal-card {
  754. width: 1300px;
  755. .modal-card-body {
  756. padding: 16px;
  757. }
  758. }
  759. }
  760. </style>
  761. <style lang="scss" scoped>
  762. .night-mode {
  763. .label,
  764. p,
  765. strong {
  766. color: var(--light-grey-2);
  767. }
  768. }
  769. .menu-list li {
  770. display: flex;
  771. justify-content: space-between;
  772. &:not(:last-of-type) {
  773. margin-bottom: 10px;
  774. }
  775. a {
  776. display: flex;
  777. }
  778. }
  779. .controls {
  780. display: flex;
  781. a {
  782. display: flex;
  783. align-items: center;
  784. }
  785. }
  786. @media screen and (max-width: 1300px) {
  787. .edit-playlist-modal .edit-playlist-modal-inner-container {
  788. height: auto !important;
  789. #import-from-youtube-section #song-query-results,
  790. .section {
  791. max-width: 100% !important;
  792. }
  793. }
  794. }
  795. .edit-playlist-modal {
  796. .edit-playlist-modal-inner-container {
  797. display: flex;
  798. flex-wrap: wrap;
  799. height: 100%;
  800. &.view-only {
  801. height: auto !important;
  802. #first-column {
  803. flex-basis: 100%;
  804. }
  805. .section {
  806. max-width: 100% !important;
  807. }
  808. }
  809. }
  810. .nothing-here-text {
  811. display: flex;
  812. align-items: center;
  813. justify-content: center;
  814. }
  815. .section {
  816. padding: 15px !important;
  817. margin: 0 10px;
  818. max-width: 600px;
  819. display: flex;
  820. flex-direction: column;
  821. flex-grow: 1;
  822. }
  823. .label {
  824. font-size: 1rem;
  825. font-weight: normal;
  826. }
  827. .input-with-button .button {
  828. width: 150px;
  829. }
  830. #first-column {
  831. max-width: 100%;
  832. height: 100%;
  833. overflow-y: auto;
  834. flex-grow: 1;
  835. .section {
  836. width: auto;
  837. }
  838. #playlist-info-section {
  839. border: 1px solid var(--light-grey-3);
  840. border-radius: 3px;
  841. padding: 15px !important;
  842. h3 {
  843. font-weight: 600;
  844. font-size: 30px;
  845. }
  846. h5 {
  847. font-size: 18px;
  848. }
  849. h3,
  850. h5 {
  851. margin: 0;
  852. }
  853. }
  854. #import-from-youtube-section {
  855. #playlist-import-type select {
  856. border-radius: 0;
  857. }
  858. #song-query-results {
  859. padding: 10px;
  860. margin-top: 10px;
  861. border: 1px solid var(--light-grey-3);
  862. border-radius: 3px;
  863. max-width: 565px;
  864. .search-query-item:not(:last-of-type) {
  865. margin-bottom: 10px;
  866. }
  867. }
  868. .load-more-button {
  869. width: 100%;
  870. margin-top: 10px;
  871. }
  872. }
  873. }
  874. #second-column {
  875. max-width: 100%;
  876. height: 100%;
  877. overflow-y: auto;
  878. flex-grow: 1;
  879. }
  880. }
  881. </style>