Users.vue 13 KB

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