Users.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. <script setup lang="ts">
  2. import { useRoute } from "vue-router";
  3. import {
  4. defineAsyncComponent,
  5. ref,
  6. reactive,
  7. computed,
  8. watch,
  9. onMounted
  10. } from "vue";
  11. import Toast from "toasters";
  12. import { storeToRefs } from "pinia";
  13. import { useWebsocketsStore } from "@/stores/websockets";
  14. import { useStationStore } from "@/stores/station";
  15. const ProfilePicture = defineAsyncComponent(
  16. () => import("@/components/ProfilePicture.vue")
  17. );
  18. const stationStore = useStationStore();
  19. const route = useRoute();
  20. const notesUri = ref("");
  21. const frontendDomain = ref("");
  22. const tab = ref("active");
  23. const tabs = ref([]);
  24. const search = reactive({
  25. query: "",
  26. searchedQuery: "",
  27. page: 0,
  28. count: 0,
  29. resultsLeft: 0,
  30. pageSize: 0,
  31. results: []
  32. });
  33. const { socket } = useWebsocketsStore();
  34. const { station, users, userCount } = storeToRefs(stationStore);
  35. const isOwner = userId => station.value.owner === userId;
  36. const isDj = userId => !!station.value.djs.find(dj => dj._id === userId);
  37. const sortedUsers = computed(() =>
  38. users.value && users.value.loggedIn
  39. ? users.value.loggedIn
  40. .slice()
  41. .sort(
  42. (a, b) =>
  43. Number(isOwner(b._id)) - Number(isOwner(a._id)) ||
  44. Number(!isOwner(a._id)) - Number(!isOwner(b._id))
  45. )
  46. : []
  47. );
  48. const resultsLeftCount = computed(() => search.count - search.results.length);
  49. const nextPageResultsCount = computed(() =>
  50. Math.min(search.pageSize, resultsLeftCount.value)
  51. );
  52. const { hasPermission } = stationStore;
  53. const copyToClipboard = async () => {
  54. try {
  55. await navigator.clipboard.writeText(
  56. frontendDomain.value + route.fullPath
  57. );
  58. } catch (err) {
  59. new Toast("Failed to copy to clipboard.");
  60. }
  61. };
  62. const showTab = _tab => {
  63. tabs.value[`${_tab}-tab`].scrollIntoView({ block: "nearest" });
  64. tab.value = _tab;
  65. };
  66. const addDj = userId => {
  67. socket.dispatch("stations.addDj", station.value._id, userId, res => {
  68. new Toast(res.message);
  69. });
  70. };
  71. const removeDj = userId => {
  72. socket.dispatch("stations.removeDj", station.value._id, userId, res => {
  73. new Toast(res.message);
  74. });
  75. };
  76. const searchForUser = page => {
  77. if (search.page >= page || search.searchedQuery !== search.query) {
  78. search.results = [];
  79. search.page = 0;
  80. search.count = 0;
  81. search.resultsLeft = 0;
  82. search.pageSize = 0;
  83. }
  84. search.searchedQuery = search.query;
  85. socket.dispatch("users.search", search.query, page, res => {
  86. const { data } = res;
  87. if (res.status === "success") {
  88. const { count, pageSize, users } = data;
  89. search.results = [...search.results, ...users];
  90. search.page = page;
  91. search.count = count;
  92. search.resultsLeft = count - search.results.length;
  93. search.pageSize = pageSize;
  94. } else if (res.status === "error") {
  95. search.results = [];
  96. search.page = 0;
  97. search.count = 0;
  98. search.resultsLeft = 0;
  99. search.pageSize = 0;
  100. new Toast(res.message);
  101. }
  102. });
  103. };
  104. watch(
  105. () => hasPermission("stations.update"),
  106. value => {
  107. if (!value && (tab.value === "djs" || tab.value === "add-dj"))
  108. showTab("active");
  109. }
  110. );
  111. onMounted(async () => {
  112. frontendDomain.value = await lofig.get("frontendDomain");
  113. notesUri.value = encodeURI(`${frontendDomain.value}/assets/notes.png`);
  114. });
  115. </script>
  116. <template>
  117. <div id="users">
  118. <div class="tabs-container">
  119. <div
  120. v-if="
  121. hasPermission('stations.update') &&
  122. station.type === 'community'
  123. "
  124. class="tab-selection"
  125. >
  126. <button
  127. class="button is-default"
  128. :ref="el => (tabs['active-tab'] = el)"
  129. :class="{ selected: tab === 'active' }"
  130. @click="showTab('active')"
  131. >
  132. Active
  133. </button>
  134. <button
  135. class="button is-default"
  136. :ref="el => (tabs['djs-tab'] = el)"
  137. :class="{ selected: tab === 'djs' }"
  138. @click="showTab('djs')"
  139. >
  140. DJs
  141. </button>
  142. <button
  143. class="button is-default"
  144. :ref="el => (tabs['add-dj-tab'] = el)"
  145. :class="{ selected: tab === 'add-dj' }"
  146. @click="showTab('add-dj')"
  147. >
  148. Add DJ
  149. </button>
  150. </div>
  151. <div class="tab" v-show="tab === 'active'">
  152. <h5 class="has-text-centered">Total users: {{ userCount }}</h5>
  153. <transition-group name="notification-box">
  154. <h6
  155. class="has-text-centered"
  156. v-if="
  157. users.loggedIn &&
  158. users.loggedOut &&
  159. ((users.loggedIn.length === 1 &&
  160. users.loggedOut.length === 0) ||
  161. (users.loggedIn.length === 0 &&
  162. users.loggedOut.length === 1))
  163. "
  164. key="only-me"
  165. >
  166. It's just you in the station!
  167. </h6>
  168. <h6
  169. class="has-text-centered"
  170. v-else-if="
  171. users.loggedIn &&
  172. users.loggedOut &&
  173. users.loggedOut.length > 0
  174. "
  175. key="logged-out-users"
  176. >
  177. {{ users.loggedOut.length }}
  178. {{
  179. users.loggedOut.length > 1 ? "users are" : "user is"
  180. }}
  181. logged-out.
  182. </h6>
  183. </transition-group>
  184. <aside class="menu">
  185. <ul class="menu-list scrollable-list">
  186. <li v-for="user in sortedUsers" :key="user.username">
  187. <router-link
  188. :to="{
  189. name: 'profile',
  190. params: { username: user.username }
  191. }"
  192. target="_blank"
  193. >
  194. <profile-picture
  195. :avatar="user.avatar"
  196. :name="user.name || user.username"
  197. />
  198. {{ user.name || user.username }}
  199. <span
  200. v-if="isOwner(user._id)"
  201. class="material-icons user-rank"
  202. content="Station Owner"
  203. v-tippy="{ theme: 'info' }"
  204. >local_police</span
  205. >
  206. <span
  207. v-else-if="isDj(user._id)"
  208. class="material-icons user-rank"
  209. content="Station DJ"
  210. v-tippy="{ theme: 'info' }"
  211. >shield</span
  212. >
  213. <button
  214. v-if="
  215. hasPermission('stations.djs.add') &&
  216. station.type === 'community' &&
  217. !isDj(user._id) &&
  218. !isOwner(user._id)
  219. "
  220. class="button is-primary material-icons"
  221. @click.prevent="addDj(user._id)"
  222. content="Promote user to DJ"
  223. v-tippy
  224. >
  225. add_moderator
  226. </button>
  227. <button
  228. v-else-if="
  229. hasPermission('stations.djs.remove') &&
  230. station.type === 'community' &&
  231. isDj(user._id)
  232. "
  233. class="button is-danger material-icons"
  234. @click.prevent="removeDj(user._id)"
  235. content="Demote user from DJ"
  236. v-tippy
  237. >
  238. remove_moderator
  239. </button>
  240. </router-link>
  241. </li>
  242. </ul>
  243. </aside>
  244. </div>
  245. <div
  246. v-if="hasPermission('stations.update')"
  247. class="tab"
  248. v-show="tab === 'djs'"
  249. >
  250. <h5 class="has-text-centered">Station DJs</h5>
  251. <h6 v-if="station.djs.length === 0" class="has-text-centered">
  252. There are currently no DJs.
  253. </h6>
  254. <aside class="menu">
  255. <ul class="menu-list scrollable-list">
  256. <li v-for="dj in station.djs" :key="dj._id">
  257. <router-link
  258. :to="{
  259. name: 'profile',
  260. params: { username: dj.username }
  261. }"
  262. target="_blank"
  263. >
  264. <profile-picture
  265. :avatar="dj.avatar"
  266. :name="dj.name || dj.username"
  267. />
  268. {{ dj.name || dj.username }}
  269. <span
  270. class="material-icons user-rank"
  271. content="Station DJ"
  272. v-tippy="{ theme: 'info' }"
  273. >shield</span
  274. >
  275. <button
  276. v-if="hasPermission('stations.djs.remove')"
  277. class="button is-danger material-icons"
  278. @click.prevent="removeDj(dj._id)"
  279. content="Demote user from DJ"
  280. v-tippy
  281. >
  282. remove_moderator
  283. </button>
  284. </router-link>
  285. </li>
  286. </ul>
  287. </aside>
  288. </div>
  289. <div
  290. v-if="hasPermission('stations.update')"
  291. class="tab add-dj-tab"
  292. v-show="tab === 'add-dj'"
  293. >
  294. <h5 class="has-text-centered">Add Station DJ</h5>
  295. <h6 class="has-text-centered">
  296. Search for users to promote to DJ.
  297. </h6>
  298. <div class="control is-grouped input-with-button">
  299. <p class="control is-expanded">
  300. <input
  301. class="input"
  302. type="text"
  303. placeholder="Enter your user query here..."
  304. v-model="search.query"
  305. @keyup.enter="searchForUser(1)"
  306. />
  307. </p>
  308. <p class="control">
  309. <button
  310. class="button is-primary"
  311. @click="searchForUser(1)"
  312. >
  313. <i class="material-icons icon-with-button">search</i
  314. >Search
  315. </button>
  316. </p>
  317. </div>
  318. <aside class="menu">
  319. <ul class="menu-list scrollable-list">
  320. <li v-for="user in search.results" :key="user.username">
  321. <router-link
  322. :to="{
  323. name: 'profile',
  324. params: { username: user.username }
  325. }"
  326. target="_blank"
  327. >
  328. <profile-picture
  329. :avatar="user.avatar"
  330. :name="user.name || user.username"
  331. />
  332. {{ user.name || user.username }}
  333. <span
  334. v-if="isOwner(user._id)"
  335. class="material-icons user-rank"
  336. content="Station Owner"
  337. v-tippy="{ theme: 'info' }"
  338. >local_police</span
  339. >
  340. <span
  341. v-else-if="isDj(user._id)"
  342. class="material-icons user-rank"
  343. content="Station DJ"
  344. v-tippy="{ theme: 'info' }"
  345. >shield</span
  346. >
  347. <button
  348. v-if="
  349. hasPermission('stations.djs.add') &&
  350. station.type === 'community' &&
  351. !isDj(user._id) &&
  352. !isOwner(user._id)
  353. "
  354. class="button is-primary material-icons"
  355. @click.prevent="addDj(user._id)"
  356. content="Promote user to DJ"
  357. v-tippy
  358. >
  359. add_moderator
  360. </button>
  361. <button
  362. v-else-if="
  363. hasPermission('stations.djs.remove') &&
  364. station.type === 'community' &&
  365. isDj(user._id)
  366. "
  367. class="button is-danger material-icons"
  368. @click.prevent="removeDj(user._id)"
  369. content="Demote user from DJ"
  370. v-tippy
  371. >
  372. remove_moderator
  373. </button>
  374. </router-link>
  375. </li>
  376. <button
  377. v-if="resultsLeftCount > 0"
  378. class="button is-primary load-more-button"
  379. @click="searchForUser(search.page + 1)"
  380. >
  381. Load {{ nextPageResultsCount }} more results
  382. </button>
  383. </ul>
  384. </aside>
  385. </div>
  386. </div>
  387. <button
  388. class="button is-primary tab-actionable-button"
  389. @click="copyToClipboard()"
  390. >
  391. <i class="material-icons icon-with-button">share</i>
  392. <span> Share (copy to clipboard) </span>
  393. </button>
  394. </div>
  395. </template>
  396. <style lang="less" scoped>
  397. .night-mode {
  398. #users {
  399. background-color: var(--dark-grey-3) !important;
  400. border: 0 !important;
  401. }
  402. a {
  403. color: var(--light-grey-2);
  404. background-color: var(--dark-grey-2) !important;
  405. border: 0 !important;
  406. &:hover {
  407. color: var(--light-grey) !important;
  408. }
  409. }
  410. .tabs-container .tab-selection .button {
  411. background: var(--dark-grey) !important;
  412. color: var(--white) !important;
  413. }
  414. }
  415. .notification-box-enter-active,
  416. .fade-leave-active {
  417. transition: opacity 0.5s;
  418. }
  419. .notification-box-enter,
  420. .notification-box-leave-to {
  421. opacity: 0;
  422. }
  423. #users {
  424. background-color: var(--white);
  425. margin-bottom: 20px;
  426. border-radius: 0 0 @border-radius @border-radius;
  427. max-height: 100%;
  428. .tabs-container {
  429. padding: 10px;
  430. .tab-selection {
  431. display: flex;
  432. overflow-x: auto;
  433. margin-bottom: 10px;
  434. .button {
  435. border-radius: 0;
  436. border: 0;
  437. text-transform: uppercase;
  438. font-size: 14px;
  439. color: var(--dark-grey-3);
  440. background-color: var(--light-grey-2);
  441. flex-grow: 1;
  442. height: 32px;
  443. &:not(:first-of-type) {
  444. margin-left: 5px;
  445. }
  446. }
  447. .selected {
  448. background-color: var(--primary-color) !important;
  449. color: var(--white) !important;
  450. font-weight: 600;
  451. }
  452. }
  453. .tab {
  454. position: absolute;
  455. height: calc(100% - 120px);
  456. width: calc(100% - 20px);
  457. overflow-y: auto;
  458. .menu {
  459. margin-top: 20px;
  460. width: 100%;
  461. .menu-list {
  462. margin-left: 0;
  463. padding: 0;
  464. &.scrollable-list {
  465. max-height: unset;
  466. }
  467. }
  468. li {
  469. &:not(:first-of-type) {
  470. margin-top: 10px;
  471. }
  472. a {
  473. display: flex;
  474. align-items: center;
  475. padding: 5px 10px;
  476. border: 0.5px var(--light-grey-3) solid;
  477. border-radius: @border-radius;
  478. cursor: pointer;
  479. &:hover {
  480. background-color: var(--light-grey);
  481. color: var(--black);
  482. }
  483. .profile-picture {
  484. margin-right: 10px;
  485. width: 36px;
  486. height: 36px;
  487. }
  488. :deep(.profile-picture.using-initials span) {
  489. font-size: calc(
  490. 36px / 5 * 2
  491. ); // 2/5th of .profile-picture height/width
  492. }
  493. .user-rank {
  494. color: var(--primary-color);
  495. font-size: 18px;
  496. margin: 0 5px;
  497. }
  498. .button {
  499. margin-left: auto;
  500. font-size: 18px;
  501. width: 36px;
  502. }
  503. }
  504. }
  505. }
  506. h5 {
  507. font-size: 20px;
  508. }
  509. &.add-dj-tab {
  510. .control.is-grouped.input-with-button {
  511. margin: 20px 0 0 0 !important;
  512. & > .control {
  513. margin-bottom: 0 !important;
  514. }
  515. }
  516. .menu {
  517. margin-top: 10px;
  518. }
  519. .load-more-button {
  520. width: 100%;
  521. margin-top: 10px;
  522. }
  523. }
  524. }
  525. }
  526. }
  527. </style>