Ugraded all pages to vue 3 & typescript
This commit is contained in:
		| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <section class="not-found" :style="background"> | ||||
|     <section class="not-found" :style="backgroundImageCSS"> | ||||
|       <h1 class="not-found__title">Page Not Found</h1> | ||||
|       <seasoned-button class="button" @click="goBack"> | ||||
|         go back to previous page | ||||
| @@ -9,76 +9,68 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions, mapGetters } from "vuex"; | ||||
| import SeasonedButton from "@/components/ui/SeasonedButton"; | ||||
| <script setup lang="ts"> | ||||
|   import { useStore } from "vuex"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|  | ||||
| export default { | ||||
|   components: { SeasonedButton }, | ||||
|   data() { | ||||
|     return { | ||||
|       background: 'background-image: url("/assets/pulp-fiction.jpg")' | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters("popup", ["isOpen"]) | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("popup", ["close"]), | ||||
|     goBack() { | ||||
|       this.$router.go(-1); | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     if (this.isOpen) this.close(); | ||||
|   const backgroundImageCSS = | ||||
|     'background-image: url("/assets/pulp-fiction.jpg")'; | ||||
|  | ||||
|   const store = useStore(); | ||||
|  | ||||
|   if (store.getters["popup/isOpen"]) { | ||||
|     store.dispatch("popup/close"); | ||||
|   } | ||||
|  | ||||
|   function goBack() { | ||||
|     window.history.go(-2); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .button { | ||||
|   font-size: 1.2rem; | ||||
|   z-index: 10; | ||||
|   .button { | ||||
|     font-size: 1.2rem; | ||||
|     z-index: 10; | ||||
|  | ||||
|   @include mobile { | ||||
|     font-size: 1rem; | ||||
|     width: content; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .not-found { | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   flex-direction: column; | ||||
|   height: calc(100vh - var(--header-size)); | ||||
|   background-size: cover; | ||||
|   background-position: 50% 50%; | ||||
|   background-repeat: no-repeat no-repeat; | ||||
|  | ||||
|   &::before { | ||||
|     content: ""; | ||||
|     position: absolute; | ||||
|     height: calc(100vh - var(--header-size)); | ||||
|     width: 100%; | ||||
|     pointer-events: none; | ||||
|     background: var(--background-40); | ||||
|   } | ||||
|  | ||||
|   &__title { | ||||
|     font-size: 2.5rem; | ||||
|     font-weight: 500; | ||||
|     padding: 0 1rem; | ||||
|     color: var(--text-color); | ||||
|     position: relative; | ||||
|     background-color: var(--background-90); | ||||
|  | ||||
|     @include tablet-min { | ||||
|       font-size: 3.5rem; | ||||
|     @include mobile { | ||||
|       font-size: 1rem; | ||||
|       width: content; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .not-found { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     flex-direction: column; | ||||
|     height: calc(100vh - var(--header-size)); | ||||
|     background-size: cover; | ||||
|     background-position: 50% 50%; | ||||
|     background-repeat: no-repeat no-repeat; | ||||
|  | ||||
|     &::before { | ||||
|       content: ""; | ||||
|       position: absolute; | ||||
|       height: calc(100vh - var(--header-size)); | ||||
|       width: 100%; | ||||
|       pointer-events: none; | ||||
|       background: var(--background-40); | ||||
|     } | ||||
|  | ||||
|     &__title { | ||||
|       font-size: 2.5rem; | ||||
|       font-weight: 500; | ||||
|       padding: 0 1rem; | ||||
|       color: var(--text-color); | ||||
|       position: relative; | ||||
|       background-color: var(--background-90); | ||||
|  | ||||
|       @include tablet-min { | ||||
|         font-size: 3.5rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -12,36 +12,28 @@ | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import LandingBanner from "@/components/LandingBanner"; | ||||
| import ResultsSection from "@/components/ResultsSection"; | ||||
| import { getRequests, getTmdbMovieListByName } from "@/api"; | ||||
| <script setup lang="ts"> | ||||
|   import LandingBanner from "@/components/LandingBanner.vue"; | ||||
|   import ResultsSection from "@/components/ResultsSection.vue"; | ||||
|   import { getRequests, getTmdbMovieListByName } from "../api"; | ||||
|   import type ISection from "../interfaces/ISection"; | ||||
|  | ||||
| export default { | ||||
|   name: "home", | ||||
|   components: { LandingBanner, ResultsSection }, | ||||
|   data() { | ||||
|     return { | ||||
|       imageFile: "/pulp-fiction.jpg", | ||||
|       lists: [ | ||||
|         { | ||||
|           title: "Requests", | ||||
|           apiFunction: getRequests | ||||
|         }, | ||||
|         { | ||||
|           title: "Now playing", | ||||
|           apiFunction: () => getTmdbMovieListByName("now_playing") | ||||
|         }, | ||||
|         { | ||||
|           title: "Upcoming", | ||||
|           apiFunction: () => getTmdbMovieListByName("upcoming") | ||||
|         }, | ||||
|         { | ||||
|           title: "Popular", | ||||
|           apiFunction: () => getTmdbMovieListByName("popular") | ||||
|         } | ||||
|       ] | ||||
|     }; | ||||
|   } | ||||
| }; | ||||
|   const lists: ISection[] = [ | ||||
|     { | ||||
|       title: "Requests", | ||||
|       apiFunction: getRequests | ||||
|     }, | ||||
|     { | ||||
|       title: "Now playing", | ||||
|       apiFunction: () => getTmdbMovieListByName("now_playing") | ||||
|     }, | ||||
|     { | ||||
|       title: "Upcoming", | ||||
|       apiFunction: () => getTmdbMovieListByName("upcoming") | ||||
|     }, | ||||
|     { | ||||
|       title: "Popular", | ||||
|       apiFunction: () => getTmdbMovieListByName("popular") | ||||
|     } | ||||
|   ]; | ||||
| </script> | ||||
|   | ||||
| @@ -1,32 +1,30 @@ | ||||
| <template> | ||||
|   <ResultsSection :title="listName" :apiFunction="getTmdbMovieListByName" /> | ||||
|   <ResultsSection :title="listName" :apiFunction="_getTmdbMovieListByName" /> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ResultsSection from "@/components/ResultsSection"; | ||||
| import { getTmdbMovieListByName } from "@/api"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref } from "vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { useRoute } from "vue-router"; | ||||
|   import ResultsSection from "@/components/ResultsSection.vue"; | ||||
|   import { getTmdbMovieListByName } from "../api"; | ||||
|  | ||||
| export default { | ||||
|   components: { ResultsSection }, | ||||
|   computed: { | ||||
|     listName() { | ||||
|       return this.$route.params.name; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     getTmdbMovieListByName(page) { | ||||
|       return getTmdbMovieListByName(this.listName, page); | ||||
|     } | ||||
|   const route = useRoute(); | ||||
|   const listName: Ref<string | string[]> = ref( | ||||
|     route?.params?.name || "List page" | ||||
|   ); | ||||
|  | ||||
|   function _getTmdbMovieListByName(page: number) { | ||||
|     return getTmdbMovieListByName(listName.value?.toString(), page); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .fullwidth-button { | ||||
|   width: 100%; | ||||
|   margin: 1rem 0; | ||||
|   padding-bottom: 2rem; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| } | ||||
|   .fullwidth-button { | ||||
|     width: 100%; | ||||
|     margin: 1rem 0; | ||||
|     padding-bottom: 2rem; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|   } | ||||
| </style> | ||||
|   | ||||
| @@ -5,10 +5,10 @@ | ||||
|         <h2 class="profile__title">{{ emoji }} Welcome {{ username }}</h2> | ||||
|  | ||||
|         <div class="button--group"> | ||||
|           <seasoned-button @click="toggleSettings">{{ | ||||
|           <seasoned-button @click="toggleSettings" :active="showSettings">{{ | ||||
|             showSettings ? "hide settings" : "show settings" | ||||
|           }}</seasoned-button> | ||||
|           <seasoned-button @click="toggleActivity">{{ | ||||
|           <seasoned-button @click="toggleActivity" :active="showActivity">{{ | ||||
|             showActivity ? "hide activity" : "show activity" | ||||
|           }}</seasoned-button> | ||||
|  | ||||
| @@ -20,7 +20,7 @@ | ||||
|  | ||||
|       <activity v-if="showActivity" /> | ||||
|  | ||||
|       <list-header title="User requests" :info="resultCount" /> | ||||
|       <page-header title="Your requests" :info="resultCount" /> | ||||
|       <results-list v-if="results" :results="results" /> | ||||
|     </div> | ||||
|  | ||||
| @@ -35,147 +35,131 @@ | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapGetters, mapActions } from "vuex"; | ||||
| import ListHeader from "@/components/ListHeader"; | ||||
| import ResultsList from "@/components/ResultsList"; | ||||
| import Settings from "@/pages/SettingsPage"; | ||||
| import Activity from "@/pages/ActivityPage"; | ||||
| import SeasonedButton from "@/components/ui/SeasonedButton"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import PageHeader from "@/components/PageHeader.vue"; | ||||
|   import ResultsList from "@/components/ResultsList.vue"; | ||||
|   import Settings from "@/pages/SettingsPage.vue"; | ||||
|   import Activity from "@/pages/ActivityPage.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import { getEmoji, getUserRequests, getSettings, logout } from "../api"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import type { ListResults } from "../interfaces/IList"; | ||||
|  | ||||
| import { getEmoji, getUserRequests, getSettings, logout } from "@/api"; | ||||
|   const emoji: Ref<string> = ref(""); | ||||
|   const results: Ref<Array<ListResults>> = ref([]); | ||||
|   const totalResults: Ref<number> = ref(-1); | ||||
|   const showSettings: Ref<boolean> = ref(); | ||||
|   const showActivity: Ref<boolean> = ref(); | ||||
|  | ||||
| export default { | ||||
|   components: { ListHeader, ResultsList, Settings, Activity, SeasonedButton }, | ||||
|   data() { | ||||
|     return { | ||||
|       emoji: "", | ||||
|       results: undefined, | ||||
|       totalResults: undefined, | ||||
|       showSettings: false, | ||||
|       showActivity: false | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters("user", ["loggedIn", "username", "settings"]), | ||||
|     resultCount() { | ||||
|       if (this.results === undefined) return; | ||||
|   const store = useStore(); | ||||
|  | ||||
|       const loadedResults = this.results.length; | ||||
|       const totalResults = this.totalResults < 10000 ? this.totalResults : "∞"; | ||||
|       return `${loadedResults} of ${totalResults} results`; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("user", ["logout", "setSettings"]), | ||||
|     toggleSettings() { | ||||
|       this.showSettings = this.showSettings ? false : true; | ||||
|   const loggedIn: Ref<boolean> = computed(() => store.getters["user/loggedIn"]); | ||||
|   const username: Ref<string> = computed(() => store.getters["user/username"]); | ||||
|   const settings: Ref<object> = computed(() => store.getters["user/settings"]); | ||||
|  | ||||
|       this.updateQueryParams("settings", this.showSettings); | ||||
|     }, | ||||
|     updateQueryParams(key, value = false) { | ||||
|       const params = new URLSearchParams(window.location.search); | ||||
|       if (params.has(key)) { | ||||
|         params.delete(key); | ||||
|       } | ||||
|   const resultCount: ComputedRef<number | string> = computed(() => { | ||||
|     const currentCount = results?.value?.length || 0; | ||||
|     const totalCount = totalResults.value < 10000 ? totalResults.value : "∞"; | ||||
|     return `${currentCount} of ${totalCount} results`; | ||||
|   }); | ||||
|  | ||||
|       if (value) { | ||||
|         params.append(key, value); | ||||
|       } | ||||
|   // Component loaded actions | ||||
|   getUserRequests().then(requestResults => { | ||||
|     if (!requestResults?.results) return; | ||||
|     results.value = requestResults.results; | ||||
|     totalResults.value = requestResults.total_results; | ||||
|   }); | ||||
|  | ||||
|       window.history.replaceState( | ||||
|         {}, | ||||
|         "search", | ||||
|         `${window.location.protocol}//${window.location.hostname}${ | ||||
|           window.location.port ? `:${window.location.port}` : "" | ||||
|         }${window.location.pathname}${ | ||||
|           params.toString().length ? `?${params}` : "" | ||||
|         }` | ||||
|       ); | ||||
|     }, | ||||
|     toggleActivity() { | ||||
|       this.showActivity = this.showActivity == true ? false : true; | ||||
|       this.updateQueryParams("activity", this.showActivity); | ||||
|     }, | ||||
|     _logout() { | ||||
|       logout().then(() => { | ||||
|         this.logout(); | ||||
|         this.$router.push("home"); | ||||
|       }); | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     if (!this.settings) { | ||||
|       getSettings().then(resp => { | ||||
|         const { settings } = resp; | ||||
|         if (settings) this.setSettings(settings); | ||||
|       }); | ||||
|     } | ||||
|   getEmoji().then(resp => (emoji.value = resp?.emoji)); | ||||
|  | ||||
|     if (this.loggedIn) { | ||||
|       this.showSettings = window.location.toString().includes("settings=true"); | ||||
|       this.showActivity = window.location.toString().includes("activity=true"); | ||||
|   showSettings.value = window.location.toString().includes("settings=true"); | ||||
|   showActivity.value = window.location.toString().includes("activity=true"); | ||||
|   // Component loaded actions end | ||||
|  | ||||
|       getUserRequests().then(results => { | ||||
|         this.results = results.results; | ||||
|         this.totalResults = results.total_results; | ||||
|       }); | ||||
|  | ||||
|       getEmoji().then(resp => { | ||||
|         const { emoji } = resp; | ||||
|         this.emoji = emoji; | ||||
|       }); | ||||
|     } | ||||
|   function toggleSettings() { | ||||
|     showSettings.value = !showSettings.value; | ||||
|     updateQueryParams("settings", showSettings.value); | ||||
|   } | ||||
|  | ||||
|   function toggleActivity() { | ||||
|     showActivity.value = !showActivity.value; | ||||
|     updateQueryParams("activity", showActivity.value); | ||||
|   } | ||||
|  | ||||
|   function _logout() { | ||||
|     store.dispatch("user/logout"); | ||||
|   } | ||||
|  | ||||
|   function updateQueryParams(key, value = false) { | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     if (params.has(key)) { | ||||
|       params.delete(key); | ||||
|     } | ||||
|  | ||||
|     if (value) { | ||||
|       params.append(key, `${value}`); | ||||
|     } | ||||
|  | ||||
|     window.history.replaceState( | ||||
|       {}, | ||||
|       "search", | ||||
|       `${window.location.protocol}//${window.location.hostname}${ | ||||
|         window.location.port ? `:${window.location.port}` : "" | ||||
|       }${window.location.pathname}${ | ||||
|         params.toString().length ? `?${params}` : "" | ||||
|       }` | ||||
|     ); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .button--group { | ||||
|   display: flex; | ||||
| } | ||||
|  | ||||
| // DUPLICATE CODE | ||||
| .profile { | ||||
|   &__header { | ||||
|   .button--group { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: space-between; | ||||
|     padding: 20px; | ||||
|     border-bottom: 1px solid $text-color-5; | ||||
|   } | ||||
|  | ||||
|     @include mobile-only { | ||||
|       flex-direction: column; | ||||
|       align-items: flex-start; | ||||
|   // DUPLICATE CODE | ||||
|   .profile { | ||||
|     &__header { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: space-between; | ||||
|       padding: 20px; | ||||
|       border-bottom: 1px solid $text-color-5; | ||||
|  | ||||
|       .button--group { | ||||
|         padding-top: 2rem; | ||||
|       @include mobile-only { | ||||
|         flex-direction: column; | ||||
|         align-items: flex-start; | ||||
|  | ||||
|         .button--group { | ||||
|           padding-top: 2rem; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       @include tablet-min { | ||||
|         padding: 29px 30px; | ||||
|       } | ||||
|       @include tablet-landscape-min { | ||||
|         padding: 29px 50px; | ||||
|       } | ||||
|       @include desktop-min { | ||||
|         padding: 29px 60px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     @include tablet-min { | ||||
|       padding: 29px 30px; | ||||
|     } | ||||
|     @include tablet-landscape-min { | ||||
|       padding: 29px 50px; | ||||
|     } | ||||
|     @include desktop-min { | ||||
|       padding: 29px 60px; | ||||
|     &__title { | ||||
|       margin: 0; | ||||
|       font-size: 16px; | ||||
|       line-height: 16px; | ||||
|       color: $text-color; | ||||
|       font-weight: 300; | ||||
|       @include tablet-min { | ||||
|         font-size: 18px; | ||||
|         line-height: 18px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   &__title { | ||||
|     margin: 0; | ||||
|     font-size: 16px; | ||||
|     line-height: 16px; | ||||
|     color: $text-color; | ||||
|     font-weight: 300; | ||||
|     @include tablet-min { | ||||
|       font-size: 18px; | ||||
|       line-height: 18px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -2,146 +2,159 @@ | ||||
|   <section> | ||||
|     <h1>Register new user</h1> | ||||
|  | ||||
|     <div class="form"> | ||||
|     <form class="form" ref="formElement"> | ||||
|       <seasoned-input | ||||
|         ref="username" | ||||
|         placeholder="username" | ||||
|         icon="Email" | ||||
|         type="email" | ||||
|         :value.sync="username" | ||||
|         @enter="submit" | ||||
|         v-model="username" | ||||
|         @keydown.enter="focusOnNextElement" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-input | ||||
|         placeholder="password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         :value.sync="password" | ||||
|         @enter="submit" | ||||
|         v-model="password" | ||||
|         @keydown.enter="focusOnNextElement" | ||||
|       /> | ||||
|       <seasoned-input | ||||
|         placeholder="repeat password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         :value.sync="passwordRepeat" | ||||
|         @enter="submit" | ||||
|         v-model="passwordRepeat" | ||||
|         @keydown.enter="submit" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-button @click="submit">Register</seasoned-button> | ||||
|     </div> | ||||
|     </form> | ||||
|  | ||||
|     <router-link class="link" to="/signin" | ||||
|       >Have a user? Sign in here</router-link | ||||
|     > | ||||
|  | ||||
|     <seasoned-messages :messages.sync="messages"></seasoned-messages> | ||||
|     <seasoned-messages v-model:messages="messages"></seasoned-messages> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions } from "vuex"; | ||||
| import { register } from "@/api"; | ||||
| import SeasonedButton from "@/components/ui/SeasonedButton"; | ||||
| import SeasonedInput from "@/components/ui/SeasonedInput"; | ||||
| import SeasonedMessages from "@/components/ui/SeasonedMessages"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, onMounted } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { useRouter } from "vue-router"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import { register } from "../api"; | ||||
|   import { focusFirstFormInput, focusOnNextElement } from "../utils"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type IErrorMessage from "../interfaces/IErrorMessage"; | ||||
|  | ||||
| export default { | ||||
|   components: { SeasonedButton, SeasonedInput, SeasonedMessages }, | ||||
|   data() { | ||||
|     return { | ||||
|       messages: [], | ||||
|       username: null, | ||||
|       password: null, | ||||
|       passwordRepeat: null | ||||
|     }; | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("user", ["login"]), | ||||
|     submit() { | ||||
|       this.messages = []; | ||||
|       let { username, password, passwordRepeat } = this; | ||||
|   const username: Ref<string> = ref(""); | ||||
|   const password: Ref<string> = ref(""); | ||||
|   const passwordRepeat: Ref<string> = ref(""); | ||||
|   const messages: Ref<IErrorMessage[]> = ref([]); | ||||
|   const formElement: Ref<HTMLFormElement> = ref(null); | ||||
|  | ||||
|       if (username == null || username.length == 0) { | ||||
|         this.messages.push({ type: "error", title: "Missing username" }); | ||||
|         return; | ||||
|       } else if (password == null || password.length == 0) { | ||||
|         this.messages.push({ type: "error", title: "Missing password" }); | ||||
|         return; | ||||
|       } else if (passwordRepeat == null || passwordRepeat.length == 0) { | ||||
|         this.messages.push({ type: "error", title: "Missing repeat password" }); | ||||
|         return; | ||||
|       } else if (passwordRepeat != password) { | ||||
|         this.messages.push({ type: "error", title: "Passwords do not match" }); | ||||
|         return; | ||||
|   const store = useStore(); | ||||
|   const router = useRouter(); | ||||
|  | ||||
|   onMounted(() => focusFirstFormInput(formElement.value)); | ||||
|  | ||||
|   function clearMessages() { | ||||
|     messages.value = []; | ||||
|   } | ||||
|  | ||||
|   function addErrorMessage(message: string, title?: string) { | ||||
|     messages.value.push({ message, title, type: "error" }); | ||||
|   } | ||||
|  | ||||
|   function addWarningMessage(message: string, title?: string) { | ||||
|     messages.value.push({ message, title, type: "warning" }); | ||||
|   } | ||||
|  | ||||
|   function validate(): Promise<boolean> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if (!username.value || username?.value?.length === 0) { | ||||
|         addWarningMessage("Missing username", "Validation error"); | ||||
|         return reject(); | ||||
|       } | ||||
|  | ||||
|       this.registerUser(username, password); | ||||
|     }, | ||||
|     registerUser(username, password) { | ||||
|       register(username, password) | ||||
|         .then(data => { | ||||
|           if (data.success && this.login()) { | ||||
|             this.$router.push({ name: "profile" }); | ||||
|           } | ||||
|         }) | ||||
|         .catch(error => { | ||||
|           if (error.status === 401) { | ||||
|             this.messages.push({ | ||||
|               type: "error", | ||||
|               title: "Access denied", | ||||
|               message: "Incorrect username or password" | ||||
|             }); | ||||
|           } else { | ||||
|             this.messages.push({ | ||||
|               type: "error", | ||||
|               title: "Unexpected error", | ||||
|               message: error.message | ||||
|             }); | ||||
|           } | ||||
|         }); | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     try { | ||||
|       this.$refs.username.$el.getElementsByTagName("input")[0].focus(); | ||||
|     } catch {} | ||||
|       if (!password.value || password?.value?.length === 0) { | ||||
|         addWarningMessage("Missing password", "Validation error"); | ||||
|         return reject(); | ||||
|       } | ||||
|  | ||||
|       if (passwordRepeat.value == null || passwordRepeat.value.length == 0) { | ||||
|         addWarningMessage("Missing repeat password", "Validation error"); | ||||
|         return reject(); | ||||
|       } | ||||
|       if (passwordRepeat != password) { | ||||
|         addWarningMessage("Passwords do not match", "Validation error"); | ||||
|         return reject(); | ||||
|       } | ||||
|  | ||||
|       resolve(true); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function submit() { | ||||
|     clearMessages(); | ||||
|     validate().then(registerUser); | ||||
|   } | ||||
|  | ||||
|   function registerUser() { | ||||
|     register(username.value, password.value) | ||||
|       .then(data => { | ||||
|         if (data?.success && store.dispatch("user/login")) { | ||||
|           router.push({ name: "profile" }); | ||||
|         } | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         if (error?.status === 401) { | ||||
|           return addErrorMessage( | ||||
|             "Incorrect username or password", | ||||
|             "Access denied" | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         addErrorMessage(error?.message, "Unexpected error"); | ||||
|       }); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
|   @import "src/scss/variables"; | ||||
|  | ||||
| section { | ||||
|   padding: 1.3rem; | ||||
|   section { | ||||
|     padding: 1.3rem; | ||||
|  | ||||
|   @include tablet-min { | ||||
|     padding: 4rem; | ||||
|   } | ||||
|     @include tablet-min { | ||||
|       padding: 4rem; | ||||
|     } | ||||
|  | ||||
|   .form > div, | ||||
|   input, | ||||
|   button { | ||||
|     margin-bottom: 1rem; | ||||
|     .form > div, | ||||
|     input, | ||||
|     button { | ||||
|       margin-bottom: 1rem; | ||||
|  | ||||
|     &:last-child { | ||||
|       margin-bottom: 0px; | ||||
|       &:last-child { | ||||
|         margin-bottom: 0px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     h1 { | ||||
|       margin: 0; | ||||
|       line-height: 16px; | ||||
|       color: $text-color; | ||||
|       font-weight: 300; | ||||
|       margin-bottom: 20px; | ||||
|       text-transform: uppercase; | ||||
|     } | ||||
|  | ||||
|     .link { | ||||
|       display: block; | ||||
|       width: max-content; | ||||
|       margin-top: 1rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   h1 { | ||||
|     margin: 0; | ||||
|     line-height: 16px; | ||||
|     color: $text-color; | ||||
|     font-weight: 300; | ||||
|     margin-bottom: 20px; | ||||
|     text-transform: uppercase; | ||||
|   } | ||||
|  | ||||
|   .link { | ||||
|     display: block; | ||||
|     width: max-content; | ||||
|     margin-top: 1rem; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -2,24 +2,17 @@ | ||||
|   <ResultsSection title="Requests" :apiFunction="getRequests" /> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ResultsSection from "@/components/ResultsSection"; | ||||
| import { getRequests } from "@/api"; | ||||
|  | ||||
| export default { | ||||
|   components: { ResultsSection }, | ||||
|   methods: { | ||||
|     getRequests: getRequests | ||||
|   } | ||||
| }; | ||||
| <script setup lang="ts"> | ||||
|   import ResultsSection from "@/components/ResultsSection.vue"; | ||||
|   import { getRequests } from "../api"; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .fullwidth-button { | ||||
|   width: 100%; | ||||
|   margin: 1rem 0; | ||||
|   padding-bottom: 2rem; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| } | ||||
|   .fullwidth-button { | ||||
|     width: 100%; | ||||
|     margin: 1rem 0; | ||||
|     padding-bottom: 2rem; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|   } | ||||
| </style> | ||||
|   | ||||
| @@ -5,93 +5,104 @@ | ||||
|         <span>Search filter:</span> | ||||
|  | ||||
|         <toggle-button | ||||
|           :options="['All', 'movie', 'show', 'person']" | ||||
|           :selected="mediaType" | ||||
|           :options="toggleOptions" | ||||
|           v-model:selected="mediaType" | ||||
|           @change="toggleChanged" | ||||
|         /> | ||||
|       </label> | ||||
|     </div> | ||||
|  | ||||
|     <ResultsSection :title="title" :apiFunction="searchTmdb" /> | ||||
|     <ResultsSection v-if="query" :title="title" :apiFunction="search" /> | ||||
|     <h1 v-else class="no-results">No query found, please search above</h1> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { searchTmdb } from "@/api"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed } from "vue"; | ||||
|   import { useRoute, useRouter } from "vue-router"; | ||||
|   import { searchTmdb } from "../api"; | ||||
|  | ||||
| import ResultsSection from "@/components/ResultsSection"; | ||||
| import ListHeader from "@/components/ListHeader"; | ||||
| import ToggleButton from "@/components/ui/ToggleButton"; | ||||
|   import ResultsSection from "@/components/ResultsSection.vue"; | ||||
|   import PageHeader from "@/components/PageHeader.vue"; | ||||
|   import ToggleButton from "@/components/ui/ToggleButton.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { ListTypes } from "../interfaces/IList"; | ||||
|  | ||||
| export default { | ||||
|   components: { ResultsSection, ListHeader, ToggleButton }, | ||||
|   data() { | ||||
|     return { | ||||
|       query: "", | ||||
|       page: 1, | ||||
|       adult: false, | ||||
|       mediaType: null | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     title() { | ||||
|       return `Search results: ${this.query}`; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     searchTmdb(page = this.page) { | ||||
|       if (this.query && this.query.length) | ||||
|         return searchTmdb(this.query, page, this.adult, this.mediaType); | ||||
|     }, | ||||
|     toggleChanged(value) { | ||||
|       if (["movie", "show", "person"].includes(value.toLowerCase())) { | ||||
|         this.mediaType = value.toLowerCase(); | ||||
|       } else { | ||||
|         this.mediaType = null; | ||||
|       } | ||||
|       this.updateQueryParams(); | ||||
|     }, | ||||
|     updateQueryParams() { | ||||
|       const { query, page, adult, media_type } = this.$route.query; | ||||
|   // interface ISearchParams { | ||||
|   //   query: string; | ||||
|   //   page: string; | ||||
|   //   adult: string; | ||||
|   //   media_type: string; | ||||
|   // } | ||||
|  | ||||
|       this.$router.push({ | ||||
|         path: "search", | ||||
|         query: { | ||||
|           ...this.$route.query, | ||||
|           media_type: this.mediaType | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     const { query, page, adult, media_type } = this.$route.query; | ||||
|   const route = useRoute(); | ||||
|   const router = useRouter(); | ||||
|  | ||||
|     if (!query) { | ||||
|       // abort | ||||
|       console.error("abort, no query"); | ||||
|     } | ||||
|     this.query = decodeURIComponent(query); | ||||
|     this.page = page || 1; | ||||
|     this.adult = adult || this.adult; | ||||
|     this.mediaType = media_type || this.mediaType; | ||||
|   const toggleOptions = ["all", ...Object.values(ListTypes)]; | ||||
|   const query: Ref<string> = ref(null); | ||||
|   const page: Ref<number> = ref(1); | ||||
|   const adult: Ref<boolean> = ref(false); | ||||
|   const mediaType: Ref<ListTypes> = ref(null); | ||||
|  | ||||
|     // this.searchTmdb(); | ||||
|   const title = computed(() => `Search results: ${query.value}`); | ||||
|  | ||||
|   const urlQuery = route.query; | ||||
|   if (urlQuery && urlQuery?.query) { | ||||
|     query.value = decodeURIComponent(urlQuery?.query?.toString()); | ||||
|     page.value = Number(urlQuery?.page) || 1; | ||||
|     adult.value = Boolean(urlQuery?.adult) || adult.value; | ||||
|     mediaType.value = (urlQuery?.media_type as ListTypes) || mediaType.value; | ||||
|   } | ||||
|  | ||||
|   let search = ( | ||||
|     _page = page.value || 1, | ||||
|     _mediaType = mediaType.value || "all" | ||||
|   ) => { | ||||
|     return searchTmdb(query.value, _page, adult.value, _mediaType); | ||||
|   }; | ||||
|  | ||||
|   function toggleChanged() { | ||||
|     updateQueryParams(); | ||||
|   } | ||||
|  | ||||
|   function updateQueryParams() { | ||||
|     const { query, page, adult, media_type } = route.query; | ||||
|  | ||||
|     router.push({ | ||||
|       path: "search", | ||||
|       query: { | ||||
|         ...route.query, | ||||
|         media_type: mediaType.value | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .filter { | ||||
|   margin-top: 0.5rem; | ||||
|   margin-left: 1.25rem; | ||||
|   display: inline-flex; | ||||
|   flex-direction: column; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   span { | ||||
|     font-size: 1.1rem; | ||||
|     line-height: 1; | ||||
|     margin: 0.5rem 0; | ||||
|     font-weight: 300; | ||||
|   .filter { | ||||
|     margin-top: 0.5rem; | ||||
|     margin-left: 1.25rem; | ||||
|     display: inline-flex; | ||||
|     flex-direction: column; | ||||
|  | ||||
|     span { | ||||
|       font-size: 1.1rem; | ||||
|       line-height: 1; | ||||
|       margin: 0.5rem 0; | ||||
|       font-weight: 300; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .no-results { | ||||
|     margin-top: 3rem; | ||||
|     display: block; | ||||
|     text-align: center; | ||||
|  | ||||
|     @include mobile { | ||||
|       padding: 0 1rem; | ||||
|       font-size: 1.5rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,227 +1,77 @@ | ||||
| <template> | ||||
|   <section class="profile"> | ||||
|     <div class="profile__content" v-if="loggedIn"> | ||||
|       <section class="settings"> | ||||
|         <h3 class="settings__header">Plex account</h3> | ||||
|   <section class="settings"> | ||||
|     <link-plex-account @reload="reloadSettings" /> | ||||
|  | ||||
|         <div v-if="!plexId"> | ||||
|           <span class="settings__info" | ||||
|             >Sign in to your plex account to get information about recently | ||||
|             added movies and to see your watch history</span | ||||
|           > | ||||
|     <hr class="setting__divider" /> | ||||
|  | ||||
|           <form class="form"> | ||||
|             <seasoned-input | ||||
|               placeholder="plex username" | ||||
|               type="email" | ||||
|               :value.sync="plexUsername" | ||||
|             /> | ||||
|             <seasoned-input | ||||
|               placeholder="plex password" | ||||
|               type="password" | ||||
|               :value.sync="plexPassword" | ||||
|               @enter="authenticatePlex" | ||||
|             > | ||||
|             </seasoned-input> | ||||
|     <change-password /> | ||||
|  | ||||
|             <seasoned-button @click="authenticatePlex" | ||||
|               >link plex account</seasoned-button | ||||
|             > | ||||
|           </form> | ||||
|         </div> | ||||
|         <div v-else> | ||||
|           <span class="settings__info" | ||||
|             >Awesome, your account is already authenticated with plex! Enjoy | ||||
|             viewing your seasoned search history, plex watch history and | ||||
|             real-time torrent download progress.</span | ||||
|           > | ||||
|           <seasoned-button @click="unauthenticatePlex" | ||||
|             >un-link plex account</seasoned-button | ||||
|           > | ||||
|         </div> | ||||
|         <seasoned-messages :messages.sync="messages" /> | ||||
|  | ||||
|         <hr class="setting__divider" /> | ||||
|  | ||||
|         <h3 class="settings__header">Change password</h3> | ||||
|         <form class="form"> | ||||
|           <seasoned-input | ||||
|             placeholder="new password" | ||||
|             icon="Keyhole" | ||||
|             type="password" | ||||
|             :value.sync="newPassword" | ||||
|           /> | ||||
|  | ||||
|           <seasoned-input | ||||
|             placeholder="repeat new password" | ||||
|             icon="Keyhole" | ||||
|             type="password" | ||||
|             :value.sync="newPasswordRepeat" | ||||
|           /> | ||||
|  | ||||
|           <seasoned-button @click="changePassword" | ||||
|             >change password</seasoned-button | ||||
|           > | ||||
|         </form> | ||||
|  | ||||
|         <hr class="setting__divider" /> | ||||
|       </section> | ||||
|     </div> | ||||
|  | ||||
|     <section class="not-found" v-else> | ||||
|       <div class="not-found__content"> | ||||
|         <h2 class="not-found__title">Authentication Request Failed</h2> | ||||
|         <router-link :to="{ name: 'signin' }" exact title="Sign in here"> | ||||
|           <button class="not-found__button button">Sign In</button> | ||||
|         </router-link> | ||||
|       </div> | ||||
|     </section> | ||||
|     <hr class="setting__divider" /> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapGetters, mapActions } from "vuex"; | ||||
| import SeasonedInput from "@/components/ui/SeasonedInput"; | ||||
| import SeasonedButton from "@/components/ui/SeasonedButton"; | ||||
| import SeasonedMessages from "@/components/ui/SeasonedMessages"; | ||||
| <script setup lang="ts"> | ||||
|   import { useStore } from "vuex"; | ||||
|   import ChangePassword from "@/components/profile/ChangePassword.vue"; | ||||
|   import LinkPlexAccount from "@/components/profile/LinkPlexAccount.vue"; | ||||
|   import { getSettings } from "../api"; | ||||
|  | ||||
| import { linkPlexAccount, unlinkPlexAccount, getSettings } from "@/api"; | ||||
|   const store = useStore(); | ||||
|  | ||||
| export default { | ||||
|   components: { SeasonedInput, SeasonedButton, SeasonedMessages }, | ||||
|   data() { | ||||
|     return { | ||||
|       messages: [], | ||||
|       plexUsername: null, | ||||
|       plexPassword: null, | ||||
|       newPassword: null, | ||||
|       newPasswordRepeat: null, | ||||
|       emoji: null | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters("user", ["loggedIn", "plexId", "settings"]) | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("user", ["setSettings"]), | ||||
|     changePassword() { | ||||
|       return; | ||||
|     }, | ||||
|     created() { | ||||
|       if (!this.settings) this.reloadSettings(); | ||||
|     }, | ||||
|     reloadSettings() { | ||||
|       return getSettings().then(response => { | ||||
|         const { settings } = response; | ||||
|         if (settings) this.setSettings(settings); | ||||
|       }); | ||||
|     }, | ||||
|     async authenticatePlex() { | ||||
|       let username = this.plexUsername; | ||||
|       let password = this.plexPassword; | ||||
|   function reloadSettings() { | ||||
|     return getSettings().then(response => { | ||||
|       const { settings } = response; | ||||
|       if (!settings) return; | ||||
|  | ||||
|       const { success, message } = await linkPlexAccount(username, password); | ||||
|  | ||||
|       if (success) { | ||||
|         this.reloadSettings(); | ||||
|         this.plexUsername = ""; | ||||
|         this.plexPassword = ""; | ||||
|       } | ||||
|  | ||||
|       this.messages.push({ | ||||
|         type: success ? "success" : "error", | ||||
|         title: success ? "Authenticated with plex" : "Something went wrong", | ||||
|         message: message | ||||
|       }); | ||||
|     }, | ||||
|     async unauthenticatePlex() { | ||||
|       const response = await unlinkPlexAccount(); | ||||
|  | ||||
|       if (response.success) this.reloadSettings(); | ||||
|  | ||||
|       this.messages.push({ | ||||
|         type: response.success ? "success" : "error", | ||||
|         title: response.success | ||||
|           ? "Unlinked plex account " | ||||
|           : "Something went wrong", | ||||
|         message: response.message | ||||
|       }); | ||||
|     } | ||||
|       store.dispatch("user/setSettings", settings); | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
| <style lang="scss"> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| a { | ||||
|   text-decoration: none; | ||||
| } | ||||
|   .settings { | ||||
|     padding: 3rem; | ||||
|  | ||||
| // DUPLICATE CODE | ||||
| .form { | ||||
|   > div, | ||||
|   input, | ||||
|   button { | ||||
|     margin-bottom: 1rem; | ||||
|     @include mobile-only { | ||||
|       padding: 1rem; | ||||
|     } | ||||
|  | ||||
|     &:last-child { | ||||
|       margin-bottom: 0px; | ||||
|     &__header { | ||||
|       margin: 0; | ||||
|       line-height: 16px; | ||||
|       color: $text-color; | ||||
|       font-weight: 300; | ||||
|       margin-bottom: 20px; | ||||
|       text-transform: uppercase; | ||||
|     } | ||||
|  | ||||
|     &__info { | ||||
|       display: block; | ||||
|       margin-bottom: 25px; | ||||
|     } | ||||
|  | ||||
|     hr { | ||||
|       display: block; | ||||
|       height: 1px; | ||||
|       border: 0; | ||||
|       border-bottom: 1px solid $text-color-50; | ||||
|       margin-top: 30px; | ||||
|       margin-bottom: 70px; | ||||
|       margin-left: 20px; | ||||
|       width: 96%; | ||||
|       text-align: left; | ||||
|     } | ||||
|  | ||||
|     span { | ||||
|       font-weight: 200; | ||||
|       size: 16px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__group { | ||||
|     justify-content: unset; | ||||
|     &__input-icon { | ||||
|       margin-top: 8px; | ||||
|       height: 22px; | ||||
|       width: 22px; | ||||
|     } | ||||
|     &-input { | ||||
|       padding: 10px 5px 10px 45px; | ||||
|       height: 40px; | ||||
|       font-size: 17px; | ||||
|       width: 75%; | ||||
|       @include desktop-min { | ||||
|         width: 400px; | ||||
|       } | ||||
|     } | ||||
|   a { | ||||
|     text-decoration: none; | ||||
|   } | ||||
| } | ||||
| .settings { | ||||
|   padding: 3rem; | ||||
|  | ||||
|   @include mobile-only { | ||||
|     padding: 1rem; | ||||
|   } | ||||
|  | ||||
|   &__header { | ||||
|     margin: 0; | ||||
|     line-height: 16px; | ||||
|     color: $text-color; | ||||
|     font-weight: 300; | ||||
|     margin-bottom: 20px; | ||||
|     text-transform: uppercase; | ||||
|   } | ||||
|   &__info { | ||||
|     display: block; | ||||
|     margin-bottom: 25px; | ||||
|   } | ||||
|   hr { | ||||
|     display: block; | ||||
|     height: 1px; | ||||
|     border: 0; | ||||
|     border-bottom: 1px solid $text-color-50; | ||||
|     margin-top: 30px; | ||||
|     margin-bottom: 70px; | ||||
|     margin-left: 20px; | ||||
|     width: 96%; | ||||
|     text-align: left; | ||||
|   } | ||||
|   span { | ||||
|     font-weight: 200; | ||||
|     size: 16px; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -2,135 +2,130 @@ | ||||
|   <section> | ||||
|     <h1>Sign in</h1> | ||||
|  | ||||
|     <div class="form"> | ||||
|     <form class="form" ref="formElement"> | ||||
|       <seasoned-input | ||||
|         ref="username" | ||||
|         placeholder="username" | ||||
|         icon="Email" | ||||
|         type="email" | ||||
|         @enter="submit" | ||||
|         :value.sync="username" | ||||
|         v-model="username" | ||||
|         @keydown.enter="focusOnNextElement" | ||||
|       /> | ||||
|       <seasoned-input | ||||
|         placeholder="password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         :value.sync="password" | ||||
|         @enter="submit" | ||||
|         v-model="password" | ||||
|         @keydown.enter="submit" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-button @click="submit">sign in</seasoned-button> | ||||
|     </div> | ||||
|     </form> | ||||
|     <router-link class="link" to="/register" | ||||
|       >Don't have a user? Register here</router-link | ||||
|     > | ||||
|  | ||||
|     <seasoned-messages :messages.sync="messages"></seasoned-messages> | ||||
|     <seasoned-messages v-model:messages="messages" /> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions } from "vuex"; | ||||
| import { login } from "@/api"; | ||||
| import SeasonedInput from "@/components/ui/SeasonedInput"; | ||||
| import SeasonedButton from "@/components/ui/SeasonedButton"; | ||||
| import SeasonedMessages from "@/components/ui/SeasonedMessages"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, onMounted } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { useRouter } from "vue-router"; | ||||
|   import SeasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import { login } from "../api"; | ||||
|   import { focusFirstFormInput, focusOnNextElement } from "../utils"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type IErrorMessage from "../interfaces/IErrorMessage"; | ||||
|  | ||||
| export default { | ||||
|   components: { SeasonedInput, SeasonedButton, SeasonedMessages }, | ||||
|   data() { | ||||
|     return { | ||||
|       messages: [], | ||||
|       username: null, | ||||
|       password: null | ||||
|     }; | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("user", ["login"]), | ||||
|     submit() { | ||||
|       this.messages = []; | ||||
|       let { username, password } = this; | ||||
|   const username: Ref<string> = ref(""); | ||||
|   const password: Ref<string> = ref(""); | ||||
|   const messages: Ref<IErrorMessage[]> = ref([]); | ||||
|   const formElement: Ref<HTMLFormElement> = ref(null); | ||||
|  | ||||
|       if (!username || username.length == 0) { | ||||
|         this.messages.push({ type: "error", title: "Missing username" }); | ||||
|         return; | ||||
|       } | ||||
|   const store = useStore(); | ||||
|   const router = useRouter(); | ||||
|  | ||||
|       if (!password || password.length == 0) { | ||||
|         this.messages.push({ type: "error", title: "Missing password" }); | ||||
|         return; | ||||
|       } | ||||
|   onMounted(() => focusFirstFormInput(formElement.value)); | ||||
|  | ||||
|       this.signin(username, password); | ||||
|     }, | ||||
|     signin(username, password) { | ||||
|       login(username, password, true) | ||||
|         .then(data => { | ||||
|           if (data.success && this.login()) { | ||||
|             this.$router.push({ name: "profile" }); | ||||
|           } | ||||
|         }) | ||||
|         .catch(error => { | ||||
|           if (error.status === 401) { | ||||
|             this.messages.push({ | ||||
|               type: "error", | ||||
|               title: "Access denied", | ||||
|               message: "Incorrect username or password" | ||||
|             }); | ||||
|           } else { | ||||
|             this.messages.push({ | ||||
|               type: "error", | ||||
|               title: "Unexpected error", | ||||
|               message: error.message | ||||
|             }); | ||||
|           } | ||||
|         }); | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     document.title = `Sign in — ${document.title}`; | ||||
|   }, | ||||
|   mounted() { | ||||
|     try { | ||||
|       this.$refs.username.$el.getElementsByTagName("input")[0].focus(); | ||||
|     } catch {} | ||||
|   function clearMessages() { | ||||
|     messages.value = []; | ||||
|   } | ||||
|  | ||||
|   function addErrorMessage(message: string, title?: string) { | ||||
|     messages.value.push({ message, title, type: "error" }); | ||||
|   } | ||||
|  | ||||
|   function addWarningMessage(message: string, title?: string) { | ||||
|     messages.value.push({ message, title, type: "warning" }); | ||||
|   } | ||||
|  | ||||
|   function validate(): Promise<boolean> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if (!username.value || username?.value?.length === 0) { | ||||
|         addWarningMessage("Missing username", "Validation error"); | ||||
|         return reject(); | ||||
|       } | ||||
|  | ||||
|       if (!password.value || password?.value?.length === 0) { | ||||
|         addWarningMessage("Missing password", "Validation error"); | ||||
|         return reject(); | ||||
|       } | ||||
|  | ||||
|       resolve(true); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function submit() { | ||||
|     clearMessages(); | ||||
|     validate().then(signin); | ||||
|   } | ||||
|  | ||||
|   function signin() { | ||||
|     login(username.value, password.value, true) | ||||
|       .then(data => { | ||||
|         if (data?.success && store.dispatch("user/login")) { | ||||
|           router.push({ name: "profile" }); | ||||
|         } | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         if (error?.status === 401) { | ||||
|           return addErrorMessage( | ||||
|             "Incorrect username or password", | ||||
|             "Access denied" | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         addErrorMessage(error?.message, "Unexpected error"); | ||||
|       }); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
|   @import "src/scss/variables"; | ||||
|  | ||||
| section { | ||||
|   padding: 1.3rem; | ||||
|   section { | ||||
|     padding: 1.3rem; | ||||
|  | ||||
|   @include tablet-min { | ||||
|     padding: 4rem; | ||||
|   } | ||||
|     @include tablet-min { | ||||
|       padding: 4rem; | ||||
|     } | ||||
|  | ||||
|   .form > div, | ||||
|   input, | ||||
|   button { | ||||
|     margin-bottom: 1rem; | ||||
|     h1 { | ||||
|       margin: 0; | ||||
|       line-height: 16px; | ||||
|       color: $text-color; | ||||
|       font-weight: 300; | ||||
|       margin-bottom: 20px; | ||||
|       text-transform: uppercase; | ||||
|     } | ||||
|  | ||||
|     &:last-child { | ||||
|       margin-bottom: 0px; | ||||
|     .link { | ||||
|       display: block; | ||||
|       width: max-content; | ||||
|       margin-top: 1rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   h1 { | ||||
|     margin: 0; | ||||
|     line-height: 16px; | ||||
|     color: $text-color; | ||||
|     font-weight: 300; | ||||
|     margin-bottom: 20px; | ||||
|     text-transform: uppercase; | ||||
|   } | ||||
|  | ||||
|   .link { | ||||
|     display: block; | ||||
|     width: max-content; | ||||
|     margin-top: 1rem; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										58
									
								
								src/pages/TorrentsPage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/pages/TorrentsPage.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <page-header title="Torrent search page" /> | ||||
|  | ||||
|     <section> | ||||
|       <div class="search-input-group"> | ||||
|         <seasoned-input | ||||
|           v-model="query" | ||||
|           type="torrents" | ||||
|           @keydown.enter="setTorrentQuery" | ||||
|           placeholder="Search torrents" | ||||
|         /> | ||||
|         <seasoned-button @click="setTorrentQuery">Search</seasoned-button> | ||||
|       </div> | ||||
|  | ||||
|       <active-torrents /> | ||||
|  | ||||
|       <TorrentList :query="torrentQuery" /> | ||||
|     </section> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref } from "vue"; | ||||
|   import PageHeader from "@/components/PageHeader.vue"; | ||||
|   import SeasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import TorrentList from "@/components/torrent/TorrentSearchResults.vue"; | ||||
|   import ActiveTorrents from "@/components/torrent/ActiveTorrents.vue"; | ||||
|   import { getValueFromUrlQuery, setUrlQueryParameter } from "../utils"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   const urlQuery = getValueFromUrlQuery("query"); | ||||
|  | ||||
|   const query: Ref<string> = ref(urlQuery || ""); | ||||
|   const torrentQuery: Ref<string> = ref(urlQuery); | ||||
|  | ||||
|   function setTorrentQuery() { | ||||
|     setUrlQueryParameter("query", query.value); | ||||
|     torrentQuery.value = query.value; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   section { | ||||
|     padding: 1.25rem; | ||||
|  | ||||
|     .search-input-group { | ||||
|       display: flex; | ||||
|  | ||||
|       margin-bottom: 2rem; | ||||
|  | ||||
|       button { | ||||
|         margin-left: 0.5rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
		Reference in New Issue
	
	Block a user