EditPlaylist.vue 20 KB

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