2
0

Settings.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. <script setup lang="ts">
  2. import { defineAsyncComponent, watch } from "vue";
  3. import Toast from "toasters";
  4. import { storeToRefs } from "pinia";
  5. import validation from "@/validation";
  6. import { useWebsocketsStore } from "@/stores/websockets";
  7. import { useManageStationStore } from "@/stores/manageStation";
  8. import { useForm } from "@/composables/useForm";
  9. import { useConfigStore } from "@/stores/config";
  10. const InfoIcon = defineAsyncComponent(
  11. () => import("@/components/InfoIcon.vue")
  12. );
  13. const props = defineProps({
  14. modalUuid: { type: String, required: true }
  15. });
  16. const { socket } = useWebsocketsStore();
  17. const configStore = useConfigStore();
  18. const { experimental } = storeToRefs(configStore);
  19. const manageStationStore = useManageStationStore({
  20. modalUuid: props.modalUuid
  21. });
  22. const { station } = storeToRefs(manageStationStore);
  23. const { editStation } = manageStationStore;
  24. const { inputs, save, setOriginalValue } = useForm(
  25. {
  26. name: {
  27. value: station.value.name,
  28. validate: value => {
  29. if (!validation.isLength(value, 2, 16))
  30. return "Name must have between 2 and 16 characters.";
  31. if (!validation.regex.az09_.test(value))
  32. return "Invalid name format. Allowed characters: a-z, 0-9 and _.";
  33. return true;
  34. }
  35. },
  36. displayName: {
  37. value: station.value.displayName,
  38. validate: value => {
  39. if (!validation.isLength(value, 2, 32))
  40. return "Display name must have between 2 and 32 characters.";
  41. if (!validation.regex.ascii.test(value))
  42. return "Invalid display name format. Only ASCII characters are allowed.";
  43. return true;
  44. }
  45. },
  46. description: {
  47. value: station.value.description,
  48. validate: value => {
  49. if (
  50. value
  51. .split("")
  52. .filter(character => character.charCodeAt(0) === 21328)
  53. .length !== 0
  54. )
  55. return "Invalid description format.";
  56. return true;
  57. }
  58. },
  59. theme: station.value.theme,
  60. privacy: station.value.privacy,
  61. skipVoteThreshold: station.value.skipVoteThreshold,
  62. requestsEnabled: station.value.requests.enabled,
  63. requestsAccess: station.value.requests.access,
  64. requestsLimit: station.value.requests.limit,
  65. requestsAllowAutorequest: station.value.requests.allowAutorequest,
  66. requestsAutorequestLimit: {
  67. value: station.value.requests.autorequestLimit,
  68. validate: (value, newInputs) => {
  69. if (value > newInputs.value.requestsLimit.value)
  70. return "The autorequest limit cannot be higher than the request limit.";
  71. return true;
  72. }
  73. },
  74. requestsAutorequestDisallowRecentlyPlayedEnabled:
  75. station.value.requests.autorequestDisallowRecentlyPlayedEnabled,
  76. requestsAutorequestDisallowRecentlyPlayedNumber:
  77. station.value.requests.autorequestDisallowRecentlyPlayedNumber,
  78. autofillEnabled: station.value.autofill.enabled,
  79. autofillLimit: station.value.autofill.limit,
  80. autofillMode: station.value.autofill.mode
  81. },
  82. ({ status, messages, values }, resolve, reject) => {
  83. if (status === "success") {
  84. const oldStation = JSON.parse(JSON.stringify(station.value));
  85. const updatedStation = {
  86. ...oldStation,
  87. name: values.name,
  88. displayName: values.displayName,
  89. description: values.description,
  90. theme: values.theme,
  91. privacy: values.privacy,
  92. skipVoteThreshold: values.skipVoteThreshold,
  93. requests: {
  94. ...oldStation.requests,
  95. enabled: values.requestsEnabled,
  96. access: values.requestsAccess,
  97. limit: values.requestsLimit,
  98. allowAutorequest: values.requestsAllowAutorequest,
  99. autorequestLimit: values.requestsAutorequestLimit,
  100. autorequestDisallowRecentlyPlayedEnabled:
  101. values.requestsAutorequestDisallowRecentlyPlayedEnabled,
  102. autorequestDisallowRecentlyPlayedNumber:
  103. values.requestsAutorequestDisallowRecentlyPlayedNumber
  104. },
  105. autofill: {
  106. ...oldStation.autofill,
  107. enabled: values.autofillEnabled,
  108. limit: values.autofillLimit,
  109. mode: values.autofillMode
  110. }
  111. };
  112. socket.dispatch(
  113. "stations.update",
  114. station.value._id,
  115. updatedStation,
  116. res => {
  117. if (res.status === "success") {
  118. editStation(updatedStation);
  119. new Toast(res.message);
  120. resolve();
  121. } else reject(new Error(res.message));
  122. }
  123. );
  124. } else {
  125. Object.values(messages).forEach(message => {
  126. new Toast({ content: message, timeout: 8000 });
  127. });
  128. resolve();
  129. }
  130. },
  131. {
  132. modalUuid: props.modalUuid
  133. }
  134. );
  135. watch(station, value => {
  136. setOriginalValue({
  137. name: value.name,
  138. displayName: value.displayName,
  139. description: value.description,
  140. theme: value.theme,
  141. privacy: value.privacy,
  142. skipVoteThreshold: value.skipVoteThreshold,
  143. requestsEnabled: value.requests.enabled,
  144. requestsAccess: value.requests.access,
  145. requestsLimit: value.requests.limit,
  146. requestsAllowAutorequest: value.requests.allowAutorequest,
  147. requestsAutorequestLimit: value.requests.autorequestLimit,
  148. requestsAutorequestDisallowRecentlyPlayedEnabled:
  149. value.requests.autorequestDisallowRecentlyPlayedEnabled,
  150. requestsAutorequestDisallowRecentlyPlayedNumber:
  151. value.requests.autorequestDisallowRecentlyPlayedNumber,
  152. autofillEnabled: value.autofill.enabled,
  153. autofillLimit: value.autofill.limit,
  154. autofillMode: value.autofill.mode
  155. });
  156. });
  157. </script>
  158. <template>
  159. <div class="station-settings">
  160. <label class="label">Name</label>
  161. <div class="control is-expanded">
  162. <input class="input" type="text" v-model="inputs['name'].value" />
  163. </div>
  164. <label class="label">Display Name</label>
  165. <div class="control is-expanded">
  166. <input
  167. class="input"
  168. type="text"
  169. v-model="inputs['displayName'].value"
  170. />
  171. </div>
  172. <label class="label">Description</label>
  173. <div class="control is-expanded">
  174. <input
  175. class="input"
  176. type="text"
  177. v-model="inputs['description'].value"
  178. />
  179. </div>
  180. <div class="settings-buttons">
  181. <div class="small-section">
  182. <label class="label">Theme</label>
  183. <div class="control is-expanded select">
  184. <select v-model="inputs['theme'].value">
  185. <option value="blue" selected>Blue</option>
  186. <option value="purple">Purple</option>
  187. <option value="teal">Teal</option>
  188. <option value="orange">Orange</option>
  189. <option value="red">Red</option>
  190. </select>
  191. </div>
  192. </div>
  193. <div class="small-section">
  194. <label class="label">Privacy</label>
  195. <div class="control is-expanded select">
  196. <select v-model="inputs['privacy'].value">
  197. <option value="public">Public</option>
  198. <option value="unlisted">Unlisted</option>
  199. <option value="private" selected>Private</option>
  200. </select>
  201. </div>
  202. </div>
  203. <div class="small-section">
  204. <label class="label">
  205. Skip Vote Threshold
  206. <info-icon
  207. tooltip="The % of logged-in station users required to vote to skip a song"
  208. />
  209. </label>
  210. <div class="control is-expanded input-slider">
  211. <input
  212. v-model="inputs['skipVoteThreshold'].value"
  213. type="range"
  214. min="0"
  215. max="100"
  216. />
  217. <span>{{ inputs["skipVoteThreshold"].value }}%</span>
  218. </div>
  219. </div>
  220. <div
  221. class="requests-settings"
  222. :class="{ enabled: inputs['requestsEnabled'].value }"
  223. >
  224. <div class="toggle-row">
  225. <label class="label">
  226. Requests
  227. <info-icon
  228. tooltip="Allow users to add songs to the queue"
  229. />
  230. </label>
  231. <p class="is-expanded checkbox-control">
  232. <label class="switch">
  233. <input
  234. type="checkbox"
  235. id="toggle-requests"
  236. v-model="inputs['requestsEnabled'].value"
  237. />
  238. <span class="slider round"></span>
  239. </label>
  240. <label for="toggle-requests">
  241. <p>
  242. {{
  243. inputs["requestsEnabled"].value
  244. ? "Enabled"
  245. : "Disabled"
  246. }}
  247. </p>
  248. </label>
  249. </p>
  250. </div>
  251. <template v-if="inputs['requestsEnabled'].value">
  252. <div class="small-section">
  253. <label class="label">Minimum access</label>
  254. <div class="control is-expanded select">
  255. <select v-model="inputs['requestsAccess'].value">
  256. <option value="owner" selected>Owner</option>
  257. <option value="user">User</option>
  258. </select>
  259. </div>
  260. </div>
  261. <div class="small-section">
  262. <label class="label">Per user request limit</label>
  263. <div class="control is-expanded">
  264. <input
  265. class="input"
  266. type="number"
  267. min="1"
  268. max="50"
  269. v-model="inputs['requestsLimit'].value"
  270. />
  271. </div>
  272. </div>
  273. <div class="small-section">
  274. <label class="label">Allow autorequest</label>
  275. <p class="is-expanded checkbox-control">
  276. <label class="switch">
  277. <input
  278. type="checkbox"
  279. v-model="
  280. inputs['requestsAllowAutorequest'].value
  281. "
  282. />
  283. <span class="slider round"></span>
  284. </label>
  285. </p>
  286. </div>
  287. <template v-if="inputs['requestsAllowAutorequest'].value">
  288. <div class="small-section">
  289. <label class="label"
  290. >Per user autorequest limit</label
  291. >
  292. <div class="control is-expanded">
  293. <input
  294. class="input"
  295. type="number"
  296. min="1"
  297. :max="
  298. Math.min(
  299. 50,
  300. inputs['requestsLimit'].value
  301. )
  302. "
  303. v-model="
  304. inputs['requestsAutorequestLimit'].value
  305. "
  306. />
  307. </div>
  308. </div>
  309. <div
  310. class="small-section"
  311. v-if="experimental.station_history"
  312. >
  313. <label class="label"
  314. >Autorequest disallow recent</label
  315. >
  316. <p class="is-expanded checkbox-control">
  317. <label class="switch">
  318. <input
  319. type="checkbox"
  320. v-model="
  321. inputs[
  322. 'requestsAutorequestDisallowRecentlyPlayedEnabled'
  323. ].value
  324. "
  325. />
  326. <span class="slider round"></span>
  327. </label>
  328. </p>
  329. </div>
  330. <div
  331. v-if="
  332. inputs[
  333. 'requestsAutorequestDisallowRecentlyPlayedEnabled'
  334. ].value && experimental.station_history
  335. "
  336. class="small-section"
  337. >
  338. <label class="label"
  339. >Autorequest disallow recent #</label
  340. >
  341. <div class="control is-expanded">
  342. <input
  343. class="input"
  344. type="number"
  345. min="1"
  346. max="250"
  347. v-model="
  348. inputs[
  349. 'requestsAutorequestDisallowRecentlyPlayedNumber'
  350. ].value
  351. "
  352. />
  353. </div>
  354. </div>
  355. </template>
  356. </template>
  357. </div>
  358. <div
  359. class="autofill-settings"
  360. :class="{ enabled: inputs['autofillEnabled'].value }"
  361. >
  362. <div class="toggle-row">
  363. <label class="label">
  364. Autofill
  365. <info-icon
  366. tooltip="Automatically fill the queue with songs"
  367. />
  368. </label>
  369. <p class="is-expanded checkbox-control">
  370. <label class="switch">
  371. <input
  372. type="checkbox"
  373. id="toggle-autofill"
  374. v-model="inputs['autofillEnabled'].value"
  375. />
  376. <span class="slider round"></span>
  377. </label>
  378. <label for="toggle-autofill">
  379. <p>
  380. {{
  381. inputs["autofillEnabled"].value
  382. ? "Enabled"
  383. : "Disabled"
  384. }}
  385. </p>
  386. </label>
  387. </p>
  388. </div>
  389. <template v-if="inputs['autofillEnabled'].value">
  390. <div class="small-section">
  391. <label class="label">Song limit</label>
  392. <div class="control is-expanded">
  393. <input
  394. class="input"
  395. type="number"
  396. min="1"
  397. max="50"
  398. v-model="inputs['autofillLimit'].value"
  399. />
  400. </div>
  401. </div>
  402. <div class="small-section">
  403. <label class="label">Play mode</label>
  404. <div class="control is-expanded select">
  405. <select v-model="inputs['autofillMode'].value">
  406. <option value="random" selected>Random</option>
  407. <option value="sequential">Sequential</option>
  408. </select>
  409. </div>
  410. </div>
  411. </template>
  412. </div>
  413. </div>
  414. <button class="control is-expanded button is-primary" @click="save()">
  415. Save Changes
  416. </button>
  417. </div>
  418. </template>
  419. <style lang="less" scoped>
  420. .night-mode {
  421. .requests-settings,
  422. .autofill-settings {
  423. background-color: var(--dark-grey-2) !important;
  424. }
  425. }
  426. .station-settings {
  427. .settings-buttons {
  428. display: flex;
  429. justify-content: center;
  430. flex-wrap: wrap;
  431. .small-section {
  432. width: calc(50% - 10px);
  433. min-width: 150px;
  434. margin: 5px auto;
  435. &:nth-child(odd) {
  436. margin-left: 0;
  437. }
  438. &:nth-child(even) {
  439. margin-right: 0;
  440. }
  441. }
  442. }
  443. .input-slider {
  444. display: flex;
  445. input[type="range"] {
  446. -webkit-appearance: none;
  447. margin: 0;
  448. padding: 0;
  449. width: 100%;
  450. min-width: 100px;
  451. background: transparent;
  452. }
  453. input[type="range"]:focus {
  454. outline: none;
  455. }
  456. input[type="range"]::-webkit-slider-runnable-track {
  457. width: 100%;
  458. height: 5.2px;
  459. cursor: pointer;
  460. box-shadow: 0;
  461. background: var(--light-grey-3);
  462. border-radius: @border-radius;
  463. border: 0;
  464. }
  465. input[type="range"]::-webkit-slider-thumb {
  466. box-shadow: 0;
  467. border: 0;
  468. height: 19px;
  469. width: 19px;
  470. border-radius: 100%;
  471. background: var(--primary-color);
  472. cursor: pointer;
  473. -webkit-appearance: none;
  474. margin-top: -6.5px;
  475. }
  476. input[type="range"]::-moz-range-track {
  477. width: 100%;
  478. height: 5.2px;
  479. cursor: pointer;
  480. box-shadow: 0;
  481. background: var(--light-grey-3);
  482. border-radius: @border-radius;
  483. border: 0;
  484. }
  485. input[type="range"]::-moz-range-thumb {
  486. box-shadow: 0;
  487. border: 0;
  488. height: 19px;
  489. width: 19px;
  490. border-radius: 100%;
  491. background: var(--primary-color);
  492. cursor: pointer;
  493. -webkit-appearance: none;
  494. margin-top: -6.5px;
  495. }
  496. input[type="range"]::-ms-track {
  497. width: 100%;
  498. height: 5.2px;
  499. cursor: pointer;
  500. box-shadow: 0;
  501. background: var(--light-grey-3);
  502. border-radius: @border-radius;
  503. }
  504. input[type="range"]::-ms-fill-lower {
  505. background: var(--light-grey-3);
  506. border: 0;
  507. border-radius: 0;
  508. box-shadow: 0;
  509. }
  510. input[type="range"]::-ms-fill-upper {
  511. background: var(--light-grey-3);
  512. border: 0;
  513. border-radius: 0;
  514. box-shadow: 0;
  515. }
  516. input[type="range"]::-ms-thumb {
  517. box-shadow: 0;
  518. border: 0;
  519. height: 15px;
  520. width: 15px;
  521. border-radius: 100%;
  522. background: var(--primary-color);
  523. cursor: pointer;
  524. -webkit-appearance: none;
  525. margin-top: 1.5px;
  526. }
  527. & > span {
  528. min-width: 40px;
  529. margin-left: 10px;
  530. text-align: center;
  531. }
  532. }
  533. .requests-settings,
  534. .autofill-settings {
  535. display: flex;
  536. flex-wrap: wrap;
  537. width: 100%;
  538. margin: 10px 0;
  539. padding: 10px;
  540. border-radius: @border-radius;
  541. box-shadow: @box-shadow;
  542. .toggle-row {
  543. display: flex;
  544. width: 100%;
  545. line-height: 36px;
  546. .label {
  547. font-size: 18px;
  548. margin: 0;
  549. }
  550. }
  551. .label {
  552. display: flex;
  553. flex-grow: 1;
  554. }
  555. > .checkbox-control {
  556. justify-content: end;
  557. }
  558. .small-section {
  559. &:nth-child(even) {
  560. margin-left: 0;
  561. margin-right: auto;
  562. }
  563. &:nth-child(odd) {
  564. margin-left: auto;
  565. margin-right: 0;
  566. }
  567. .checkbox-control {
  568. justify-content: center;
  569. }
  570. }
  571. }
  572. }
  573. </style>